From ed70dbab698d81793b6e0da3ec8558804f06e2ce Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Wed, 24 Jun 2026 17:32:01 +0100 Subject: [PATCH 1/3] feat(tasks): bundle local skills for cloud runs --- apps/code/src/renderer/di/bindings.ts | 7 +- apps/code/src/renderer/di/container.ts | 10 +- packages/agent/package.json | 1 + .../claude/claude-agent.slash-command.test.ts | 25 ++ .../agent/src/adapters/claude/claude-agent.ts | 23 ++ .../claude/conversion/acp-to-sdk.test.ts | 18 + .../adapters/claude/conversion/acp-to-sdk.ts | 28 ++ .../agent/src/server/agent-server.test.ts | 103 +++++ packages/agent/src/server/agent-server.ts | 364 ++++++++++++++++-- packages/agent/tsup.config.ts | 7 +- packages/api-client/src/posthog-client.ts | 12 +- packages/core/src/editor/cloud-prompt.ts | 23 +- .../core/src/message-editor/content.test.ts | 25 ++ packages/core/src/message-editor/content.ts | 37 +- .../core/src/message-editor/suggestions.ts | 15 +- .../src/sessions/cloudArtifactIdentifiers.ts | 34 +- .../src/sessions/cloudArtifactService.test.ts | 92 ++++- .../core/src/sessions/cloudArtifactService.ts | 63 ++- packages/core/src/sessions/cloudPrompt.ts | 60 ++- packages/core/src/sessions/sessionEvents.ts | 37 +- packages/core/src/sessions/sessionService.ts | 74 +++- .../core/src/task-detail/taskCreationHost.ts | 3 + .../src/task-detail/taskCreationSaga.test.ts | 3 + .../core/src/task-detail/taskCreationSaga.ts | 1 + .../host-router/src/routers/skills.router.ts | 8 + packages/shared/src/index.ts | 2 + packages/shared/src/skills.ts | 1 + packages/shared/src/task.ts | 12 +- .../features/message-editor/commands.test.ts | 40 ++ .../src/features/message-editor/commands.ts | 18 + .../message-editor/components/PromptInput.tsx | 14 +- .../src/features/message-editor/draftStore.ts | 11 +- .../suggestions/getSuggestions.test.ts | 51 ++- .../suggestions/getSuggestions.ts | 13 +- .../message-editor/tiptap/CommandGhostText.ts | 3 + .../message-editor/tiptap/MentionChipNode.ts | 7 + .../tiptap/createSuggestionMention.ts | 3 + .../message-editor/tiptap/useDraftSync.ts | 6 + .../message-editor/tiptap/useTiptapEditor.ts | 3 + .../ui/src/features/message-editor/types.ts | 21 +- .../sessions/hooks/useSessionCallbacks.ts | 40 +- .../sessions/sessionServiceHost.test.ts | 105 +++++ .../features/sessions/sessionServiceHost.ts | 35 +- .../task-detail/components/TaskInput.tsx | 24 ++ .../task-detail/taskCreationHostImpl.ts | 2 + .../src/services/skills/schemas.ts | 19 + .../src/services/skills/skill-bundler.ts | 163 ++++++++ .../src/services/skills/skills.ts | 13 + pnpm-lock.yaml | 3 + 49 files changed, 1596 insertions(+), 86 deletions(-) create mode 100644 packages/ui/src/features/message-editor/commands.test.ts create mode 100644 packages/workspace-server/src/services/skills/skill-bundler.ts diff --git a/apps/code/src/renderer/di/bindings.ts b/apps/code/src/renderer/di/bindings.ts index b7f08d2618..9114fe06c3 100644 --- a/apps/code/src/renderer/di/bindings.ts +++ b/apps/code/src/renderer/di/bindings.ts @@ -55,7 +55,11 @@ import { GITHUB_CONNECT_CLIENT, type GithubConnectClient, } from "@posthog/core/onboarding/identifiers"; -import { CLOUD_ARTIFACT_READ_FILE_AS_BASE64 } from "@posthog/core/sessions/cloudArtifactIdentifiers"; +import { + type BundleLocalSkill, + CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL, + CLOUD_ARTIFACT_READ_FILE_AS_BASE64, +} from "@posthog/core/sessions/cloudArtifactIdentifiers"; import { LOCAL_HANDOFF_DIALOG, LOCAL_HANDOFF_HOST, @@ -262,6 +266,7 @@ export interface RendererBindings { [CODE_REVIEW_WORKSPACE_CLIENT]: CodeReviewWorkspaceClient; [REVERT_HUNK_SERVICE]: RevertHunkService; [SKILLS_WORKSPACE_CLIENT]: SkillsWorkspaceClient; + [CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL]: BundleLocalSkill; [CLOUD_ARTIFACT_READ_FILE_AS_BASE64]: ReadFileAsBase64; [LLM_GATEWAY_SERVICE]: LlmGatewayService; [TITLE_GENERATOR_FILE_READ_CLIENT]: FileReadClient; diff --git a/apps/code/src/renderer/di/container.ts b/apps/code/src/renderer/di/container.ts index 705ada1eba..9a071ea068 100644 --- a/apps/code/src/renderer/di/container.ts +++ b/apps/code/src/renderer/di/container.ts @@ -29,7 +29,10 @@ import { import { LLM_GATEWAY_SERVICE } from "@posthog/core/llm-gateway/identifiers"; import type { LlmGatewayService } from "@posthog/core/llm-gateway/llm-gateway"; import type { LlmMessage } from "@posthog/core/llm-gateway/schemas"; -import { CLOUD_ARTIFACT_READ_FILE_AS_BASE64 } from "@posthog/core/sessions/cloudArtifactIdentifiers"; +import { + CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL, + CLOUD_ARTIFACT_READ_FILE_AS_BASE64, +} from "@posthog/core/sessions/cloudArtifactIdentifiers"; import { LOCAL_HANDOFF_DIALOG, LOCAL_HANDOFF_HOST, @@ -365,6 +368,11 @@ container .toConstantValue((filePath: string) => trpcClient.fs.readFileAsBase64.query({ filePath }), ); +container + .bind(CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL) + .toConstantValue((skillBundleRef) => + hostTrpcClient.skills.bundleLocal.query(skillBundleRef), + ); container.bind(LLM_GATEWAY_SERVICE).toConstantValue({ prompt: ( messages: LlmMessage[], diff --git a/packages/agent/package.json b/packages/agent/package.json index b6f4326c64..78f3334b0e 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -139,6 +139,7 @@ "@opentelemetry/semantic-conventions": "^1.28.0", "@types/jsonwebtoken": "^9.0.10", "commander": "^14.0.2", + "fflate": "^0.8.2", "hono": "^4.11.7", "jsonwebtoken": "^9.0.2", "minimatch": "^10.0.3", diff --git a/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts b/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts index 0dc8536af8..850002be2c 100644 --- a/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts +++ b/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts @@ -119,6 +119,22 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => { expectsUnsupportedChunk: false, commandInMessage: null, }, + { + label: + "newly installed skill command is refreshed before unsupported check", + sessionId: "s-new-skill", + prompt: "/local-test-skill", + knownCommands: undefined, + supportedCommandsAfterReload: [ + { + name: "local-test-skill", + description: "Local test skill", + argumentHint: "", + }, + ], + expectsUnsupportedChunk: false, + commandInMessage: null, + }, { label: "known plugin/skill command with early idle is not flagged as unsupported", @@ -137,6 +153,11 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => { tc.sessionId, tc.knownCommands as Set | undefined, ); + if ("supportedCommandsAfterReload" in tc) { + vi.mocked(query.supportedCommands).mockResolvedValue([ + ...tc.supportedCommandsAfterReload, + ]); + } const promptPromise = agent.prompt({ sessionId: tc.sessionId, @@ -171,6 +192,10 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => { expect( findUnsupportedChunkText(client.sessionUpdate.mock.calls), ).toBeUndefined(); + if ("supportedCommandsAfterReload" in tc) { + expect(query.reloadSkills).toHaveBeenCalled(); + expect(query.supportedCommands).toHaveBeenCalled(); + } } }); }); diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 9d1456b398..e43a694e55 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -459,6 +459,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent { promptReplayed = true; } + if (commandMatch && !isLocalOnlyCommand) { + await this.refreshSlashCommandsForPrompt(commandMatch[1]); + } + if (this.session.promptRunning) { const isSteer = isSteerMeta(params._meta); if (isSteer) { @@ -2164,6 +2168,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { private async sendAvailableCommandsUpdate(): Promise { const commands = await this.session.query.supportedCommands(); + this.session.knownSlashCommands = collectKnownSlashCommands(commands); const available = getAvailableSlashCommands(commands); await this.client.sessionUpdate({ sessionId: this.sessionId, @@ -2175,6 +2180,24 @@ export class ClaudeAcpAgent extends BaseAcpAgent { this.updateBreakdownCategory("skills", estimateSkillsTokens(available)); } + private async refreshSlashCommandsForPrompt(command: string): Promise { + const commandName = command.slice(1); + if (this.session.knownSlashCommands?.has(commandName)) { + return; + } + + try { + await this.session.query.reloadSkills(); + await this.sendAvailableCommandsUpdate(); + } catch (error) { + this.logger.warn("Failed to refresh slash commands before prompt", { + sessionId: this.sessionId, + command, + error: error instanceof Error ? error.message : String(error), + }); + } + } + /** Update one category of the context-breakdown baseline so the next * `_posthog/usage_update` carries fresher numbers. No-op when the baseline * hasn't been initialized yet (e.g. in a unit-test session). */ diff --git a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts index a8b8ffe3e3..6af7b5dfa3 100644 --- a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts +++ b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts @@ -79,6 +79,24 @@ describe("promptToClaude", () => { expect(result.priority).toBe("next"); }); + it("adds local skill context before the visible slash command", () => { + const result = promptToClaude({ + sessionId: "session-1", + prompt: [{ type: "text", text: "/mimi" }], + _meta: { + localSkillContext: + "Apply the local skill instructions and include LOCAL_SKILL_MARKER.", + localSkillName: "mimi", + }, + }); + + expect(result.message.content).toHaveLength(1); + expect(result.message.content[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("LOCAL_SKILL_MARKER"), + }); + }); + it("leaves priority and shouldQuery unset for a normal message", () => { const result = promptToClaude({ sessionId: "session-1", diff --git a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts index 37d9a87a29..9c73b080f8 100644 --- a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts +++ b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts @@ -80,6 +80,18 @@ function transformMcpCommand(text: string): string { return text; } +function isLocalSkillCommandChunk( + chunk: PromptRequest["prompt"][number], + skillName: string, +): boolean { + if (chunk.type !== "text") { + return false; + } + + const match = chunk.text.trim().match(/^\/([^\s]+)(?:\s+[\s\S]*)?$/); + return match?.[1] === skillName; +} + function processPromptChunk( chunk: PromptRequest["prompt"][number], content: ContentBlockParam[], @@ -168,8 +180,24 @@ export function promptToClaude(prompt: PromptRequest): SDKUserMessage { if (typeof prContext === "string") { content.push(sdkText(prContext)); } + const localSkillContext = meta?.localSkillContext; + if (typeof localSkillContext === "string") { + content.push(sdkText(localSkillContext)); + } + const localSkillName = + typeof meta?.localSkillName === "string" ? meta.localSkillName : null; + let skippedLocalSkillCommand = false; for (const chunk of prompt.prompt) { + if ( + localSkillContext && + localSkillName && + !skippedLocalSkillCommand && + isLocalSkillCommandChunk(chunk, localSkillName) + ) { + skippedLocalSkillCommand = true; + continue; + } processPromptChunk(chunk, content, context); } diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index 5fa39907e5..fe8bc24fad 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -1,3 +1,6 @@ +import { createHash } from "node:crypto"; +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { zipSync } from "fflate"; import jwt from "jsonwebtoken"; import { type SetupServerApi, setupServer } from "msw/node"; import { @@ -225,6 +228,12 @@ function getNextTestPort(): number { return port; } +function exactArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} + // The Claude Agent SDK has an internal readMessages() loop that rejects with // "Query closed before response received" during cleanup. The SDK starts this // promise in the constructor without a .catch() handler, so the rejection is @@ -944,6 +953,100 @@ describe("AgentServer HTTP Mode", () => { const body = await response.json(); expect(body.error).toBe("No active session for this run"); }, 20000); + + it("rewrites a bundled local skill slash command before sending the prompt", async () => { + const skillDefinition = [ + "---", + "name: local-test-skill", + "description: Test skill", + "---", + "", + "Reply with LOCAL_SKILL_MARKER from the bundled skill.", + ].join("\n"); + const bundle = zipSync({ + "SKILL.md": new TextEncoder().encode(skillDefinition), + }); + const checksum = createHash("sha256") + .update(Buffer.from(bundle)) + .digest("hex"); + + const s = createServer(); + await s.start(); + const prompt = vi.fn( + async (_params: { + prompt: ContentBlock[]; + _meta?: Record; + }) => ({ stopReason: "cancelled" }) as { stopReason: string }, + ); + const downloadArtifact = vi.fn(async () => exactArrayBuffer(bundle)); + const serverInternals = s as unknown as { + session: { clientConnection: { prompt: typeof prompt } }; + posthogAPI: { downloadArtifact: typeof downloadArtifact }; + }; + serverInternals.session.clientConnection.prompt = prompt; + serverInternals.posthogAPI.downloadArtifact = downloadArtifact; + + const token = createToken(); + const response = await fetch(`http://localhost:${port}/command`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "skill-command", + method: "user_message", + params: { + content: "/local-test-skill with context", + artifacts: [ + { + id: "skill-artifact-1", + name: "local-test-skill.zip", + type: "skill_bundle", + source: "posthog_code_skill", + storage_path: "tasks/artifacts/local-test-skill.zip", + content_type: "application/zip", + metadata: { + skill_name: "local-test-skill", + skill_source: "user", + content_sha256: checksum, + bundle_format: "zip", + schema_version: 1, + }, + }, + ], + }, + }), + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + result?: { stopReason?: string }; + }; + expect(body.result?.stopReason).toBe("cancelled"); + expect(downloadArtifact).toHaveBeenCalledWith( + "test-task-id", + "test-run-id", + "tasks/artifacts/local-test-skill.zip", + ); + expect(prompt).toHaveBeenCalledOnce(); + + const sentPrompt = prompt.mock.calls[0]?.[0].prompt; + const sentMeta = prompt.mock.calls[0]?.[0]._meta; + const sentText = sentPrompt?.find( + (block): block is Extract => + block.type === "text", + )?.text; + + expect(sentText).toBe("/local-test-skill with context"); + expect(sentMeta?.localSkillContext).toContain( + 'local skill "/local-test-skill"', + ); + expect(sentMeta?.localSkillContext).toContain("LOCAL_SKILL_MARKER"); + expect(sentMeta?.localSkillContext).toContain("with context"); + expect(sentMeta?.localSkillName).toBe("local-test-skill"); + }, 20000); }); describe("404 handling", () => { diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 9e6405ddab..27d0983329 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -1,5 +1,6 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { basename, join } from "node:path"; +import { createHash } from "node:crypto"; +import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, relative } from "node:path"; import { pathToFileURL } from "node:url"; import type { ContentBlock, @@ -14,6 +15,7 @@ import { import { type ServerType, serve } from "@hono/node-server"; import { execGh } from "@posthog/git/gh"; import { getCurrentBranch } from "@posthog/git/queries"; +import { unzipSync } from "fflate"; import { Hono } from "hono"; import { z } from "zod"; import packageJson from "../../package.json" with { type: "json" }; @@ -229,6 +231,23 @@ interface ActiveSession { pendingHandoffGitState?: HandoffLocalGitState; } +interface InstalledSkillBundle { + skillName: string; + skillDefinition: string; + contentSha256: string; + skillRoot: string; +} + +interface BuiltPrompt { + prompt: ContentBlock[]; + meta?: Record; +} + +interface LocalSkillPromptContext { + skillName: string; + context: string; +} + function getTaskRunStateString( taskRun: TaskRun | null, key: string, @@ -259,6 +278,8 @@ export class AgentServer { private readonly evaluatedPrUrls = new Set(); private lastReportedBranch: string | null = null; private resumeState: ResumeState | null = null; + private installedSkillBundles = new Set(); + private installedSkillBundleInfo = new Map(); // Guards against concurrent session initialization. autoInitializeSession() and // the GET /events SSE handler can both call initializeSession() — the SSE connection // often arrives while newSession() is still awaited (this.session is still null), @@ -688,7 +709,7 @@ export class AgentServer { ? params.artifacts.length : 0, }); - const prompt = await this.buildPromptFromContentAndArtifacts({ + const builtPrompt = await this.buildPromptFromContentAndArtifacts({ content: params.content as string | ContentBlock[] | undefined, artifacts: Array.isArray(params.artifacts) ? (params.artifacts as TaskRunArtifact[]) @@ -696,6 +717,7 @@ export class AgentServer { taskId: this.session.payload.task_id, runId: this.session.payload.run_id, }); + const prompt = builtPrompt.prompt; if (prompt.length === 0) { throw new Error("User message cannot be empty"); } @@ -710,16 +732,19 @@ export class AgentServer { this.session.logWriter.resetTurnMessages(this.session.payload.run_id); + const promptMeta: Record = { + ...(builtPrompt.meta ?? {}), + ...(this.detectedPrUrl + ? { + prContext: this.buildDetectedPrContext(this.detectedPrUrl), + } + : {}), + }; + const result = await this.session.clientConnection.prompt({ sessionId: this.session.acpSessionId, prompt, - ...(this.detectedPrUrl && { - _meta: { - // Keep the live-session PR override aligned with the startup - // prompt policy so non-Slack runs remain review-first. - prContext: this.buildDetectedPrContext(this.detectedPrUrl), - }, - }), + ...(Object.keys(promptMeta).length > 0 ? { _meta: promptMeta } : {}), }); this.logger.debug("User message completed", { @@ -1059,6 +1084,18 @@ export class AgentServer { : runtimeAdapter === "codex" ? "auto" : "bypassPermissions"; + const pendingUserArtifactIds = Array.isArray( + runState?.pending_user_artifact_ids, + ) + ? runState.pending_user_artifact_ids.filter( + (artifactId): artifactId is string => typeof artifactId === "string", + ) + : []; + await this.installSkillBundleArtifacts( + payload.task_id, + payload.run_id, + this.getArtifactsById(preTaskRun?.artifacts, pendingUserArtifactIds), + ); const sessionResponse = await clientConnection.newSession({ cwd: this.config.repositoryPath ?? "/tmp/workspace", mcpServers: this.config.mcpServers ?? [], @@ -1232,8 +1269,10 @@ export class AgentServer { : null; const pendingUserPrompt = await this.getPendingUserPrompt(taskRun); let initialPrompt: ContentBlock[] = []; - if (pendingUserPrompt?.length) { - initialPrompt = pendingUserPrompt; + let initialPromptMeta: Record | undefined; + if (pendingUserPrompt?.prompt.length) { + initialPrompt = pendingUserPrompt.prompt; + initialPromptMeta = pendingUserPrompt.meta; } else if (initialPromptOverride) { initialPrompt = [{ type: "text", text: initialPromptOverride }]; } else if (task.description) { @@ -1249,7 +1288,7 @@ export class AgentServer { taskId: payload.task_id, descriptionLength: promptBlocksToText(initialPrompt).length, usedInitialPromptOverride: !!initialPromptOverride, - usedPendingUserMessage: !!pendingUserPrompt?.length, + usedPendingUserMessage: !!pendingUserPrompt?.prompt.length, }); this.session.logWriter.resetTurnMessages(payload.run_id); @@ -1257,6 +1296,7 @@ export class AgentServer { const result = await this.session.clientConnection.prompt({ sessionId: this.session.acpSessionId, prompt: initialPrompt, + ...(initialPromptMeta ? { _meta: initialPromptMeta } : {}), }); this.logger.debug("Initial task message completed", { @@ -1334,7 +1374,9 @@ export class AgentServer { : `The workspace from the previous session was not restored from a checkpoint, so you are starting with a fresh environment. Your conversation history is fully preserved below.`; let resumePromptBlocks: ContentBlock[]; - if (pendingUserPrompt?.length) { + let resumePromptMeta: Record | undefined; + if (pendingUserPrompt?.prompt.length) { + resumePromptMeta = pendingUserPrompt.meta; resumePromptBlocks = [ { type: "text", @@ -1344,7 +1386,7 @@ export class AgentServer { `${conversationSummary}\n\n` + `The user has sent a new message:\n\n`, }, - ...pendingUserPrompt, + ...pendingUserPrompt.prompt, { type: "text", text: "\n\nRespond to the user's new message above. You have full context from the previous session.", @@ -1367,7 +1409,7 @@ export class AgentServer { taskId: payload.task_id, conversationTurns: this.resumeState.conversation.length, promptLength: promptBlocksToText(resumePromptBlocks).length, - hasPendingUserMessage: !!pendingUserPrompt?.length, + hasPendingUserMessage: !!pendingUserPrompt?.prompt.length, checkpointApplied, hasGitCheckpoint: !!this.resumeState.latestGitCheckpoint, gitCheckpointBranch: @@ -1382,6 +1424,7 @@ export class AgentServer { const result = await this.session.clientConnection.prompt({ sessionId: this.session.acpSessionId, prompt: resumePromptBlocks, + ...(resumePromptMeta ? { _meta: resumePromptMeta } : {}), }); this.logger.debug("Resume message completed", { @@ -1419,7 +1462,7 @@ export class AgentServer { private async getPendingUserPrompt( taskRun: TaskRun | null, - ): Promise { + ): Promise { if (!taskRun) return null; const state = taskRun.state as Record | undefined; const message = state?.pending_user_message; @@ -1438,9 +1481,9 @@ export class AgentServer { this.logger.debug("Built pending user prompt", { hasMessage: typeof message === "string" && message.trim().length > 0, requestedArtifactCount: artifactIds.length, - blockTypes: prompt.map((block) => block.type), + blockTypes: prompt.prompt.map((block) => block.type), }); - return prompt.length > 0 ? prompt : null; + return prompt.prompt.length > 0 ? prompt : null; } private getClearedPendingUserState(taskRun: TaskRun | null): string[] | null { @@ -1485,15 +1528,31 @@ export class AgentServer { artifacts?: TaskRunArtifact[]; taskId: string; runId: string; - }): Promise { + }): Promise { const contentBlocks = content ? normalizeCloudPromptContent(content) : []; + await this.installSkillBundleArtifacts(taskId, runId, artifacts ?? []); + const localSkillContext = this.buildInstalledSkillPromptContext( + contentBlocks, + runId, + artifacts ?? [], + ); const artifactBlocks = await this.hydrateArtifactsToPrompt( taskId, runId, - artifacts ?? [], + (artifacts ?? []).filter((artifact) => artifact.type !== "skill_bundle"), ); - return [...contentBlocks, ...artifactBlocks]; + return { + prompt: [...contentBlocks, ...artifactBlocks], + ...(localSkillContext + ? { + meta: { + localSkillContext: localSkillContext.context, + localSkillName: localSkillContext.skillName, + } satisfies Record, + } + : {}), + }; } private getArtifactsById( @@ -1551,6 +1610,262 @@ export class AgentServer { ).flatMap((artifactBlock) => (artifactBlock ? [artifactBlock] : [])); } + private async installSkillBundleArtifacts( + taskId: string, + runId: string, + artifacts: TaskRunArtifact[], + ): Promise { + const skillBundleArtifacts = artifacts.filter( + (artifact) => artifact.type === "skill_bundle", + ); + if (skillBundleArtifacts.length === 0) { + return; + } + + this.logger.debug("Installing skill bundle artifacts", { + taskId, + runId, + artifactCount: skillBundleArtifacts.length, + artifactNames: skillBundleArtifacts.map((artifact) => artifact.name), + }); + + for (const artifact of skillBundleArtifacts) { + await this.installSkillBundleArtifact(taskId, runId, artifact); + } + } + + private buildInstalledSkillPromptContext( + contentBlocks: ContentBlock[], + runId: string, + artifacts: TaskRunArtifact[], + ): LocalSkillPromptContext | null { + if (contentBlocks.length === 0) { + return null; + } + + const textBlockIndex = contentBlocks.findIndex( + (block): block is Extract => + block.type === "text" && block.text.trim().length > 0, + ); + if (textBlockIndex === -1) { + return null; + } + + const textBlock = contentBlocks[textBlockIndex]; + if (textBlock.type !== "text") { + return null; + } + + const invocation = this.parseLocalSkillInvocation(textBlock.text); + if (!invocation) { + return null; + } + + const hasMatchingArtifact = artifacts.some( + (artifact) => + artifact.type === "skill_bundle" && + artifact.metadata?.skill_name === invocation.skillName, + ); + if (!hasMatchingArtifact) { + return null; + } + + const installedSkill = this.installedSkillBundleInfo.get( + this.getInstalledSkillBundleInfoKey(runId, invocation.skillName), + ); + if (!installedSkill) { + return null; + } + + return { + skillName: invocation.skillName, + context: this.buildInstalledSkillPrompt(installedSkill, invocation.args), + }; + } + + private parseLocalSkillInvocation( + textValue: string, + ): { skillName: string; args?: string } | null { + const trimmed = textValue.trim(); + const match = trimmed.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/); + if (!match?.[1]) { + return null; + } + + return { + skillName: match[1], + ...(match[2]?.trim() ? { args: match[2].trim() } : {}), + }; + } + + private buildInstalledSkillPrompt( + skill: InstalledSkillBundle, + args: string | undefined, + ): string { + return [ + `The user invoked the local skill "/${skill.skillName}". Apply these skill instructions for this turn.`, + "", + `--- BEGIN LOCAL SKILL ${skill.skillName} ---`, + skill.skillDefinition.trim(), + `--- END LOCAL SKILL ${skill.skillName} ---`, + "", + `Installed skill path: ${skill.skillRoot}`, + "", + "User request:", + args?.trim() || `Run /${skill.skillName}.`, + ].join("\n"); + } + + private getInstalledSkillBundleInfoKey( + runId: string, + skillName: string, + ): string { + return `${runId}:${skillName}`; + } + + private async installSkillBundleArtifact( + taskId: string, + runId: string, + artifact: TaskRunArtifact, + ): Promise { + const metadata = artifact.metadata; + const skillName = metadata?.skill_name; + const expectedSha256 = metadata?.content_sha256; + + if (!artifact.storage_path || !skillName || !expectedSha256) { + throw new Error( + `Skill bundle artifact ${artifact.name} is missing metadata`, + ); + } + + const installKey = `${runId}:${expectedSha256}:${skillName}`; + if ( + this.installedSkillBundles.has(installKey) && + this.installedSkillBundleInfo.has( + this.getInstalledSkillBundleInfoKey(runId, skillName), + ) + ) { + return; + } + + const data = await this.posthogAPI.downloadArtifact( + taskId, + runId, + artifact.storage_path, + ); + if (!data) { + throw new Error(`Failed to download skill bundle ${artifact.name}`); + } + + const buffer = Buffer.from(data); + const actualSha256 = createHash("sha256").update(buffer).digest("hex"); + if (actualSha256 !== expectedSha256) { + throw new Error(`Skill bundle ${skillName} failed checksum validation`); + } + + const safeSkillName = this.getSafeArtifactName(skillName); + const skillRoot = join( + this.config.repositoryPath ?? "/tmp/workspace", + ".posthog", + "skills", + runId, + actualSha256, + safeSkillName, + ); + + await rm(skillRoot, { recursive: true, force: true }); + await mkdir(skillRoot, { recursive: true }); + await this.extractSkillBundle(buffer, skillRoot); + + const skillDefinition = await readFile( + join(skillRoot, "SKILL.md"), + "utf-8", + ).catch(() => null); + if (!skillDefinition?.trim()) { + throw new Error(`Skill bundle ${skillName} does not contain SKILL.md`); + } + + const copyFailures: Array<{ destination: string; error: unknown }> = []; + await Promise.all( + this.getSkillInstallDirectories(safeSkillName).map( + async (destination) => { + try { + await rm(destination, { recursive: true, force: true }); + await mkdir(dirname(destination), { recursive: true }); + await cp(skillRoot, destination, { recursive: true }); + } catch (error) { + copyFailures.push({ destination, error }); + } + }, + ), + ); + if (copyFailures.length > 0) { + this.logger.warn("Failed to copy skill bundle to some skill roots", { + taskId, + runId, + skillName, + failedDestinations: copyFailures.map((failure) => failure.destination), + }); + } + + this.installedSkillBundles.add(installKey); + this.installedSkillBundleInfo.set( + this.getInstalledSkillBundleInfoKey(runId, skillName), + { + skillName, + skillDefinition, + contentSha256: actualSha256, + skillRoot, + }, + ); + this.logger.debug("Installed skill bundle artifact", { + taskId, + runId, + skillName, + contentSha256: actualSha256, + }); + } + + private async extractSkillBundle( + archive: Uint8Array, + destinationRoot: string, + ): Promise { + const entries = unzipSync(archive); + for (const [entryName, content] of Object.entries(entries)) { + const normalizedEntryName = entryName.replaceAll("\\", "/"); + if ( + !normalizedEntryName || + normalizedEntryName.endsWith("/") || + normalizedEntryName.startsWith("/") || + normalizedEntryName.split("/").includes("..") + ) { + continue; + } + + const destinationPath = join(destinationRoot, normalizedEntryName); + const relativeDestination = relative(destinationRoot, destinationPath); + if ( + !relativeDestination || + relativeDestination.startsWith("..") || + isAbsolute(relativeDestination) + ) { + continue; + } + + await mkdir(dirname(destinationPath), { recursive: true }); + await writeFile(destinationPath, Buffer.from(content)); + } + } + + private getSkillInstallDirectories(skillName: string): string[] { + const home = process.env.HOME ?? "/tmp"; + return [ + join("/scripts", "plugins", "posthog", "skills", skillName), + join(home, ".agents", "skills", skillName), + join(home, ".claude", "skills", skillName), + ]; + } + private async hydrateArtifactToPromptBlock( taskId: string, runId: string, @@ -1596,7 +1911,10 @@ export class AgentServer { private getSafeArtifactName(name: string): string { const baseName = basename(name).trim(); const normalizedName = baseName.replace(/[^\w.-]/g, "_"); - return normalizedName.length > 0 ? normalizedName : "attachment"; + if (normalizedName.length === 0 || /^\.+$/.test(normalizedName)) { + return "attachment"; + } + return normalizedName; } private async autoInitializeSession(): Promise { diff --git a/packages/agent/tsup.config.ts b/packages/agent/tsup.config.ts index 19269bb8d4..203ee1d91a 100644 --- a/packages/agent/tsup.config.ts +++ b/packages/agent/tsup.config.ts @@ -83,7 +83,12 @@ const sharedOptions = { splitting: false, outDir: "dist", target: "node20", - noExternal: ["@posthog/shared", "@posthog/git", "@posthog/enricher"], + noExternal: [ + "@posthog/shared", + "@posthog/git", + "@posthog/enricher", + "fflate", + ], external: [ ...builtinModules, ...builtinModules.map((m) => `node:${m}`), diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 3573de35b1..e06dc30fcf 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -432,10 +432,17 @@ export class FolderInstructionsConflictError extends Error { export interface TaskArtifactUploadRequest { name: string; - type: "user_attachment"; + type: "user_attachment" | "skill_bundle"; size: number; content_type?: string; source?: string; + metadata?: { + skill_name: string; + skill_source: "user" | "repo" | "marketplace" | "codex"; + content_sha256: string; + bundle_format: "zip"; + schema_version: number; + }; } export interface DirectUploadPresignedPost { @@ -457,6 +464,7 @@ export interface FinalizedTaskArtifactUpload { source?: string; size?: number; content_type?: string; + metadata?: TaskArtifactUploadRequest["metadata"]; storage_path: string; uploaded_at?: string; } @@ -2361,6 +2369,7 @@ export class PostHogAPIClient { type: artifact.type, source: artifact.source, content_type: artifact.content_type, + metadata: artifact.metadata, storage_path: artifact.storage_path, })), }), @@ -2436,6 +2445,7 @@ export class PostHogAPIClient { type: artifact.type, source: artifact.source, content_type: artifact.content_type, + metadata: artifact.metadata, storage_path: artifact.storage_path, })), }), diff --git a/packages/core/src/editor/cloud-prompt.ts b/packages/core/src/editor/cloud-prompt.ts index 6ab68831be..0e37f7d4ea 100644 --- a/packages/core/src/editor/cloud-prompt.ts +++ b/packages/core/src/editor/cloud-prompt.ts @@ -17,6 +17,8 @@ export type ReadFileAsBase64 = (filePath: string) => Promise; const ABSOLUTE_FILE_TAG_REGEX = //g; const FOLDER_TAG_REGEX = //g; const FOLDER_TAG_PATH_REGEX = //g; +const SKILL_TAG_REGEX = /]*?)\s*\/>/g; +const ATTR_REGEX = /(\w+)="([^"]*)"/g; const TEXT_EXTENSIONS = new Set([ "c", "cc", @@ -111,7 +113,22 @@ function normalizePromptText(prompt: string): string { return prompt.replace(/\n{3,}/g, "\n\n").trim(); } -export function stripAbsoluteFileTags(prompt: string): string { +function parseAttrs(raw: string): Record { + const attrs: Record = {}; + for (const match of raw.matchAll(ATTR_REGEX)) { + attrs[match[1]] = unescapeXmlAttr(match[2]); + } + return attrs; +} + +export function stripSkillTags(prompt: string): string { + return prompt.replaceAll(SKILL_TAG_REGEX, (_match, rawAttrs: string) => { + const attrs = parseAttrs(rawAttrs); + return attrs.name ? `/${attrs.name}` : ""; + }); +} + +export function stripAttachmentTags(prompt: string): string { return normalizePromptText( prompt .replaceAll(ABSOLUTE_FILE_TAG_REGEX, (match, rawPath: string) => { @@ -122,6 +139,10 @@ export function stripAbsoluteFileTags(prompt: string): string { ); } +export function stripAbsoluteFileTags(prompt: string): string { + return normalizePromptText(stripSkillTags(stripAttachmentTags(prompt))); +} + export function getAbsoluteAttachmentPaths( prompt: string, filePaths: string[] = [], diff --git a/packages/core/src/message-editor/content.test.ts b/packages/core/src/message-editor/content.test.ts index beb226b2b9..c7aac6c249 100644 --- a/packages/core/src/message-editor/content.test.ts +++ b/packages/core/src/message-editor/content.test.ts @@ -180,6 +180,31 @@ describe("xmlToContent", () => { ); }); + it("round-trips a local skill command chip", () => { + const content: EditorContent = { + segments: [ + { + type: "chip", + chip: { + type: "command", + id: "/Users/alessandro/.claude/skills/local-skill", + label: "local-skill", + skillName: "local-skill", + skillSource: "user", + skillPath: "/Users/alessandro/.claude/skills/local-skill", + }, + }, + ], + }; + + expect(contentToXml(content)).toBe( + '', + ); + expect(xmlToContent(contentToXml(content)).segments).toEqual( + content.segments, + ); + }); + it("extractFilePaths includes folder chips alongside file chips", () => { const content: EditorContent = { segments: [ diff --git a/packages/core/src/message-editor/content.ts b/packages/core/src/message-editor/content.ts index 07b8646a36..4b5dd33823 100644 --- a/packages/core/src/message-editor/content.ts +++ b/packages/core/src/message-editor/content.ts @@ -1,4 +1,8 @@ -import { escapeXmlAttr, unescapeXmlAttr } from "@posthog/shared"; +import { + escapeXmlAttr, + type UploadableSkillSource, + unescapeXmlAttr, +} from "@posthog/shared"; export interface MentionChip { type: @@ -15,6 +19,9 @@ export interface MentionChip { label: string; pastedText?: boolean; chipId?: string; + skillPath?: string; + skillSource?: UploadableSkillSource; + skillName?: string; } export interface FileAttachment { @@ -60,6 +67,9 @@ export function contentToXml(content: EditorContent): string { inlineFilePaths.add(chip.id); return ``; case "command": + if (chip.skillPath && chip.skillSource) { + return ``; + } if (chip.id && chip.id !== chip.label && isAbsolutePathLike(chip.id)) { return ``; } @@ -97,7 +107,7 @@ export function contentToXml(content: EditorContent): string { } const CHIP_TAG_REGEX = - /<(file|folder|error|experiment|insight|feature_flag|github_issue|github_pr)\b([^>]*?)\s*\/>/g; + /<(file|folder|skill|error|experiment|insight|feature_flag|github_issue|github_pr)\b([^>]*?)\s*\/>/g; const ATTR_REGEX = /(\w+)="([^"]*)"/g; export function deriveFileLabel(filePath: string): string { @@ -128,6 +138,29 @@ function chipFromTag(tag: string, rawAttrs: string): MentionChip | null { if (!path) return null; return { type: "folder", id: path, label: deriveFileLabel(path) }; } + case "skill": { + const path = attrs.path; + const name = attrs.name; + const source = attrs.source; + if ( + !path || + !name || + (source !== "user" && + source !== "repo" && + source !== "marketplace" && + source !== "codex") + ) { + return null; + } + return { + type: "command", + id: path, + label: name, + skillPath: path, + skillSource: source, + skillName: name, + }; + } case "error": case "experiment": case "insight": diff --git a/packages/core/src/message-editor/suggestions.ts b/packages/core/src/message-editor/suggestions.ts index 6cffc1cb2b..ba1255c4a4 100644 --- a/packages/core/src/message-editor/suggestions.ts +++ b/packages/core/src/message-editor/suggestions.ts @@ -1,9 +1,14 @@ -import { isAbsolutePath } from "@posthog/shared"; +import { isAbsolutePath, type SkillSource } from "@posthog/shared"; import Fuse, { type IFuseOptions } from "fuse.js"; export interface CommandLike { name: string; description?: string; + localSkill?: { + name: string; + source: Exclude; + path: string; + }; } export interface FileItemLike { @@ -27,6 +32,9 @@ export interface CommandSuggestionShape { id: string; label: string; description?: string; + skillPath?: string; + skillSource?: Exclude; + skillName?: string; command: T; } @@ -75,9 +83,12 @@ export function shapeCommandSuggestions( commands: T[], ): CommandSuggestionShape[] { return commands.map((cmd) => ({ - id: cmd.name, + id: cmd.localSkill?.path ?? cmd.name, label: cmd.name, description: cmd.description, + skillPath: cmd.localSkill?.path, + skillSource: cmd.localSkill?.source, + skillName: cmd.localSkill?.name, command: cmd, })); } diff --git a/packages/core/src/sessions/cloudArtifactIdentifiers.ts b/packages/core/src/sessions/cloudArtifactIdentifiers.ts index c2c8649341..eed72f2f6f 100644 --- a/packages/core/src/sessions/cloudArtifactIdentifiers.ts +++ b/packages/core/src/sessions/cloudArtifactIdentifiers.ts @@ -1,9 +1,18 @@ +import type { UploadableSkillSource } from "@posthog/shared"; + export interface CloudArtifactUploadRequest { name: string; - type: "user_attachment"; + type: "user_attachment" | "skill_bundle"; size: number; content_type?: string; source?: string; + metadata?: { + skill_name: string; + skill_source: UploadableSkillSource; + content_sha256: string; + bundle_format: "zip"; + schema_version: number; + }; } export interface CloudArtifactPresignedPost { @@ -41,9 +50,32 @@ export interface CloudArtifactClient { ): Promise; } +export interface CloudSkillBundleRef { + name: string; + source: UploadableSkillSource; + path: string; +} + +export interface LocalSkillBundle { + name: string; + source: UploadableSkillSource; + fileName: string; + contentType: "application/zip"; + contentBase64: string; + contentSha256: string; + size: number; +} + +export type BundleLocalSkill = ( + skillBundleRef: CloudSkillBundleRef, +) => Promise; + export const CLOUD_ARTIFACT_SERVICE = Symbol.for( "posthog.core.sessions.cloudArtifactService", ); export const CLOUD_ARTIFACT_READ_FILE_AS_BASE64 = Symbol.for( "posthog.core.sessions.cloudArtifactReadFileAsBase64", ); +export const CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL = Symbol.for( + "posthog.core.sessions.cloudArtifactBundleLocalSkill", +); diff --git a/packages/core/src/sessions/cloudArtifactService.test.ts b/packages/core/src/sessions/cloudArtifactService.test.ts index 53c3a67bb6..2abf5f277b 100644 --- a/packages/core/src/sessions/cloudArtifactService.test.ts +++ b/packages/core/src/sessions/cloudArtifactService.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import type { CloudArtifactClient } from "./cloudArtifactIdentifiers"; +import type { + BundleLocalSkill, + CloudArtifactClient, +} from "./cloudArtifactIdentifiers"; import { CLOUD_ATTACHMENT_MAX_SIZE_BYTES, CLOUD_PDF_ATTACHMENT_MAX_SIZE_BYTES, @@ -15,9 +18,23 @@ function makeClient(): CloudArtifactClient { }; } +const bundleLocalSkill: BundleLocalSkill = vi.fn(async (skillBundleRef) => { + const contentBase64 = btoa("skill-bundle"); + return { + name: skillBundleRef.name, + source: skillBundleRef.source, + fileName: `${skillBundleRef.name}.zip`, + contentType: "application/zip" as const, + contentBase64, + contentSha256: + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + size: 12, + }; +}); + describe("CloudArtifactService", () => { it("returns empty ids when no file paths are provided", async () => { - const service = new CloudArtifactService(vi.fn()); + const service = new CloudArtifactService(vi.fn(), bundleLocalSkill); expect( await service.uploadRunAttachments(makeClient(), "t", "r", []), ).toEqual([]); @@ -26,7 +43,10 @@ describe("CloudArtifactService", () => { it("rejects attachments that exceed the max size", async () => { const oversized = CLOUD_ATTACHMENT_MAX_SIZE_BYTES + 1; const base64 = btoa("a".repeat(oversized)); - const service = new CloudArtifactService(vi.fn().mockResolvedValue(base64)); + const service = new CloudArtifactService( + vi.fn().mockResolvedValue(base64), + bundleLocalSkill, + ); await expect( service.uploadRunAttachments(makeClient(), "task-1", "run-1", [ @@ -38,7 +58,10 @@ describe("CloudArtifactService", () => { it("rejects PDFs that exceed the stricter cloud limit", async () => { const oversized = CLOUD_PDF_ATTACHMENT_MAX_SIZE_BYTES + 1; const base64 = btoa("a".repeat(oversized)); - const service = new CloudArtifactService(vi.fn().mockResolvedValue(base64)); + const service = new CloudArtifactService( + vi.fn().mockResolvedValue(base64), + bundleLocalSkill, + ); await expect( service.uploadRunAttachments(makeClient(), "task-1", "run-1", [ @@ -50,7 +73,10 @@ describe("CloudArtifactService", () => { }); it("throws when a file cannot be read", async () => { - const service = new CloudArtifactService(vi.fn().mockResolvedValue(null)); + const service = new CloudArtifactService( + vi.fn().mockResolvedValue(null), + bundleLocalSkill, + ); await expect( service.uploadRunAttachments(makeClient(), "task-1", "run-1", [ @@ -64,7 +90,10 @@ describe("CloudArtifactService", () => { .spyOn(globalThis, "fetch") .mockResolvedValue({ ok: true } as Response); const base64 = btoa("hello"); - const service = new CloudArtifactService(vi.fn().mockResolvedValue(base64)); + const service = new CloudArtifactService( + vi.fn().mockResolvedValue(base64), + bundleLocalSkill, + ); const client = makeClient(); ( @@ -93,4 +122,55 @@ describe("CloudArtifactService", () => { ); fetchMock.mockRestore(); }); + + it("uploads local skill bundles as skill bundle artifacts", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue({ ok: true } as Response); + const service = new CloudArtifactService(vi.fn(), bundleLocalSkill); + const client = makeClient(); + + ( + client.prepareTaskRunArtifactUploads as ReturnType + ).mockResolvedValue([ + { + id: "prep-1", + name: "local-skill.zip", + type: "skill_bundle", + size: 12, + presigned_post: { url: "https://s3/upload", fields: { key: "k" } }, + }, + ]); + ( + client.finalizeTaskRunArtifactUploads as ReturnType + ).mockResolvedValue([{ id: "skill-artifact-1" }]); + + const ids = await service.uploadRunAttachments( + client, + "task-1", + "run-1", + [], + [{ name: "local-skill", source: "user", path: "/tmp/local-skill" }], + ); + + expect(ids).toEqual(["skill-artifact-1"]); + expect(client.prepareTaskRunArtifactUploads).toHaveBeenCalledWith( + "task-1", + "run-1", + [ + expect.objectContaining({ + name: "local-skill.zip", + type: "skill_bundle", + content_type: "application/zip", + metadata: expect.objectContaining({ + skill_name: "local-skill", + skill_source: "user", + bundle_format: "zip", + schema_version: 1, + }), + }), + ], + ); + fetchMock.mockRestore(); + }); }); diff --git a/packages/core/src/sessions/cloudArtifactService.ts b/packages/core/src/sessions/cloudArtifactService.ts index 4c2d837d1e..8673231670 100644 --- a/packages/core/src/sessions/cloudArtifactService.ts +++ b/packages/core/src/sessions/cloudArtifactService.ts @@ -2,15 +2,20 @@ import type { ReadFileAsBase64 } from "@posthog/core/editor/cloud-prompt"; import { getFileName } from "@posthog/shared"; import { inject, injectable } from "inversify"; import { + type BundleLocalSkill, + CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL, CLOUD_ARTIFACT_READ_FILE_AS_BASE64, type CloudArtifactClient, type CloudArtifactUploadRequest, + type CloudSkillBundleRef, type FinalizedCloudArtifact, type PreparedCloudArtifact, } from "./cloudArtifactIdentifiers"; const ATTACHMENT_SOURCE = "posthog_code"; +const SKILL_BUNDLE_SOURCE = "posthog_code_skill"; const DEFAULT_CONTENT_TYPE = "application/octet-stream"; +const SKILL_BUNDLE_CONTENT_TYPE = "application/zip"; export const CLOUD_ATTACHMENT_MAX_SIZE_BYTES = 30 * 1024 * 1024; export const CLOUD_PDF_ATTACHMENT_MAX_SIZE_BYTES = 10 * 1024 * 1024; @@ -116,18 +121,24 @@ export class CloudArtifactService { constructor( @inject(CLOUD_ARTIFACT_READ_FILE_AS_BASE64) private readonly readFileAsBase64: ReadFileAsBase64, + @inject(CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL) + private readonly bundleLocalSkill: BundleLocalSkill, ) {} async uploadTaskStagedAttachments( client: CloudArtifactClient, taskId: string, filePaths: string[], + skillBundles: CloudSkillBundleRef[] = [], ): Promise { - if (!filePaths.length) { + if (!filePaths.length && !skillBundles.length) { return []; } - const attachments = await this.loadCloudAttachments(filePaths); + const attachments = [ + ...(await this.loadCloudAttachments(filePaths)), + ...(await this.loadCloudSkillBundles(skillBundles)), + ]; const preparedArtifacts = await client.prepareTaskStagedArtifactUploads( taskId, attachments.map((attachment) => attachment.upload), @@ -148,12 +159,16 @@ export class CloudArtifactService { taskId: string, runId: string, filePaths: string[], + skillBundles: CloudSkillBundleRef[] = [], ): Promise { - if (!filePaths.length) { + if (!filePaths.length && !skillBundles.length) { return []; } - const attachments = await this.loadCloudAttachments(filePaths); + const attachments = [ + ...(await this.loadCloudAttachments(filePaths)), + ...(await this.loadCloudSkillBundles(skillBundles)), + ]; const preparedArtifacts = await client.prepareTaskRunArtifactUploads( taskId, runId, @@ -207,6 +222,46 @@ export class CloudArtifactService { ); } + private async loadCloudSkillBundles( + skillBundleRefs: CloudSkillBundleRef[], + ): Promise { + return Promise.all( + skillBundleRefs.map(async (skillBundleRef) => { + const bundle = await this.bundleLocalSkill(skillBundleRef); + const bytes = base64ToUint8Array(bundle.contentBase64); + if (bytes.byteLength !== bundle.size) { + throw new Error( + `Unable to prepare local skill ${skillBundleRef.name}`, + ); + } + if (bytes.byteLength > CLOUD_ATTACHMENT_MAX_SIZE_BYTES) { + throw new Error( + `${bundle.fileName} exceeds the 30MB attachment limit`, + ); + } + + return { + filePath: skillBundleRef.path, + bytes, + upload: { + name: bundle.fileName, + type: "skill_bundle", + source: SKILL_BUNDLE_SOURCE, + size: bytes.byteLength, + content_type: SKILL_BUNDLE_CONTENT_TYPE, + metadata: { + skill_name: bundle.name, + skill_source: bundle.source, + content_sha256: bundle.contentSha256, + bundle_format: "zip", + schema_version: 1, + }, + }, + }; + }), + ); + } + private async uploadPreparedArtifacts( attachments: LoadedCloudAttachment[], preparedArtifacts: PreparedCloudArtifact[], diff --git a/packages/core/src/sessions/cloudPrompt.ts b/packages/core/src/sessions/cloudPrompt.ts index 9a554f36cf..e1560cd624 100644 --- a/packages/core/src/sessions/cloudPrompt.ts +++ b/packages/core/src/sessions/cloudPrompt.ts @@ -3,14 +3,20 @@ import { buildCloudTaskDescription, getAbsoluteAttachmentPaths, stripAbsoluteFileTags, + stripAttachmentTags, + stripSkillTags, } from "@posthog/core/editor/cloud-prompt"; import type { EditorContent } from "@posthog/core/message-editor/content"; -import { getFileName, pathToFileUri } from "@posthog/shared"; +import { getFileName, pathToFileUri, unescapeXmlAttr } from "@posthog/shared"; +import type { CloudSkillBundleRef } from "./cloudArtifactIdentifiers"; const FILE_URI_PREFIX = "file://"; +const SKILL_TAG_REGEX = /]*?)\s*\/>/g; +const ATTR_REGEX = /(\w+)="([^"]*)"/g; export interface CloudPromptTransport { filePaths: string[]; + skillBundles: CloudSkillBundleRef[]; messageText?: string; promptText: string; } @@ -61,6 +67,43 @@ function collectBlockAttachmentPaths(prompt: ContentBlock[]): string[] { return Array.from(new Set(filePaths)); } +function parseAttrs(raw: string): Record { + const attrs: Record = {}; + for (const match of raw.matchAll(ATTR_REGEX)) { + attrs[match[1]] = unescapeXmlAttr(match[2]); + } + return attrs; +} + +function collectSkillBundleRefs(prompt: string): CloudSkillBundleRef[] { + const refs: CloudSkillBundleRef[] = []; + const seen = new Set(); + + for (const match of prompt.matchAll(SKILL_TAG_REGEX)) { + const attrs = parseAttrs(match[1] ?? ""); + const source = attrs.source; + if ( + !attrs.name || + !attrs.path || + (source !== "user" && + source !== "repo" && + source !== "marketplace" && + source !== "codex") + ) { + continue; + } + + const key = `${source}:${attrs.path}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + refs.push({ name: attrs.name, source, path: attrs.path }); + } + + return refs; +} + function summarizePrompt(text: string, filePaths: string[]): string { if (filePaths.length === 0) { return text.trim(); @@ -78,27 +121,31 @@ export function getCloudPromptTransport( ): CloudPromptTransport { if (typeof prompt === "string") { const attachmentPaths = getAbsoluteAttachmentPaths(prompt, filePaths); + const skillBundles = collectSkillBundleRefs(prompt); const messageText = stripAbsoluteFileTags(prompt).trim(); return { filePaths: attachmentPaths, + skillBundles, messageText: messageText || undefined, promptText: buildCloudTaskDescription(prompt, filePaths).trim(), }; } - const promptText = prompt + const rawPromptText = prompt .filter( (block): block is Extract => block.type === "text", ) .map((block) => block.text) - .join("") - .trim(); + .join(""); + const promptText = stripSkillTags(rawPromptText).trim(); const attachmentPaths = collectBlockAttachmentPaths(prompt); + const skillBundles = collectSkillBundleRefs(rawPromptText); return { filePaths: attachmentPaths, + skillBundles, messageText: promptText || undefined, promptText: summarizePrompt(promptText, attachmentPaths), }; @@ -111,9 +158,10 @@ export function cloudPromptToBlocks(prompt: QueuedCloudPrompt): ContentBlock[] { const transport = getCloudPromptTransport(prompt); const blocks: ContentBlock[] = []; + const textWithSkillTags = stripAttachmentTags(prompt); - if (transport.messageText) { - blocks.push({ type: "text", text: transport.messageText }); + if (textWithSkillTags) { + blocks.push({ type: "text", text: textWithSkillTags }); } for (const filePath of transport.filePaths) { diff --git a/packages/core/src/sessions/sessionEvents.ts b/packages/core/src/sessions/sessionEvents.ts index 9cba7d4c13..be1cdc7af5 100644 --- a/packages/core/src/sessions/sessionEvents.ts +++ b/packages/core/src/sessions/sessionEvents.ts @@ -18,6 +18,29 @@ import { isJsonRpcNotification, isJsonRpcRequest } from "@posthog/shared"; import { isNotification, POSTHOG_NOTIFICATIONS } from "./acpNotifications"; import { extractPromptDisplayContent } from "./promptContent"; +const SKILL_TAG_REGEX = /]*?)\s*\/>/g; +const ATTR_REGEX = /(\w+)="([^"]*)"/g; + +function decodeXmlAttr(value: string): string { + return value + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&"); +} + +function skillTagsToSlashCommands(prompt: string): string { + return prompt.replaceAll(SKILL_TAG_REGEX, (_match, rawAttrs: string) => { + for (const attrMatch of rawAttrs.matchAll(ATTR_REGEX)) { + if (attrMatch[1] === "name" && attrMatch[2]) { + return `/${decodeXmlAttr(attrMatch[2])}`; + } + } + return ""; + }); +} + /** * Convert a stored log entry to an ACP message. */ @@ -208,8 +231,8 @@ export function extractUserPromptsFromEvents(events: AcpMessage[]): string[] { } export function extractPromptText(prompt: string | ContentBlock[]): string { - if (typeof prompt === "string") return prompt; - return extractPromptDisplayContent(prompt).text; + if (typeof prompt === "string") return skillTagsToSlashCommands(prompt); + return skillTagsToSlashCommands(extractPromptDisplayContent(prompt).text); } /** @@ -218,7 +241,15 @@ export function extractPromptText(prompt: string | ContentBlock[]): string { export function normalizePromptToBlocks( prompt: string | ContentBlock[], ): ContentBlock[] { - return typeof prompt === "string" ? [{ type: "text", text: prompt }] : prompt; + if (typeof prompt === "string") { + return [{ type: "text", text: skillTagsToSlashCommands(prompt) }]; + } + + return prompt.map((block) => + block.type === "text" + ? { ...block, text: skillTagsToSlashCommands(block.text) } + : block, + ); } export { isFatalSessionError, isRateLimitError } from "@posthog/shared"; diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 28993a584a..5ea21f79dd 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -40,7 +40,10 @@ import { } from "@posthog/shared/domain-types"; import { isNotification, POSTHOG_NOTIFICATIONS } from "./acpNotifications"; import { createAppendOnlyTracker } from "./appendOnlyTracker"; -import type { CloudArtifactClient } from "./cloudArtifactIdentifiers"; +import type { + CloudArtifactClient, + CloudSkillBundleRef, +} from "./cloudArtifactIdentifiers"; import { classifyCloudLogAppend } from "./cloudLogGap"; import { CloudLogGapReconciler } from "./cloudLogGapReconciler"; import { CloudRunIdleTracker } from "./cloudRunIdleTracker"; @@ -205,16 +208,19 @@ export interface SessionServiceHelpers { cloudPromptToBlocks: (...args: any[]) => any; combineQueuedCloudPrompts: (...args: any[]) => any; getCloudPromptTransport: (...args: any[]) => any; + resolveLocalSkillCommandPrompt?: (prompt: string) => Promise; uploadRunAttachments: ( client: CloudArtifactClient, taskId: string, runId: string, filePaths: string[], + skillBundles?: CloudSkillBundleRef[], ) => Promise; uploadTaskStagedAttachments: ( client: CloudArtifactClient, taskId: string, filePaths: string[], + skillBundles?: CloudSkillBundleRef[], ) => Promise; } @@ -2224,8 +2230,13 @@ export class SessionService { prompt: string | ContentBlock[], options?: { skipQueueGuard?: boolean }, ): Promise<{ stopReason: string }> { - const transport = this.d.h.getCloudPromptTransport(prompt); - if (!transport.messageText && transport.filePaths.length === 0) { + const normalizedPrompt = await this.resolveCloudPrompt(prompt); + const transport = this.d.h.getCloudPromptTransport(normalizedPrompt); + if ( + !transport.messageText && + transport.filePaths.length === 0 && + transport.skillBundles.length === 0 + ) { return { stopReason: "empty" }; } @@ -2239,11 +2250,15 @@ export class SessionService { "Cloud run couldn't start. Check that GitHub is connected for this project, then try again.", ); } - return this.resumeCloudRun(session, prompt); + return this.resumeCloudRun(session, normalizedPrompt); } if (session.cloudStatus !== "in_progress") { - this.d.store.enqueueMessage(session.taskId, transport.promptText); + this.d.store.enqueueMessage( + session.taskId, + transport.promptText, + normalizedPrompt, + ); this.d.log.info("Cloud message queued (sandbox not ready)", { taskId: session.taskId, cloudStatus: session.cloudStatus, @@ -2263,7 +2278,11 @@ export class SessionService { session.isCloud && session.status !== "connected" ) { - this.d.store.enqueueMessage(session.taskId, transport.promptText, prompt); + this.d.store.enqueueMessage( + session.taskId, + transport.promptText, + normalizedPrompt, + ); this.d.log.info("Cloud message queued (agent not ready)", { taskId: session.taskId, sessionStatus: session.status, @@ -2289,7 +2308,11 @@ export class SessionService { } if (!options?.skipQueueGuard && session.isPromptPending) { - this.d.store.enqueueMessage(session.taskId, transport.promptText, prompt); + this.d.store.enqueueMessage( + session.taskId, + transport.promptText, + normalizedPrompt, + ); this.d.log.info("Cloud message queued", { taskId: session.taskId, queueLength: session.messageQueue.length + 1, @@ -2301,7 +2324,7 @@ export class SessionService { if (authStatus.kind === "restoring") { return this.queueRestoringCloudPrompt( session, - prompt, + normalizedPrompt, "Cloud message queued (auth restoring)", ); } @@ -2328,6 +2351,7 @@ export class SessionService { session.taskId, session.taskRunId, transport.filePaths, + transport.skillBundles, ); const params: Record = {}; if (transport.messageText) { @@ -2484,11 +2508,12 @@ export class SessionService { session: AgentSession, prompt: string | ContentBlock[], ): Promise<{ stopReason: string }> { + const normalizedPrompt = await this.resolveCloudPrompt(prompt); const authStatus = await this.getAuthCredentialsStatus(); if (authStatus.kind === "restoring") { return this.queueRestoringCloudPrompt( session, - prompt, + normalizedPrompt, "Cloud resume queued (auth restoring)", ); } @@ -2502,14 +2527,19 @@ export class SessionService { throw new Error("Authentication required for cloud commands"); } - const transport = this.d.h.getCloudPromptTransport(prompt); - if (!transport.messageText && transport.filePaths.length === 0) { + const transport = this.d.h.getCloudPromptTransport(normalizedPrompt); + if ( + !transport.messageText && + transport.filePaths.length === 0 && + transport.skillBundles.length === 0 + ) { return { stopReason: "empty" }; } const artifactIds = await this.d.h.uploadTaskStagedAttachments( authCredentials.client, session.taskId, transport.filePaths, + transport.skillBundles, ); const previousRun = await authCredentials.client.getTaskRun( @@ -4396,6 +4426,28 @@ export class SessionService { // --- Helper Methods --- + private async resolveCloudPrompt( + prompt: string | ContentBlock[], + ): Promise { + if (typeof prompt !== "string") { + return prompt; + } + + const resolver = this.d.h.resolveLocalSkillCommandPrompt; + if (!resolver) { + return prompt; + } + + try { + return (await resolver(prompt)) ?? prompt; + } catch (error) { + this.d.log.warn("Failed to resolve local skill command prompt", { + error: String(error), + }); + return prompt; + } + } + private async getAuthCredentialsStatus(): Promise { const authState = await this.d.fetchAuthState(); // `bootstrapComplete === false` also covers the pre-initialize window where diff --git a/packages/core/src/task-detail/taskCreationHost.ts b/packages/core/src/task-detail/taskCreationHost.ts index b67faa501b..7dd668b3d5 100644 --- a/packages/core/src/task-detail/taskCreationHost.ts +++ b/packages/core/src/task-detail/taskCreationHost.ts @@ -1,9 +1,11 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; +import type { CloudSkillBundleRef } from "@posthog/core/sessions/cloudArtifactIdentifiers"; import type { Workspace, WorkspaceMode } from "@posthog/shared"; import type { TaskCreationApiClient } from "./taskCreationApiClient"; export interface CloudPromptTransport { filePaths: string[]; + skillBundles: CloudSkillBundleRef[]; messageText?: string; promptText: string; } @@ -88,6 +90,7 @@ export interface ITaskCreationHost { taskId: string, runId: string, filePaths: string[], + skillBundles?: CloudSkillBundleRef[], ): Promise; setProvisioningActive(taskId: string): void; clearProvisioning(taskId: string): void; diff --git a/packages/core/src/task-detail/taskCreationSaga.test.ts b/packages/core/src/task-detail/taskCreationSaga.test.ts index 3729cfa27e..7e769ec936 100644 --- a/packages/core/src/task-detail/taskCreationSaga.test.ts +++ b/packages/core/src/task-detail/taskCreationSaga.test.ts @@ -82,6 +82,7 @@ describe("TaskCreationSaga", () => { filePaths: string[] = [], ): CloudPromptTransport => ({ filePaths, + skillBundles: [], messageText: typeof prompt === "string" ? prompt : undefined, promptText: typeof prompt === "string" ? prompt : "", }), @@ -250,6 +251,7 @@ describe("TaskCreationSaga", () => { mockHost.getCloudPromptTransport.mockReturnValue({ filePaths: ["/tmp/test.txt"], + skillBundles: [], messageText: "read this file", promptText: "read this file\n\nAttached files: test.txt", }); @@ -311,6 +313,7 @@ describe("TaskCreationSaga", () => { "task-123", "run-123", ["/tmp/test.txt"], + [], ); expect(startTaskRunMock).toHaveBeenCalledWith("task-123", "run-123", { pendingUserMessage: "read this file", diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index 77ab27ba97..452cd76a84 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -306,6 +306,7 @@ export class TaskCreationSaga extends Saga< task.id, taskRun.id, transport.filePaths, + transport.skillBundles, ) : []; diff --git a/packages/host-router/src/routers/skills.router.ts b/packages/host-router/src/routers/skills.router.ts index 2067fb6a13..af99170f12 100644 --- a/packages/host-router/src/routers/skills.router.ts +++ b/packages/host-router/src/routers/skills.router.ts @@ -1,6 +1,8 @@ import { publicProcedure, router } from "@posthog/host-trpc/trpc"; import { SKILLS_SERVICE } from "@posthog/workspace-server/services/skills/identifiers"; import { + bundleLocalSkillInput, + bundleLocalSkillOutput, createSkillInput, deleteSkillFileInput, deleteSkillInput, @@ -36,6 +38,12 @@ export const skillsRouter = router({ .query(({ ctx }) => ctx.container.get(SKILLS_SERVICE).listSkills(), ), + bundleLocal: publicProcedure + .input(bundleLocalSkillInput) + .output(bundleLocalSkillOutput) + .query(({ ctx, input }) => + ctx.container.get(SKILLS_SERVICE).bundleLocalSkill(input), + ), contents: publicProcedure .input(skillContentsInput) .output(skillContentsOutput) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8ecefc4d5e..9e50612630 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -169,6 +169,7 @@ export type { SkillFileEntry, SkillInfo, SkillSource, + UploadableSkillSource, } from "./skills"; export { SKILL_EXISTS_MARKER, stripFrontmatter } from "./skills"; export type { @@ -176,6 +177,7 @@ export type { PostHogAPIConfig, TaskRun, TaskRunArtifact, + TaskRunArtifactMetadata, TaskRunEnvironment, TaskRunStatus, } from "./task"; diff --git a/packages/shared/src/skills.ts b/packages/shared/src/skills.ts index d1e98463e9..540929b7be 100644 --- a/packages/shared/src/skills.ts +++ b/packages/shared/src/skills.ts @@ -1,4 +1,5 @@ export type SkillSource = "bundled" | "user" | "repo" | "marketplace" | "codex"; +export type UploadableSkillSource = Exclude; export interface SkillInfo { name: string; diff --git a/packages/shared/src/task.ts b/packages/shared/src/task.ts index c0d6b3f98c..94fac87298 100644 --- a/packages/shared/src/task.ts +++ b/packages/shared/src/task.ts @@ -37,7 +37,16 @@ export type ArtifactType = | "reference" | "output" | "artifact" - | "user_attachment"; + | "user_attachment" + | "skill_bundle"; + +export interface TaskRunArtifactMetadata { + skill_name: string; + skill_source: "user" | "repo" | "marketplace" | "codex"; + content_sha256: string; + bundle_format: "zip"; + schema_version: number; +} export interface TaskRunArtifact { id?: string; @@ -46,6 +55,7 @@ export interface TaskRunArtifact { source?: string; size?: number; content_type?: string; + metadata?: TaskRunArtifactMetadata; storage_path?: string; uploaded_at?: string; } diff --git a/packages/ui/src/features/message-editor/commands.test.ts b/packages/ui/src/features/message-editor/commands.test.ts new file mode 100644 index 0000000000..420de06c2f --- /dev/null +++ b/packages/ui/src/features/message-editor/commands.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { rewriteLocalSkillCommandPrompt } from "./commands"; +import type { EditorAvailableCommand } from "./types"; + +const commands: EditorAvailableCommand[] = [ + { + name: "local-test-skill", + description: "Local user skill", + localSkill: { + name: "local-test-skill", + source: "user", + path: "/Users/example/.claude/skills/local-test-skill", + }, + }, +]; + +describe("message editor commands", () => { + it("rewrites local skill slash commands to skill tags", () => { + expect(rewriteLocalSkillCommandPrompt("/local-test-skill", commands)).toBe( + '', + ); + }); + + it("preserves local skill arguments after the skill tag", () => { + expect( + rewriteLocalSkillCommandPrompt( + "/local-test-skill with context", + commands, + ), + ).toBe( + ' with context', + ); + }); + + it("does not rewrite unknown commands", () => { + expect( + rewriteLocalSkillCommandPrompt("/feedback looks good", commands), + ).toBe(null); + }); +}); diff --git a/packages/ui/src/features/message-editor/commands.ts b/packages/ui/src/features/message-editor/commands.ts index 5bea6e5d51..f472442a00 100644 --- a/packages/ui/src/features/message-editor/commands.ts +++ b/packages/ui/src/features/message-editor/commands.ts @@ -4,6 +4,7 @@ import { buildFeedbackEventPayload, parseCommandLine, } from "@posthog/core/message-editor/commands"; +import { escapeXmlAttr } from "@posthog/shared"; import { ANALYTICS_EVENTS, type FeedbackType, @@ -14,6 +15,7 @@ import type { Editor } from "@tiptap/core"; import { track } from "../../shell/analytics"; import { selectDirectory } from "./hostApi"; import type { MentionChipAttrs } from "./tiptap/MentionChipNode"; +import type { EditorAvailableCommand } from "./types"; interface CommandContext { taskId: string; @@ -134,3 +136,19 @@ export async function tryExecuteCodeCommand( await cmd.execute(parsed.args, context); return true; } + +export function rewriteLocalSkillCommandPrompt( + text: string, + commands: EditorAvailableCommand[], +): string | null { + const parsed = parseCommandLine(text.trim()); + if (!parsed) return null; + + const localSkill = commands.find( + (cmd) => cmd.name === parsed.name, + )?.localSkill; + if (!localSkill) return null; + + const skillTag = ``; + return parsed.args?.trim() ? `${skillTag} ${parsed.args}` : skillTag; +} diff --git a/packages/ui/src/features/message-editor/components/PromptInput.tsx b/packages/ui/src/features/message-editor/components/PromptInput.tsx index d1743253a3..a11efbe9d1 100644 --- a/packages/ui/src/features/message-editor/components/PromptInput.tsx +++ b/packages/ui/src/features/message-editor/components/PromptInput.tsx @@ -197,7 +197,19 @@ export const PromptInput = forwardRef( if (!enableCommands || !skills) return; useDraftStore.getState().actions.setCommands( sessionId, - skills.map((s) => ({ name: s.name, description: s.description })), + skills.map((s) => ({ + name: s.name, + description: s.description, + ...(s.source === "bundled" + ? {} + : { + localSkill: { + name: s.name, + source: s.source, + path: s.path, + }, + }), + })), ); return () => { useDraftStore.getState().actions.clearCommands(sessionId); diff --git a/packages/ui/src/features/message-editor/draftStore.ts b/packages/ui/src/features/message-editor/draftStore.ts index 01486df3ae..0ea0c48954 100644 --- a/packages/ui/src/features/message-editor/draftStore.ts +++ b/packages/ui/src/features/message-editor/draftStore.ts @@ -1,9 +1,9 @@ -import type { AvailableCommand } from "@agentclientprotocol/sdk"; import type { EditorContent } from "@posthog/core/message-editor/content"; import { electronStorage } from "@posthog/ui/shell/rendererStorage"; import { create } from "zustand"; import { persist } from "zustand/middleware"; import { immer } from "zustand/middleware/immer"; +import type { EditorAvailableCommand } from "./types"; type SessionId = string; @@ -19,7 +19,7 @@ export interface EditorContext { interface DraftState { drafts: Record; contexts: Record; - commands: Record; + commands: Record; focusRequested: Record; pendingContent: Record; _hasHydrated: boolean; @@ -35,8 +35,11 @@ export interface DraftActions { ) => void; getContext: (sessionId: SessionId) => EditorContext | null; removeContext: (sessionId: SessionId) => void; - setCommands: (sessionId: SessionId, commands: AvailableCommand[]) => void; - getCommands: (sessionId: SessionId) => AvailableCommand[]; + setCommands: ( + sessionId: SessionId, + commands: EditorAvailableCommand[], + ) => void; + getCommands: (sessionId: SessionId) => EditorAvailableCommand[]; clearCommands: (sessionId: SessionId) => void; requestFocus: (sessionId: SessionId) => void; clearFocusRequest: (sessionId: SessionId) => void; diff --git a/packages/ui/src/features/message-editor/suggestions/getSuggestions.test.ts b/packages/ui/src/features/message-editor/suggestions/getSuggestions.test.ts index 2603e548b0..4cf68320e4 100644 --- a/packages/ui/src/features/message-editor/suggestions/getSuggestions.test.ts +++ b/packages/ui/src/features/message-editor/suggestions/getSuggestions.test.ts @@ -2,13 +2,14 @@ import type { AcpMessage } from "@posthog/shared"; import { beforeEach, describe, expect, it } from "vitest"; import { useSessionStore } from "../../sessions/sessionStore"; import { useDraftStore } from "../draftStore"; +import type { EditorAvailableCommand } from "../types"; import { getCommandSuggestions } from "./getSuggestions"; const SESSION_ID = "task-123"; const TASK_ID = "task-123"; const TASK_RUN_ID = "run-1"; -function seedDraftCommands(commands: { name: string; description: string }[]) { +function seedDraftCommands(commands: EditorAvailableCommand[]) { useDraftStore.getState().actions.setCommands(SESSION_ID, commands); } @@ -66,7 +67,7 @@ interface Scenario { name: string; contextTaskId?: string; sessionCommands?: { name: string; description: string }[]; - draftCommands?: { name: string; description: string }[]; + draftCommands?: EditorAvailableCommand[]; expectContains: string[]; expectNotContains?: string[]; } @@ -101,6 +102,23 @@ const SCENARIOS: Scenario[] = [ expectContains: ["agent-cmd"], expectNotContains: ["fallback-only"], }, + { + name: "agent-supplied commands keep local skill commands for follow-ups", + contextTaskId: TASK_ID, + draftCommands: [ + { + name: "local-test-skill", + description: "Local user skill", + localSkill: { + name: "local-test-skill", + source: "user", + path: "/Users/example/.claude/skills/local-test-skill", + }, + }, + ], + sessionCommands: [{ name: "agent-cmd", description: "From agent" }], + expectContains: ["agent-cmd", "local-test-skill"], + }, { name: "uses draft-store skills when there is no running task", draftCommands: [{ name: "my-skill", description: "User skill" }], @@ -146,4 +164,33 @@ describe("getCommandSuggestions", () => { } }, ); + + it("preserves local skill metadata when the agent has reported commands", () => { + seedSessionContext(TASK_ID); + seedDraftCommands([ + { + name: "local-test-skill", + description: "Local user skill", + localSkill: { + name: "local-test-skill", + source: "user", + path: "/Users/example/.claude/skills/local-test-skill", + }, + }, + ]); + seedSessionAvailableCommands([ + { name: "agent-cmd", description: "From agent" }, + ]); + + const localSkill = getCommandSuggestions( + SESSION_ID, + "local-test-skill", + ).find((suggestion) => suggestion.command.name === "local-test-skill"); + + expect(localSkill).toMatchObject({ + skillName: "local-test-skill", + skillSource: "user", + skillPath: "/Users/example/.claude/skills/local-test-skill", + }); + }); }); diff --git a/packages/ui/src/features/message-editor/suggestions/getSuggestions.ts b/packages/ui/src/features/message-editor/suggestions/getSuggestions.ts index 951252ebb2..0659aca4a4 100644 --- a/packages/ui/src/features/message-editor/suggestions/getSuggestions.ts +++ b/packages/ui/src/features/message-editor/suggestions/getSuggestions.ts @@ -16,6 +16,7 @@ import { useDraftStore } from "../draftStore"; import { searchGithubRefs } from "../hostApi"; import type { CommandSuggestionItem, + EditorAvailableCommand, FileSuggestionItem, IssueSuggestionItem, } from "../types"; @@ -83,15 +84,13 @@ export function getCommandSuggestions( ): CommandSuggestionItem[] { const store = useDraftStore.getState(); const taskId = store.contexts[sessionId]?.taskId; - // Agent commands (from `available_commands_update`) are authoritative once a - // session has reported them, but they arrive async after session startup — - // fall back to the trpc-fetched skills list so users don't see only the - // built-in /good /bad /feedback commands during that window. `null` means - // "agent hasn't reported yet"; an empty array means "agent reported empty" - // and we respect it. const sessionCommands = taskId ? getAvailableCommandsForTask(taskId) : null; const draftCommands = store.commands[sessionId] ?? []; - const agentCommands = sessionCommands ?? draftCommands; + const localDraftCommands = draftCommands.filter((cmd) => cmd.localSkill); + const agentCommands: EditorAvailableCommand[] = + sessionCommands === null + ? draftCommands + : [...sessionCommands, ...localDraftCommands]; const commands = mergeCommands(CODE_COMMANDS, agentCommands); const filtered = searchCommands(commands, query); diff --git a/packages/ui/src/features/message-editor/tiptap/CommandGhostText.ts b/packages/ui/src/features/message-editor/tiptap/CommandGhostText.ts index 4bdc365d1c..bce6ed9ea6 100644 --- a/packages/ui/src/features/message-editor/tiptap/CommandGhostText.ts +++ b/packages/ui/src/features/message-editor/tiptap/CommandGhostText.ts @@ -84,6 +84,9 @@ function acceptGhost( id: ghost.item.id, label: ghost.item.label, pastedText: false, + skillPath: ghost.item.skillPath, + skillSource: ghost.item.skillSource, + skillName: ghost.item.skillName, }); const space = state.schema.text(" "); diff --git a/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts b/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts index 107eaa32a3..9f72f163a3 100644 --- a/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts +++ b/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts @@ -1,3 +1,4 @@ +import type { UploadableSkillSource } from "@posthog/shared"; import { mergeAttributes, Node } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; import { MentionChipView } from "./MentionChipView"; @@ -20,6 +21,9 @@ export interface MentionChipAttrs { pastedText: boolean; /** Optional unique handle so callers can later replace or remove this chip. */ chipId?: string | null; + skillPath?: string; + skillSource?: UploadableSkillSource; + skillName?: string; } declare module "@tiptap/core" { @@ -49,6 +53,9 @@ export const MentionChipNode = Node.create({ label: { default: "" }, pastedText: { default: false }, chipId: { default: null as string | null }, + skillPath: { default: undefined }, + skillSource: { default: undefined }, + skillName: { default: undefined }, }; }, diff --git a/packages/ui/src/features/message-editor/tiptap/createSuggestionMention.ts b/packages/ui/src/features/message-editor/tiptap/createSuggestionMention.ts index 88eb8bba99..430997832d 100644 --- a/packages/ui/src/features/message-editor/tiptap/createSuggestionMention.ts +++ b/packages/ui/src/features/message-editor/tiptap/createSuggestionMention.ts @@ -176,6 +176,9 @@ export function createSuggestionMention( label: overrides.label ?? item.label, pastedText: false, chipId, + skillPath: overrides.skillPath ?? item.skillPath, + skillSource: overrides.skillSource ?? item.skillSource, + skillName: overrides.skillName ?? item.skillName, }; editor .chain() diff --git a/packages/ui/src/features/message-editor/tiptap/useDraftSync.ts b/packages/ui/src/features/message-editor/tiptap/useDraftSync.ts index 2aeea959ea..389b52f06a 100644 --- a/packages/ui/src/features/message-editor/tiptap/useDraftSync.ts +++ b/packages/ui/src/features/message-editor/tiptap/useDraftSync.ts @@ -25,6 +25,9 @@ function tiptapJsonToEditorContent(json: JSONContent): EditorContent { id: node.attrs.id, label: node.attrs.label, pastedText: node.attrs.pastedText, + skillPath: node.attrs.skillPath, + skillSource: node.attrs.skillSource, + skillName: node.attrs.skillName, }, }); } else if (node.type === "doc" && node.content) { @@ -81,6 +84,9 @@ function editorContentToTiptapJson(content: EditorContent): JSONContent { id: seg.chip.id, label: seg.chip.label, pastedText: seg.chip.pastedText ?? false, + skillPath: seg.chip.skillPath, + skillSource: seg.chip.skillSource, + skillName: seg.chip.skillName, }, }); } diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 3bc8b7e7b3..7db95ae086 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -644,6 +644,9 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { label: chip.label, pastedText: false, chipId: chip.chipId, + skillPath: chip.skillPath, + skillSource: chip.skillSource, + skillName: chip.skillName, }); draft.saveDraft(editor, attachments); }, diff --git a/packages/ui/src/features/message-editor/types.ts b/packages/ui/src/features/message-editor/types.ts index 8420137e3d..e7f9841184 100644 --- a/packages/ui/src/features/message-editor/types.ts +++ b/packages/ui/src/features/message-editor/types.ts @@ -4,7 +4,21 @@ import type { FileAttachment, MentionChip, } from "@posthog/core/message-editor/content"; -import type { GithubRefKind, GithubRefState } from "@posthog/shared"; +import type { + GithubRefKind, + GithubRefState, + UploadableSkillSource, +} from "@posthog/shared"; + +export interface LocalSkillCommand { + name: string; + source: UploadableSkillSource; + path: string; +} + +export type EditorAvailableCommand = AvailableCommand & { + localSkill?: LocalSkillCommand; +}; export type GithubIssueState = GithubRefState; export type { GithubRefKind, GithubRefState }; @@ -33,6 +47,9 @@ export interface SuggestionItem { description?: string; filename?: string; chipType?: MentionChip["type"]; + skillPath?: string; + skillSource?: UploadableSkillSource; + skillName?: string; } export interface FileSuggestionItem extends SuggestionItem { @@ -41,7 +58,7 @@ export interface FileSuggestionItem extends SuggestionItem { } export interface CommandSuggestionItem extends SuggestionItem { - command: AvailableCommand; + command: EditorAvailableCommand; } export interface IssueSuggestionItem extends SuggestionItem { diff --git a/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts b/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts index 70796b1983..6569352243 100644 --- a/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts +++ b/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts @@ -7,8 +7,12 @@ import { type SessionService, } from "@posthog/core/sessions/sessionService"; import { useService } from "@posthog/di/react"; +import { useHostTRPCClient } from "@posthog/host-router/react"; import type { Task } from "@posthog/shared/domain-types"; -import { tryExecuteCodeCommand } from "@posthog/ui/features/message-editor/commands"; +import { + rewriteLocalSkillCommandPrompt, + tryExecuteCodeCommand, +} from "@posthog/ui/features/message-editor/commands"; import { useDraftStore } from "@posthog/ui/features/message-editor/draftStore"; import { useMessagingMode } from "@posthog/ui/features/sessions/hooks/useMessagingMode"; import { @@ -42,6 +46,7 @@ export function useSessionCallbacks({ }: UseSessionCallbacksOptions) { const sessionService = useService(SESSION_SERVICE); const shellClient = useService(SHELL_CLIENT); + const hostClient = useHostTRPCClient(); const { markActivity, markAsViewed } = useTaskViewed(); const { requestFocus, setPendingContent } = useDraftStore((s) => s.actions); @@ -68,10 +73,40 @@ export function useSessionCallbacks({ }); if (handled) return; + let promptText = + rewriteLocalSkillCommandPrompt( + text, + useDraftStore.getState().commands[taskId] ?? [], + ) ?? null; + + if (!promptText && text.trim().startsWith("/")) { + try { + const skills = await hostClient.skills.list.query(); + promptText = rewriteLocalSkillCommandPrompt( + text, + skills.map((skill) => ({ + name: skill.name, + description: skill.description, + ...(skill.source === "bundled" + ? {} + : { + localSkill: { + name: skill.name, + source: skill.source, + path: skill.path, + }, + }), + })), + ); + } catch (error) { + log.warn("Failed to resolve local skill command", { error }); + } + } + try { markAsViewed(taskId); markActivity(taskId); - await sessionService.sendPrompt(taskId, text, { + await sessionService.sendPrompt(taskId, promptText ?? text, { steer: messagingMode === "steer", }); @@ -95,6 +130,7 @@ export function useSessionCallbacks({ markAsViewed, task.latest_run, sessionService, + hostClient, messagingMode, ], ); diff --git a/packages/ui/src/features/sessions/sessionServiceHost.test.ts b/packages/ui/src/features/sessions/sessionServiceHost.test.ts index 82a27d257f..e47d81b403 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.test.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.test.ts @@ -44,6 +44,11 @@ const mockTrpcFs = vi.hoisted(() => ({ readFileAsBase64: { query: vi.fn() }, })); +const mockTrpcSkills = vi.hoisted(() => ({ + list: { query: vi.fn() }, + bundleLocal: { query: vi.fn() }, +})); + const mockTrpcHandoff = vi.hoisted(() => ({ preflightToCloud: { query: vi.fn() }, executeToCloud: { mutate: vi.fn() }, @@ -260,6 +265,7 @@ vi.mock("@posthog/di/container", () => ({ logs: mockTrpcLogs, cloudTask: mockTrpcCloudTask, fs: mockTrpcFs, + skills: mockTrpcSkills, handoff: mockTrpcHandoff, os: mockTrpcOs, }; @@ -418,6 +424,10 @@ describe("SessionService", () => { unsubscribe: vi.fn(), }); mockTrpcFs.readFileAsBase64.query.mockResolvedValue(null); + mockTrpcSkills.list.query.mockResolvedValue([]); + mockTrpcSkills.bundleLocal.query.mockRejectedValue( + new Error("Unexpected skill bundle upload"), + ); mockTrpcHandoff.preflightToCloud.query.mockResolvedValue({ canHandoff: true, }); @@ -4004,6 +4014,101 @@ describe("SessionService", () => { ); }); + it("resolves raw local skill slash commands before sending cloud follow-ups", async () => { + const service = getSessionService(); + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue( + createMockSession({ + isCloud: true, + cloudStatus: "in_progress", + }), + ); + mockTrpcSkills.list.query.mockResolvedValue([ + { + name: "local-test-skill", + description: "Local user skill", + source: "user", + path: "/Users/example/.claude/skills/local-test-skill", + }, + ]); + mockTrpcSkills.bundleLocal.query.mockResolvedValue({ + name: "local-test-skill", + source: "user", + fileName: "local-test-skill.zip", + contentType: "application/zip", + contentBase64: btoa("skill-bundle"), + contentSha256: + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + size: 12, + }); + mockAuthenticatedClient.prepareTaskRunArtifactUploads.mockResolvedValue([ + { + id: "skill-prep-1", + name: "local-test-skill.zip", + type: "skill_bundle", + source: "posthog_code_skill", + size: 12, + content_type: "application/zip", + storage_path: "tasks/artifacts/local-test-skill.zip", + expires_in: 3600, + presigned_post: { + url: "https://uploads.example.com", + fields: { key: "tasks/artifacts/local-test-skill.zip" }, + }, + }, + ]); + mockAuthenticatedClient.finalizeTaskRunArtifactUploads.mockResolvedValue([ + { + id: "skill-artifact-1", + name: "local-test-skill.zip", + type: "skill_bundle", + source: "posthog_code_skill", + size: 12, + content_type: "application/zip", + storage_path: "tasks/artifacts/local-test-skill.zip", + uploaded_at: "2026-04-16T00:00:00Z", + }, + ]); + mockTrpcCloudTask.sendCommand.mutate.mockResolvedValue({ + success: true, + result: { queued: true }, + }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: true } as Response), + ); + + const result = await service.sendPrompt("task-123", "/local-test-skill"); + + expect(result.stopReason).toBe("queued"); + expect(mockTrpcSkills.bundleLocal.query).toHaveBeenCalledWith({ + name: "local-test-skill", + source: "user", + path: "/Users/example/.claude/skills/local-test-skill", + }); + expect( + mockAuthenticatedClient.prepareTaskRunArtifactUploads, + ).toHaveBeenCalledWith("task-123", "run-123", [ + expect.objectContaining({ + name: "local-test-skill.zip", + type: "skill_bundle", + source: "posthog_code_skill", + metadata: expect.objectContaining({ + skill_name: "local-test-skill", + skill_source: "user", + }), + }), + ]); + expect(mockTrpcCloudTask.sendCommand.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + method: "user_message", + params: { + content: "/local-test-skill", + artifact_ids: ["skill-artifact-1"], + }, + }), + ); + }); + it("preserves codex runtime selection when resuming a terminal cloud run", async () => { const service = getSessionService(); mockSessionStoreSetters.getSessionByTaskId.mockReturnValue( diff --git a/packages/ui/src/features/sessions/sessionServiceHost.ts b/packages/ui/src/features/sessions/sessionServiceHost.ts index 8dd499b724..f232cfe1ba 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.ts @@ -45,6 +45,7 @@ import { IMPERATIVE_QUERY_CLIENT, type ImperativeQueryClient, } from "../../shell/queryClient"; +import { rewriteLocalSkillCommandPrompt } from "../message-editor/commands"; export { SessionService }; @@ -59,8 +60,9 @@ function buildSessionServiceDeps(): SessionServiceDeps { const queryClient = resolveService( IMPERATIVE_QUERY_CLIENT, ); - const cloudArtifactService = new CloudArtifactService((filePath) => - trpc.fs.readFileAsBase64.query({ filePath }), + const cloudArtifactService = new CloudArtifactService( + (filePath) => trpc.fs.readFileAsBase64.query({ filePath }), + (skillBundleRef) => trpc.skills.bundleLocal.query(skillBundleRef), ); return { @@ -126,18 +128,43 @@ function buildSessionServiceDeps(): SessionServiceDeps { cloudPromptToBlocks, combineQueuedCloudPrompts, getCloudPromptTransport, - uploadRunAttachments: (client, taskId, runId, filePaths) => + resolveLocalSkillCommandPrompt: async (prompt) => { + if (!prompt.trim().startsWith("/")) { + return null; + } + + const skills = await trpc.skills.list.query(); + return rewriteLocalSkillCommandPrompt( + prompt, + skills.map((skill) => ({ + name: skill.name, + description: skill.description, + ...(skill.source === "bundled" + ? {} + : { + localSkill: { + name: skill.name, + source: skill.source, + path: skill.path, + }, + }), + })), + ); + }, + uploadRunAttachments: (client, taskId, runId, filePaths, skillBundles) => cloudArtifactService.uploadRunAttachments( client, taskId, runId, filePaths, + skillBundles, ), - uploadTaskStagedAttachments: (client, taskId, filePaths) => + uploadTaskStagedAttachments: (client, taskId, filePaths, skillBundles) => cloudArtifactService.uploadTaskStagedAttachments( client, taskId, filePaths, + skillBundles, ), }, }; diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 93631a781d..782eb17a88 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -53,6 +53,7 @@ import { type AgentAdapter, useSettingsStore, } from "../../settings/settingsStore"; +import { useSkills } from "../../skills/useSkills"; import { areReposReady, useInitialRepoSelectionFromFolderId, @@ -165,6 +166,7 @@ export function TaskInput({ setLastUsedModel, _hasHydrated: settingsHydrated, } = useSettingsStore(); + const { data: skills } = useSkills(); const editorRef = useRef(null); const containerRef = useRef(null); @@ -653,6 +655,28 @@ export function TaskInput({ const { isOnline } = useConnectivity(); const promptSessionId = sessionId; + useEffect(() => { + if (!skills) return; + useDraftStore.getState().actions.setCommands( + promptSessionId, + skills.map((skill) => ({ + name: skill.name, + description: skill.description, + ...(skill.source !== "bundled" + ? { + localSkill: { + name: skill.name, + source: skill.source, + path: skill.path, + }, + } + : {}), + })), + ); + return () => { + useDraftStore.getState().actions.clearCommands(promptSessionId); + }; + }, [promptSessionId, skills]); const hasHistory = useTaskInputHistoryStore((s) => s.entries.length > 0); const getPromptHistory = useCallback( () => useTaskInputHistoryStore.getState().entries.map((e) => e.text), diff --git a/packages/ui/src/features/task-detail/taskCreationHostImpl.ts b/packages/ui/src/features/task-detail/taskCreationHostImpl.ts index d88885507f..7e5b2b6247 100644 --- a/packages/ui/src/features/task-detail/taskCreationHostImpl.ts +++ b/packages/ui/src/features/task-detail/taskCreationHostImpl.ts @@ -149,6 +149,7 @@ export class TrpcTaskCreationHost implements ITaskCreationHost { taskId: string, runId: string, filePaths: string[], + skillBundles?: CloudPromptTransport["skillBundles"], ): Promise { return resolveService( CLOUD_ARTIFACT_SERVICE, @@ -157,6 +158,7 @@ export class TrpcTaskCreationHost implements ITaskCreationHost { taskId, runId, filePaths, + skillBundles, ); } diff --git a/packages/workspace-server/src/services/skills/schemas.ts b/packages/workspace-server/src/services/skills/schemas.ts index 3f038e1d57..8ad47242be 100644 --- a/packages/workspace-server/src/services/skills/schemas.ts +++ b/packages/workspace-server/src/services/skills/schemas.ts @@ -118,9 +118,28 @@ export const installTeamSkillInput = z.object({ export type ExportedSkill = z.infer; export type InstallTeamSkillInput = z.infer; +export const bundleLocalSkillInput = z.object({ + name: z.string().min(1), + source: z.enum(["user", "repo", "marketplace", "codex"]), + path: z.string().min(1), +}); + +export const bundleLocalSkillOutput = z.object({ + name: z.string(), + source: z.enum(["user", "repo", "marketplace", "codex"]), + fileName: z.string(), + contentType: z.literal("application/zip"), + contentBase64: z.string(), + contentSha256: z.string(), + size: z.number().int().positive(), +}); + +export type BundleLocalSkillInput = z.infer; +export type BundleLocalSkillOutput = z.infer; export type SkillInfo = z.infer; export type SkillScope = z.infer; export type CreateSkillInput = z.infer; export type SkillSource = z.infer; export type SkillFileEntry = z.infer; export type SkillContents = z.infer; +export type UploadableSkillSource = BundleLocalSkillInput["source"]; diff --git a/packages/workspace-server/src/services/skills/skill-bundler.ts b/packages/workspace-server/src/services/skills/skill-bundler.ts new file mode 100644 index 0000000000..91c2d3141c --- /dev/null +++ b/packages/workspace-server/src/services/skills/skill-bundler.ts @@ -0,0 +1,163 @@ +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { strToU8, zipSync } from "fflate"; +import type { BundleLocalSkillOutput, UploadableSkillSource } from "./schemas"; + +const SKILL_BUNDLE_MAX_BYTES = 30 * 1024 * 1024; +const SKILL_BUNDLE_MAX_FILES = 1000; +const IGNORED_ENTRIES = new Set([ + ".DS_Store", + ".git", + "node_modules", + "__pycache__", +]); + +function toZipPath(filePath: string): string { + return filePath.split(path.sep).join("/"); +} + +function getSafeSkillFileName(name: string): string { + const safeName = path.basename(name).replace(/[^\w.-]/g, "_"); + return safeName.length > 0 ? safeName : "skill"; +} + +async function assertSkillRoot(skillPath: string): Promise { + const root = await fs.promises.realpath(path.resolve(skillPath)); + const skillMdPath = path.join(root, "SKILL.md"); + const stat = await fs.promises.stat(skillMdPath); + if (!stat.isFile()) { + throw new Error("Local skill bundle must contain a SKILL.md file"); + } + return root; +} + +function isInsideRoot(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return ( + Boolean(relative) && + !relative.startsWith("..") && + !path.isAbsolute(relative) + ); +} + +async function collectSkillFiles( + root: string, + currentDir: string, + files: Record, +): Promise { + const entries = await fs.promises.readdir(currentDir, { + withFileTypes: true, + }); + let totalBytes = 0; + + for (const entry of entries) { + if (IGNORED_ENTRIES.has(entry.name)) { + continue; + } + + const absolutePath = path.join(currentDir, entry.name); + const relativePath = path.relative(root, absolutePath); + if ( + !relativePath || + relativePath.startsWith("..") || + path.isAbsolute(relativePath) + ) { + continue; + } + + if (entry.isSymbolicLink()) { + const realPath = await fs.promises + .realpath(absolutePath) + .catch(() => null); + if (!realPath || !isInsideRoot(root, realPath)) { + continue; + } + const stat = await fs.promises.stat(realPath); + if (!stat.isFile()) { + continue; + } + const content = await fs.promises.readFile(realPath); + files[toZipPath(relativePath)] = new Uint8Array(content); + totalBytes += content.byteLength; + continue; + } + + if (entry.isDirectory()) { + totalBytes += await collectSkillFiles(root, absolutePath, files); + continue; + } + + if (!entry.isFile()) { + continue; + } + + const content = await fs.promises.readFile(absolutePath); + files[toZipPath(relativePath)] = new Uint8Array(content); + totalBytes += content.byteLength; + } + + return totalBytes; +} + +export async function bundleLocalSkill({ + name, + source, + skillPath, +}: { + name: string; + source: UploadableSkillSource; + skillPath: string; +}): Promise { + const root = await assertSkillRoot(skillPath); + const files: Record = {}; + const totalBytes = await collectSkillFiles(root, root, files); + const fileNames = Object.keys(files).sort(); + + if (!files["SKILL.md"]) { + throw new Error("Local skill bundle must contain a SKILL.md file"); + } + if (fileNames.length > SKILL_BUNDLE_MAX_FILES) { + throw new Error( + `Local skill bundle contains more than ${SKILL_BUNDLE_MAX_FILES} files`, + ); + } + if (totalBytes > SKILL_BUNDLE_MAX_BYTES) { + throw new Error("Local skill bundle exceeds the 30MB cloud run limit"); + } + + const manifest = { + schema_version: 1, + name, + source, + bundled_at: new Date().toISOString(), + }; + + const zipInput: Record = {}; + for (const fileName of fileNames) { + zipInput[fileName] = files[fileName]; + } + zipInput["posthog-skill-bundle.json"] = strToU8(JSON.stringify(manifest)); + + const zipped = zipSync(zipInput, { level: 6 }); + if (zipped.byteLength > SKILL_BUNDLE_MAX_BYTES) { + throw new Error( + "Local skill bundle archive exceeds the 30MB cloud run limit", + ); + } + + const contentSha256 = crypto + .createHash("sha256") + .update(zipped) + .digest("hex"); + + return { + name, + source, + fileName: `${getSafeSkillFileName(name)}.zip`, + contentType: "application/zip", + contentBase64: Buffer.from(zipped).toString("base64"), + contentSha256, + size: zipped.byteLength, + }; +} diff --git a/packages/workspace-server/src/services/skills/skills.ts b/packages/workspace-server/src/services/skills/skills.ts index dcb6974859..76e630b46e 100644 --- a/packages/workspace-server/src/services/skills/skills.ts +++ b/packages/workspace-server/src/services/skills/skills.ts @@ -14,6 +14,8 @@ import type { PosthogPluginService } from "../posthog-plugin/posthog-plugin"; import type { WatcherService } from "../watcher/service"; import { parseSkillFrontmatter } from "./parse-skill-frontmatter"; import type { + BundleLocalSkillInput, + BundleLocalSkillOutput, CreateSkillInput, ExportedSkill, InstallTeamSkillInput, @@ -21,6 +23,7 @@ import type { SkillInfo, SkillSource, } from "./schemas"; +import { bundleLocalSkill } from "./skill-bundler"; import { getMarketplaceInstallPaths, getUserSkillsDir, @@ -485,6 +488,16 @@ export class SkillsService { } return resolved; } + + bundleLocalSkill( + input: BundleLocalSkillInput, + ): Promise { + return bundleLocalSkill({ + name: input.name, + source: input.source, + skillPath: input.path, + }); + } } export function validateSkillDirName(name: string): void { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c7194a670..276f32ce31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -746,6 +746,9 @@ importers: commander: specifier: ^14.0.2 version: 14.0.3 + fflate: + specifier: ^0.8.2 + version: 0.8.2 hono: specifier: ^4.11.7 version: 4.11.7 From 18ca1040d2072e270fdd15108fc4a3dd1b513f76 Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Wed, 24 Jun 2026 17:42:56 +0100 Subject: [PATCH 2/3] fix(tasks): update queued cloud prompt test --- packages/ui/src/features/sessions/sessionServiceHost.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/features/sessions/sessionServiceHost.test.ts b/packages/ui/src/features/sessions/sessionServiceHost.test.ts index e47d81b403..1f248e7e91 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.test.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.test.ts @@ -3881,6 +3881,7 @@ describe("SessionService", () => { expect(mockSessionStoreSetters.enqueueMessage).toHaveBeenCalledWith( "task-123", "before boot", + prompt, ); const wroteIsPromptPendingTrue = mockSessionStoreSetters.updateSession.mock.calls.some( From 9a1a7aaa420df52ea55ef28e019f06d42dfe7a1f Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Wed, 24 Jun 2026 17:46:29 +0100 Subject: [PATCH 3/3] fix(tasks): share skill tag parsing --- packages/core/src/editor/cloud-prompt.ts | 16 +---- packages/core/src/message-editor/content.ts | 18 +----- packages/core/src/message-editor/skillTags.ts | 63 +++++++++++++++++++ packages/core/src/sessions/cloudPrompt.ts | 32 ++-------- packages/core/src/sessions/sessionEvents.ts | 24 +------ 5 files changed, 74 insertions(+), 79 deletions(-) create mode 100644 packages/core/src/message-editor/skillTags.ts diff --git a/packages/core/src/editor/cloud-prompt.ts b/packages/core/src/editor/cloud-prompt.ts index 0e37f7d4ea..dff4659d10 100644 --- a/packages/core/src/editor/cloud-prompt.ts +++ b/packages/core/src/editor/cloud-prompt.ts @@ -11,14 +11,13 @@ import { serializeCloudPrompt, unescapeXmlAttr, } from "@posthog/shared"; +import { skillTagsToSlashCommands } from "../message-editor/skillTags"; export type ReadFileAsBase64 = (filePath: string) => Promise; const ABSOLUTE_FILE_TAG_REGEX = //g; const FOLDER_TAG_REGEX = //g; const FOLDER_TAG_PATH_REGEX = //g; -const SKILL_TAG_REGEX = /]*?)\s*\/>/g; -const ATTR_REGEX = /(\w+)="([^"]*)"/g; const TEXT_EXTENSIONS = new Set([ "c", "cc", @@ -113,19 +112,8 @@ function normalizePromptText(prompt: string): string { return prompt.replace(/\n{3,}/g, "\n\n").trim(); } -function parseAttrs(raw: string): Record { - const attrs: Record = {}; - for (const match of raw.matchAll(ATTR_REGEX)) { - attrs[match[1]] = unescapeXmlAttr(match[2]); - } - return attrs; -} - export function stripSkillTags(prompt: string): string { - return prompt.replaceAll(SKILL_TAG_REGEX, (_match, rawAttrs: string) => { - const attrs = parseAttrs(rawAttrs); - return attrs.name ? `/${attrs.name}` : ""; - }); + return skillTagsToSlashCommands(prompt); } export function stripAttachmentTags(prompt: string): string { diff --git a/packages/core/src/message-editor/content.ts b/packages/core/src/message-editor/content.ts index 4b5dd33823..2c028cce92 100644 --- a/packages/core/src/message-editor/content.ts +++ b/packages/core/src/message-editor/content.ts @@ -1,8 +1,5 @@ -import { - escapeXmlAttr, - type UploadableSkillSource, - unescapeXmlAttr, -} from "@posthog/shared"; +import { escapeXmlAttr, type UploadableSkillSource } from "@posthog/shared"; +import { parseXmlAttrs } from "./skillTags"; export interface MentionChip { type: @@ -108,7 +105,6 @@ export function contentToXml(content: EditorContent): string { const CHIP_TAG_REGEX = /<(file|folder|skill|error|experiment|insight|feature_flag|github_issue|github_pr)\b([^>]*?)\s*\/>/g; -const ATTR_REGEX = /(\w+)="([^"]*)"/g; export function deriveFileLabel(filePath: string): string { const segments = filePath.split("/").filter(Boolean); @@ -117,16 +113,8 @@ export function deriveFileLabel(filePath: string): string { return parentDir ? `${parentDir}/${fileName}` : fileName; } -function parseAttrs(raw: string): Record { - const attrs: Record = {}; - for (const match of raw.matchAll(ATTR_REGEX)) { - attrs[match[1]] = unescapeXmlAttr(match[2]); - } - return attrs; -} - function chipFromTag(tag: string, rawAttrs: string): MentionChip | null { - const attrs = parseAttrs(rawAttrs); + const attrs = parseXmlAttrs(rawAttrs); switch (tag) { case "file": { const path = attrs.path; diff --git a/packages/core/src/message-editor/skillTags.ts b/packages/core/src/message-editor/skillTags.ts new file mode 100644 index 0000000000..0d4c1bf26b --- /dev/null +++ b/packages/core/src/message-editor/skillTags.ts @@ -0,0 +1,63 @@ +import { type UploadableSkillSource, unescapeXmlAttr } from "@posthog/shared"; + +const SKILL_TAG_REGEX = /]*?)\s*\/>/g; +const XML_ATTR_REGEX = /(\w+)="([^"]*)"/g; + +export interface UploadableSkillTag { + name: string; + source: UploadableSkillSource; + path: string; +} + +export function parseXmlAttrs(raw: string): Record { + const attrs: Record = {}; + for (const match of raw.matchAll(XML_ATTR_REGEX)) { + attrs[match[1]] = unescapeXmlAttr(match[2]); + } + return attrs; +} + +function isUploadableSkillSource( + source: string | undefined, +): source is UploadableSkillSource { + return ( + source === "user" || + source === "repo" || + source === "marketplace" || + source === "codex" + ); +} + +export function replaceSkillTags( + prompt: string, + replacer: (attrs: Record) => string, +): string { + return prompt.replaceAll(SKILL_TAG_REGEX, (_match, rawAttrs: string) => + replacer(parseXmlAttrs(rawAttrs)), + ); +} + +export function skillTagsToSlashCommands(prompt: string): string { + return replaceSkillTags(prompt, (attrs) => + attrs.name ? `/${attrs.name}` : "", + ); +} + +export function collectUploadableSkillTags( + prompt: string, +): UploadableSkillTag[] { + const tags: UploadableSkillTag[] = []; + + replaceSkillTags(prompt, (attrs) => { + if (attrs.name && attrs.path && isUploadableSkillSource(attrs.source)) { + tags.push({ + name: attrs.name, + source: attrs.source, + path: attrs.path, + }); + } + return ""; + }); + + return tags; +} diff --git a/packages/core/src/sessions/cloudPrompt.ts b/packages/core/src/sessions/cloudPrompt.ts index e1560cd624..7b2ef124b0 100644 --- a/packages/core/src/sessions/cloudPrompt.ts +++ b/packages/core/src/sessions/cloudPrompt.ts @@ -7,12 +7,11 @@ import { stripSkillTags, } from "@posthog/core/editor/cloud-prompt"; import type { EditorContent } from "@posthog/core/message-editor/content"; -import { getFileName, pathToFileUri, unescapeXmlAttr } from "@posthog/shared"; +import { collectUploadableSkillTags } from "@posthog/core/message-editor/skillTags"; +import { getFileName, pathToFileUri } from "@posthog/shared"; import type { CloudSkillBundleRef } from "./cloudArtifactIdentifiers"; const FILE_URI_PREFIX = "file://"; -const SKILL_TAG_REGEX = /]*?)\s*\/>/g; -const ATTR_REGEX = /(\w+)="([^"]*)"/g; export interface CloudPromptTransport { filePaths: string[]; @@ -67,38 +66,17 @@ function collectBlockAttachmentPaths(prompt: ContentBlock[]): string[] { return Array.from(new Set(filePaths)); } -function parseAttrs(raw: string): Record { - const attrs: Record = {}; - for (const match of raw.matchAll(ATTR_REGEX)) { - attrs[match[1]] = unescapeXmlAttr(match[2]); - } - return attrs; -} - function collectSkillBundleRefs(prompt: string): CloudSkillBundleRef[] { const refs: CloudSkillBundleRef[] = []; const seen = new Set(); - for (const match of prompt.matchAll(SKILL_TAG_REGEX)) { - const attrs = parseAttrs(match[1] ?? ""); - const source = attrs.source; - if ( - !attrs.name || - !attrs.path || - (source !== "user" && - source !== "repo" && - source !== "marketplace" && - source !== "codex") - ) { - continue; - } - - const key = `${source}:${attrs.path}`; + for (const tag of collectUploadableSkillTags(prompt)) { + const key = `${tag.source}:${tag.path}`; if (seen.has(key)) { continue; } seen.add(key); - refs.push({ name: attrs.name, source, path: attrs.path }); + refs.push(tag); } return refs; diff --git a/packages/core/src/sessions/sessionEvents.ts b/packages/core/src/sessions/sessionEvents.ts index be1cdc7af5..7bd9df93ee 100644 --- a/packages/core/src/sessions/sessionEvents.ts +++ b/packages/core/src/sessions/sessionEvents.ts @@ -15,32 +15,10 @@ import type { UserShellExecuteParams, } from "@posthog/shared"; import { isJsonRpcNotification, isJsonRpcRequest } from "@posthog/shared"; +import { skillTagsToSlashCommands } from "../message-editor/skillTags"; import { isNotification, POSTHOG_NOTIFICATIONS } from "./acpNotifications"; import { extractPromptDisplayContent } from "./promptContent"; -const SKILL_TAG_REGEX = /]*?)\s*\/>/g; -const ATTR_REGEX = /(\w+)="([^"]*)"/g; - -function decodeXmlAttr(value: string): string { - return value - .replaceAll(""", '"') - .replaceAll("'", "'") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll("&", "&"); -} - -function skillTagsToSlashCommands(prompt: string): string { - return prompt.replaceAll(SKILL_TAG_REGEX, (_match, rawAttrs: string) => { - for (const attrMatch of rawAttrs.matchAll(ATTR_REGEX)) { - if (attrMatch[1] === "name" && attrMatch[2]) { - return `/${decodeXmlAttr(attrMatch[2])}`; - } - } - return ""; - }); -} - /** * Convert a stored log entry to an ACP message. */