From f2a911de9945a13b6a0f69553d170abb6888fa2b Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 31 Jul 2026 13:17:09 -0400 Subject: [PATCH 1/2] feat(skills): always-on skills injected into every new task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-skill "Always-on" toggle (user/marketplace/codex/bundled sources) that injects toggled skills into every new task's first message — like a channel's CONTEXT.md — on both cloud and local runs. Cloud rides the existing bundle-upload pipeline: refs append to the transport's skillBundles and a /name manifest folds into the first message so the sandbox's existing inliner picks them up. Local inlines SKILL.md bodies into initialPrompt via the existing skills.readFile surface. Refs persist as {name, source} in the settings store and re-resolve at creation, so moved or deleted skills self-heal. The transcript collapses the injected block into an "Always-on skills (n)" chip. Generated-By: PostHog Code Task-Id: 4e1d8b15-54b7-4555-a21f-150eead107f4 --- .../core/src/editor/prompt-builder.test.ts | 101 +++++++++++++++ packages/core/src/editor/prompt-builder.ts | 70 ++++++++++ packages/core/src/sessions/cloudPrompt.ts | 35 ++++- .../core/src/skills/alwaysOnSkills.test.ts | 120 ++++++++++++++++++ packages/core/src/skills/alwaysOnSkills.ts | 118 +++++++++++++++++ .../core/src/task-detail/taskCreationHost.ts | 10 ++ .../src/task-detail/taskCreationSaga.test.ts | 96 ++++++++++++++ .../core/src/task-detail/taskCreationSaga.ts | 70 ++++++++-- .../core/src/task-detail/taskService.test.ts | 1 + packages/shared/src/analytics-events.ts | 9 ++ .../components/chat-thread/ChatThread.tsx | 20 ++- .../chat-thread/composerPromptRecall.ts | 10 +- .../components/mergeConversationItems.ts | 9 +- .../components/session-update/UserMessage.tsx | 24 +++- .../session-update/alwaysOnSkills.test.ts | 35 +++++ .../session-update/alwaysOnSkills.ts | 47 +++++++ .../features/settings/settingsStore.test.ts | 46 +++++++ .../ui/src/features/settings/settingsStore.ts | 35 +++++ packages/ui/src/features/skills/SkillCard.tsx | 16 ++- .../src/features/skills/SkillDetailPanel.tsx | 21 +++ .../ui/src/features/skills/SkillsView.tsx | 12 ++ .../src/features/skills/useAlwaysOnSkill.ts | 32 +++++ .../task-detail/taskCreationHostImpl.ts | 52 +++++++- 23 files changed, 962 insertions(+), 27 deletions(-) create mode 100644 packages/core/src/skills/alwaysOnSkills.test.ts create mode 100644 packages/core/src/skills/alwaysOnSkills.ts create mode 100644 packages/ui/src/features/sessions/components/session-update/alwaysOnSkills.test.ts create mode 100644 packages/ui/src/features/sessions/components/session-update/alwaysOnSkills.ts create mode 100644 packages/ui/src/features/skills/useAlwaysOnSkill.ts diff --git a/packages/core/src/editor/prompt-builder.test.ts b/packages/core/src/editor/prompt-builder.test.ts index e4df963617..334e0c7e13 100644 --- a/packages/core/src/editor/prompt-builder.test.ts +++ b/packages/core/src/editor/prompt-builder.test.ts @@ -1,5 +1,9 @@ +import type { ResolvedAlwaysOnSkill } from "@posthog/core/skills/alwaysOnSkills"; import { describe, expect, it } from "vitest"; import { + ALWAYS_ON_SKILL_MD_MAX_BYTES, + buildAlwaysOnSkillsBlock, + buildAlwaysOnSkillsCloudText, buildChannelContextBlock, buildChannelContextText, buildCustomInstructionsText, @@ -67,6 +71,103 @@ describe("buildCustomInstructionsText", () => { }); }); +const alwaysOnSkill = ( + overrides: Partial = {}, +): ResolvedAlwaysOnSkill => ({ + name: "i-have-adhd", + source: "user", + path: "/home/u/.claude/skills/i-have-adhd", + description: "Focus aid", + skillMdBytes: 120, + ...overrides, +}); + +// Mirrors the sandbox's token-boundary mention matcher +// (buildAttachedSkillsPromptContext in @posthog/agent): the cloud text's +// reference lines must trip it or the uploaded bundles are never inlined. +function sandboxMentionRegex(skillName: string): RegExp { + const escaped = skillName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(^|[\\s(\`"'\\[])/${escaped}(?![A-Za-z0-9_/-])`, "m"); +} + +describe("buildAlwaysOnSkillsCloudText", () => { + it("returns null when no skills are toggled on", () => { + expect(buildAlwaysOnSkillsCloudText([])).toBeNull(); + }); + + it("lists each skill as a /name mention the sandbox inliner matches", () => { + const text = buildAlwaysOnSkillsCloudText([ + alwaysOnSkill(), + alwaysOnSkill({ name: "review.checklist", source: "codex" }), + ]); + expect(text?.startsWith("\n")).toBe(true); + expect(text).toContain("marked these skills as always-on"); + expect(text).toContain("- /i-have-adhd: Focus aid"); + expect(text).toMatch(sandboxMentionRegex("i-have-adhd")); + expect(text).toMatch(sandboxMentionRegex("review.checklist")); + expect(text?.endsWith("\n")).toBe(true); + }); + + it("marks bundled skills as preinstalled and tolerates empty descriptions", () => { + const text = buildAlwaysOnSkillsCloudText([ + alwaysOnSkill({ name: "max", source: "bundled", description: "" }), + ]); + expect(text).toContain("- /max (preinstalled PostHog skill)"); + }); +}); + +describe("buildAlwaysOnSkillsBlock", () => { + it("returns null when no skills are toggled on", () => { + expect(buildAlwaysOnSkillsBlock([])).toBeNull(); + }); + + it("inlines a skill body with its directory path", () => { + const block = buildAlwaysOnSkillsBlock([ + alwaysOnSkill({ body: "Keep responses short." }), + ]); + const text = (block as { text: string }).text; + expect(block?.type).toBe("text"); + expect(text).toContain("--- BEGIN ALWAYS-ON SKILL i-have-adhd ---"); + expect(text).toContain("Keep responses short."); + expect(text).toContain("--- END ALWAYS-ON SKILL i-have-adhd ---"); + expect(text).toContain( + "Skill directory: /home/u/.claude/skills/i-have-adhd", + ); + }); + + it.each([ + ["no body", alwaysOnSkill()], + [ + "an oversized manifest", + alwaysOnSkill({ + body: "big", + skillMdBytes: ALWAYS_ON_SKILL_MD_MAX_BYTES + 1, + }), + ], + ])("degrades a skill with %s to a path reference", (_label, skill) => { + const text = (buildAlwaysOnSkillsBlock([skill]) as { text: string }).text; + expect(text).not.toContain("BEGIN ALWAYS-ON SKILL"); + expect(text).toContain( + "- /i-have-adhd: Focus aid — read its SKILL.md at /home/u/.claude/skills/i-have-adhd", + ); + }); + + it("stops inlining once the total budget is spent", () => { + const big = 30 * 1024; + const text = ( + buildAlwaysOnSkillsBlock([ + alwaysOnSkill({ name: "first", body: "one", skillMdBytes: big }), + alwaysOnSkill({ name: "second", body: "two", skillMdBytes: big }), + alwaysOnSkill({ name: "third", body: "three", skillMdBytes: big }), + alwaysOnSkill({ name: "fourth", body: "four", skillMdBytes: big }), + ]) as { text: string } + ).text; + expect(text).toContain("--- BEGIN ALWAYS-ON SKILL third ---"); + expect(text).not.toContain("--- BEGIN ALWAYS-ON SKILL fourth ---"); + expect(text).toContain("- /fourth: Focus aid — read its SKILL.md at"); + }); +}); + describe("buildChannelContextBlock", () => { it.each([[undefined], [null], [""], [" \n "]] as const)( "returns null for empty or whitespace content (%s)", diff --git a/packages/core/src/editor/prompt-builder.ts b/packages/core/src/editor/prompt-builder.ts index 5812727b8f..6f281a1721 100644 --- a/packages/core/src/editor/prompt-builder.ts +++ b/packages/core/src/editor/prompt-builder.ts @@ -1,4 +1,5 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; +import type { ResolvedAlwaysOnSkill } from "@posthog/core/skills/alwaysOnSkills"; import { escapeXmlAttr, isAbsolutePath, pathToFileUri } from "@posthog/shared"; export async function buildPromptBlocks( @@ -81,3 +82,72 @@ export function buildChannelContextBlock( const text = buildChannelContextText(content, channelName, channelContextId); return text ? { type: "text", text } : null; } + +// The fixed preamble is also the anchor the conversation UI's stripper matches +// on (session-update/alwaysOnSkills.ts in @posthog/ui), so user-pasted +// lookalike tags are never hidden — keep the wording in sync. +const ALWAYS_ON_SKILLS_PREAMBLE = + "The user has marked these skills as always-on: their instructions apply to this entire session, from your first response on, without being explicitly invoked. If a skill conflicts with the user's message, the message wins."; + +/** SKILL.md bodies above this size are referenced by path instead of inlined. */ +export const ALWAYS_ON_SKILL_MD_MAX_BYTES = 32 * 1024; +const ALWAYS_ON_SKILLS_TOTAL_MAX_BYTES = 96 * 1024; + +function alwaysOnSkillReferenceLine(skill: ResolvedAlwaysOnSkill): string { + const description = skill.description.trim(); + const bundled = + skill.source === "bundled" ? " (preinstalled PostHog skill)" : ""; + return `- /${skill.name}${description ? `: ${description}` : ""}${bundled}`; +} + +// Cloud form of the always-on skills injection: a reference manifest only. +// Bodies are not inlined here — the skills travel as uploaded bundles, and the +// sandbox inlines each bundle's full SKILL.md when its /name is mentioned in +// the message (buildAttachedSkillsPromptContext in @posthog/agent), so these +// lines are what trigger that. Returns null when nothing is toggled on. +export function buildAlwaysOnSkillsCloudText( + skills: ResolvedAlwaysOnSkill[], +): string | null { + if (skills.length === 0) return null; + const lines = skills.map(alwaysOnSkillReferenceLine); + return `\n${ALWAYS_ON_SKILLS_PREAMBLE}\n\n${lines.join("\n")}\n`; +} + +// Local form: inlines each SKILL.md body — a local session has no bundle +// upload or server-side inliner, so the first message is the only delivery +// path. The framing mirrors the sandbox's inline format so both runtimes see +// near-identical context. Entries without a body (bundled skills, oversized +// manifests, read failures) degrade to a reference line with the on-disk path. +export function buildAlwaysOnSkillsBlock( + skills: ResolvedAlwaysOnSkill[], +): ContentBlock | null { + if (skills.length === 0) return null; + const sections: string[] = [ALWAYS_ON_SKILLS_PREAMBLE]; + let inlinedBytes = 0; + for (const skill of skills) { + const body = skill.body?.trim(); + const inline = + body && + skill.skillMdBytes <= ALWAYS_ON_SKILL_MD_MAX_BYTES && + inlinedBytes + skill.skillMdBytes <= ALWAYS_ON_SKILLS_TOTAL_MAX_BYTES; + if (inline) { + inlinedBytes += skill.skillMdBytes; + sections.push( + "", + `--- BEGIN ALWAYS-ON SKILL ${skill.name} ---`, + body, + `--- END ALWAYS-ON SKILL ${skill.name} ---`, + `Skill directory: ${skill.path}`, + ); + } else { + sections.push( + "", + `${alwaysOnSkillReferenceLine(skill)} — read its SKILL.md at ${skill.path}`, + ); + } + } + return { + type: "text", + text: `\n${sections.join("\n")}\n`, + }; +} diff --git a/packages/core/src/sessions/cloudPrompt.ts b/packages/core/src/sessions/cloudPrompt.ts index da0f527b34..91eadb62d4 100644 --- a/packages/core/src/sessions/cloudPrompt.ts +++ b/packages/core/src/sessions/cloudPrompt.ts @@ -8,7 +8,11 @@ import { stripSkillTags, } from "@posthog/core/editor/cloud-prompt"; import type { EditorContent } from "@posthog/core/message-editor/content"; -import { collectUploadableSkillTags } from "@posthog/core/message-editor/skillTags"; +import { + collectUploadableSkillTags, + isUploadableSkillSource, +} from "@posthog/core/message-editor/skillTags"; +import type { ResolvedAlwaysOnSkill } from "@posthog/core/skills/alwaysOnSkills"; import { getFileName, pathToFileUri } from "@posthog/shared"; import type { CloudSkillBundleRef } from "./cloudArtifactIdentifiers"; @@ -130,6 +134,35 @@ export function getCloudPromptTransport( }; } +// Adds the user's always-on skills to the transport's bundle refs so the +// existing upload pipeline ships them with the first message. Bundled-source +// skills are skipped (preinstalled in the sandbox), and refs already collected +// from typed skill tags are not duplicated. +export function appendAlwaysOnSkillBundles( + transport: CloudPromptTransport, + alwaysOnSkills: ResolvedAlwaysOnSkill[], +): CloudPromptTransport { + if (alwaysOnSkills.length === 0) return transport; + const seen = new Set( + transport.skillBundles.map((ref) => `${ref.source}:${ref.path}`), + ); + const skillBundles = [...transport.skillBundles]; + for (const skill of alwaysOnSkills) { + if (!isUploadableSkillSource(skill.source)) continue; + const key = `${skill.source}:${skill.path}`; + if (seen.has(key)) continue; + seen.add(key); + skillBundles.push({ + name: skill.name, + source: skill.source, + path: skill.path, + }); + } + return skillBundles.length === transport.skillBundles.length + ? transport + : { ...transport, skillBundles }; +} + export function cloudPromptToBlocks(prompt: QueuedCloudPrompt): ContentBlock[] { if (typeof prompt !== "string") { return prompt; diff --git a/packages/core/src/skills/alwaysOnSkills.test.ts b/packages/core/src/skills/alwaysOnSkills.test.ts new file mode 100644 index 0000000000..1952f5c499 --- /dev/null +++ b/packages/core/src/skills/alwaysOnSkills.test.ts @@ -0,0 +1,120 @@ +import type { SkillInfo } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { + type AlwaysOnSkillRef, + isAlwaysOnSkill, + pruneAlwaysOnSkillRefs, + resolveAlwaysOnSkills, + sanitizeAlwaysOnSkillRefs, + toggleAlwaysOnSkillRef, +} from "./alwaysOnSkills"; + +const skill = (overrides: Partial = {}): SkillInfo => ({ + name: "i-have-adhd", + description: "Focus aid", + source: "user", + path: "/home/u/.claude/skills/i-have-adhd", + editable: true, + skillMdBytes: 120, + ...overrides, +}); + +describe("toggleAlwaysOnSkillRef", () => { + it("adds and removes a ref for a toggleable source", () => { + const added = toggleAlwaysOnSkillRef([], skill(), true); + expect(added).toEqual([{ name: "i-have-adhd", source: "user" }]); + expect(isAlwaysOnSkill(added, skill())).toBe(true); + + const removed = toggleAlwaysOnSkillRef(added, skill(), false); + expect(removed).toEqual([]); + expect(isAlwaysOnSkill(removed, skill())).toBe(false); + }); + + it("does not duplicate an already-toggled skill", () => { + const refs: AlwaysOnSkillRef[] = [{ name: "i-have-adhd", source: "user" }]; + expect(toggleAlwaysOnSkillRef(refs, skill(), true)).toHaveLength(1); + }); + + it("ignores repo-source skills", () => { + expect(toggleAlwaysOnSkillRef([], skill({ source: "repo" }), true)).toEqual( + [], + ); + }); +}); + +describe("sanitizeAlwaysOnSkillRefs", () => { + it.each([[undefined], [null], ["nope"], [42]])( + "returns an empty list for non-array input (%s)", + (input) => { + expect(sanitizeAlwaysOnSkillRefs(input)).toEqual([]); + }, + ); + + it("keeps valid refs and drops malformed, repo, and duplicate entries", () => { + expect( + sanitizeAlwaysOnSkillRefs([ + { name: "a", source: "user" }, + { name: "b", source: "repo" }, + { name: "", source: "user" }, + { name: 42, source: "user" }, + { source: "codex" }, + null, + { name: "a", source: "user" }, + { name: "c", source: "bundled" }, + ]), + ).toEqual([ + { name: "a", source: "user" }, + { name: "c", source: "bundled" }, + ]); + }); +}); + +describe("resolveAlwaysOnSkills", () => { + it("resolves refs against the live list by name and source", () => { + const resolved = resolveAlwaysOnSkills( + [{ name: "i-have-adhd", source: "user" }], + [skill({ path: "/moved/i-have-adhd" })], + ); + expect(resolved).toEqual([ + { + name: "i-have-adhd", + source: "user", + path: "/moved/i-have-adhd", + description: "Focus aid", + skillMdBytes: 120, + }, + ]); + }); + + it("drops refs with no live match and dedupes repeated refs", () => { + const resolved = resolveAlwaysOnSkills( + [ + { name: "gone", source: "user" }, + { name: "i-have-adhd", source: "user" }, + { name: "i-have-adhd", source: "user" }, + // Same name under a different source must not match. + { name: "i-have-adhd", source: "codex" }, + ], + [skill()], + ); + expect(resolved).toHaveLength(1); + expect(resolved[0].name).toBe("i-have-adhd"); + }); +}); + +describe("pruneAlwaysOnSkillRefs", () => { + it("returns the same array by identity when nothing is pruned", () => { + const refs: AlwaysOnSkillRef[] = [{ name: "i-have-adhd", source: "user" }]; + expect(pruneAlwaysOnSkillRefs(refs, [skill()])).toBe(refs); + }); + + it("drops refs whose skill no longer exists", () => { + const refs: AlwaysOnSkillRef[] = [ + { name: "i-have-adhd", source: "user" }, + { name: "gone", source: "codex" }, + ]; + expect(pruneAlwaysOnSkillRefs(refs, [skill()])).toEqual([ + { name: "i-have-adhd", source: "user" }, + ]); + }); +}); diff --git a/packages/core/src/skills/alwaysOnSkills.ts b/packages/core/src/skills/alwaysOnSkills.ts new file mode 100644 index 0000000000..b892cddf0f --- /dev/null +++ b/packages/core/src/skills/alwaysOnSkills.ts @@ -0,0 +1,118 @@ +import type { SkillInfo, SkillSource } from "@posthog/shared"; + +// Always-on skills are persisted as {name, source} refs, never paths: every +// task creation re-resolves them against the live skill list, so a skill that +// moved or was reinstalled keeps working and a deleted one drops out silently. +// Repo-source skills are excluded — always-on is a machine-global setting and +// must behave identically for repo and repo-less tasks. +export type AlwaysOnSkillSource = Exclude; + +export interface AlwaysOnSkillRef { + name: string; + source: AlwaysOnSkillSource; +} + +export interface ResolvedAlwaysOnSkill { + name: string; + source: AlwaysOnSkillSource; + path: string; + description: string; + skillMdBytes: number; + /** SKILL.md body (frontmatter stripped); read for local runs only. */ + body?: string; +} + +export function isAlwaysOnSkillSource( + source: string | undefined, +): source is AlwaysOnSkillSource { + return ( + source === "user" || + source === "marketplace" || + source === "codex" || + source === "bundled" + ); +} + +function refKey(ref: AlwaysOnSkillRef): string { + return `${ref.source}:${ref.name}`; +} + +function refMatchesSkill( + ref: AlwaysOnSkillRef, + skill: Pick, +): boolean { + return ref.name === skill.name && ref.source === skill.source; +} + +export function isAlwaysOnSkill( + refs: AlwaysOnSkillRef[], + skill: Pick, +): boolean { + return refs.some((ref) => refMatchesSkill(ref, skill)); +} + +export function toggleAlwaysOnSkillRef( + refs: AlwaysOnSkillRef[], + skill: Pick, + enabled: boolean, +): AlwaysOnSkillRef[] { + if (!isAlwaysOnSkillSource(skill.source)) return refs; + const without = refs.filter((ref) => !refMatchesSkill(ref, skill)); + return enabled + ? [...without, { name: skill.name, source: skill.source }] + : without; +} + +/** Drops persisted entries that are malformed or reference a non-toggleable source. */ +export function sanitizeAlwaysOnSkillRefs(value: unknown): AlwaysOnSkillRef[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const refs: AlwaysOnSkillRef[] = []; + for (const entry of value) { + const candidate = entry as { name?: unknown; source?: unknown } | null; + const name = candidate?.name; + const source = candidate?.source; + if (typeof name !== "string" || name.length === 0) continue; + if (typeof source !== "string" || !isAlwaysOnSkillSource(source)) continue; + const ref: AlwaysOnSkillRef = { name, source }; + if (seen.has(refKey(ref))) continue; + seen.add(refKey(ref)); + refs.push(ref); + } + return refs; +} + +export function resolveAlwaysOnSkills( + refs: AlwaysOnSkillRef[], + liveSkills: SkillInfo[], +): ResolvedAlwaysOnSkill[] { + const seen = new Set(); + const resolved: ResolvedAlwaysOnSkill[] = []; + for (const ref of refs) { + if (seen.has(refKey(ref))) continue; + seen.add(refKey(ref)); + const skill = liveSkills.find((candidate) => + refMatchesSkill(ref, candidate), + ); + if (!skill) continue; + resolved.push({ + name: skill.name, + source: ref.source, + path: skill.path, + description: skill.description, + skillMdBytes: skill.skillMdBytes, + }); + } + return resolved; +} + +/** Returns the same array when nothing was pruned, so callers can compare by identity. */ +export function pruneAlwaysOnSkillRefs( + refs: AlwaysOnSkillRef[], + liveSkills: SkillInfo[], +): AlwaysOnSkillRef[] { + const pruned = refs.filter((ref) => + liveSkills.some((skill) => refMatchesSkill(ref, skill)), + ); + return pruned.length === refs.length ? refs : pruned; +} diff --git a/packages/core/src/task-detail/taskCreationHost.ts b/packages/core/src/task-detail/taskCreationHost.ts index 2d29814552..d630f5a36e 100644 --- a/packages/core/src/task-detail/taskCreationHost.ts +++ b/packages/core/src/task-detail/taskCreationHost.ts @@ -1,5 +1,6 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; import type { CloudSkillBundleRef } from "@posthog/core/sessions/cloudArtifactIdentifiers"; +import type { ResolvedAlwaysOnSkill } from "@posthog/core/skills/alwaysOnSkills"; import type { Workspace, WorkspaceInfo, WorkspaceMode } from "@posthog/shared"; import type { TaskCreationApiClient } from "./taskCreationApiClient"; @@ -103,6 +104,15 @@ export interface ITaskCreationHost { * too, or a typed `/my-skill` reaches the sandbox with no bundle attached. */ resolveLocalSkillCommandPrompt(prompt: string): Promise; + /** + * Resolve the user's always-on skill toggles into live skills for injection + * into a new task's first message. `includeBodies` reads each SKILL.md for + * local runs (cloud delivers bodies via uploaded bundles). Must never throw: + * a failed resolution must not block task creation — return [] instead. + */ + resolveAlwaysOnSkills(args: { + includeBodies: boolean; + }): Promise; /** * Return-and-clear the pre-warmed sandbox lease matching the composer * selection, if one was provisioned while the user typed. The saga uploads diff --git a/packages/core/src/task-detail/taskCreationSaga.test.ts b/packages/core/src/task-detail/taskCreationSaga.test.ts index 1d3849110d..4429f70821 100644 --- a/packages/core/src/task-detail/taskCreationSaga.test.ts +++ b/packages/core/src/task-detail/taskCreationSaga.test.ts @@ -1,4 +1,5 @@ import type { SessionService } from "@posthog/core/sessions/sessionService"; +import type { ResolvedAlwaysOnSkill } from "@posthog/core/skills/alwaysOnSkills"; import type { Task, TaskRun } from "@posthog/shared/domain-types"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { @@ -21,6 +22,9 @@ const mockHost = vi.hoisted(() => ({ detectRepo: vi.fn(), getCloudPromptTransport: vi.fn(), resolveLocalSkillCommandPrompt: vi.fn(async (prompt: string) => prompt), + resolveAlwaysOnSkills: vi.fn( + async (): Promise => [], + ), takeWarmTaskLease: vi.fn( (): { taskId: string; runId: string } | null => null, ), @@ -118,6 +122,7 @@ describe("TaskCreationSaga", () => { mockHost.getWorkspace.mockResolvedValue(null); mockHost.getFolders.mockResolvedValue([]); mockHost.uploadRunAttachments.mockResolvedValue([]); + mockHost.resolveAlwaysOnSkills.mockResolvedValue([]); mockHost.linkTaskBranch.mockResolvedValue(undefined); mockHost.recordClaudeCliImport.mockResolvedValue(undefined); mockHost.deleteClaudeCliImport.mockResolvedValue(undefined); @@ -243,6 +248,97 @@ describe("TaskCreationSaga", () => { ); }); + it("uploads always-on skill bundles and folds their manifest into the cloud first message", async () => { + mockHost.resolveAlwaysOnSkills.mockResolvedValue([ + { + name: "i-have-adhd", + source: "user", + path: "/skills/i-have-adhd", + description: "Focus aid", + skillMdBytes: 120, + }, + ]); + const startedTask = createTask({ latest_run: createRun() }); + const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); + const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); + vi.mocked(sessionService.rememberInitialCloudPrompt).mockClear(); + + const saga = makeSaga({ + createTask: vi.fn().mockResolvedValue(createTask()), + createTaskRun: createTaskRunMock, + startTaskRun: startTaskRunMock, + }); + + const result = await saga.run({ + content: "Ship the fix", + repository: "posthog/posthog", + workspaceMode: "cloud", + }); + + expect(result.success).toBe(true); + expect(mockHost.resolveAlwaysOnSkills).toHaveBeenCalledWith({ + includeBodies: false, + }); + // The bundle rides the existing attachment pipeline to the real run. + expect(mockHost.uploadRunAttachments).toHaveBeenCalledWith( + expect.anything(), + "task-123", + "run-123", + [], + [{ name: "i-have-adhd", source: "user", path: "/skills/i-have-adhd" }], + ); + const sentMessage = startTaskRunMock.mock.calls[0][2] + .pendingUserMessage as string; + expect(sentMessage).toContain("Ship the fix"); + expect(sentMessage).toContain(""); + expect(sentMessage).toContain("- /i-have-adhd: Focus aid"); + expect(sessionService.rememberInitialCloudPrompt).toHaveBeenCalledWith( + "task-123", + sentMessage, + ); + }); + + it("appends always-on skill bodies to a local task's initial prompt", async () => { + mockHost.resolveAlwaysOnSkills.mockResolvedValue([ + { + name: "i-have-adhd", + source: "user", + path: "/skills/i-have-adhd", + description: "Focus aid", + skillMdBytes: 20, + body: "Stay focused.", + }, + ]); + mockHost.addFolder.mockResolvedValue({ id: "folder-1", path: "/repo" }); + mockHost.detectRepo.mockResolvedValue(null); + + const saga = makeSaga({ + createTask: vi.fn().mockResolvedValue(createTask()), + }); + + const result = await saga.run({ + content: "Ship the fix", + repoPath: "/repo", + workspaceMode: "local", + }); + + expect(result.success).toBe(true); + expect(mockHost.resolveAlwaysOnSkills).toHaveBeenCalledWith({ + includeBodies: true, + }); + const connectParams = vi.mocked(sessionService.connectToTask).mock + .calls[0][0]; + const lastBlock = connectParams.initialPrompt?.at(-1) as + | { type: string; text: string } + | undefined; + expect(lastBlock?.type).toBe("text"); + expect(lastBlock?.text).toContain(""); + expect(lastBlock?.text).toContain( + "--- BEGIN ALWAYS-ON SKILL i-have-adhd ---", + ); + expect(lastBlock?.text).toContain("Stay focused."); + }); + it("folds custom personalization into the cloud prompt and stashes it for the optimistic placeholder", async () => { const createdTask = createTask(); const startedTask = createTask({ latest_run: createRun() }); diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index da3f55a9ca..1b47e8df2f 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -1,14 +1,18 @@ import { PI_THINKING_LEVELS } from "@posthog/agent/pi/types"; import { + buildAlwaysOnSkillsBlock, + buildAlwaysOnSkillsCloudText, buildChannelContextBlock, buildChannelContextText, buildCustomInstructionsText, buildPromptBlocks, } from "@posthog/core/editor/prompt-builder"; +import { appendAlwaysOnSkillBundles } from "@posthog/core/sessions/cloudPrompt"; import type { ConnectParams, SessionService, } from "@posthog/core/sessions/sessionService"; +import type { ResolvedAlwaysOnSkill } from "@posthog/core/skills/alwaysOnSkills"; import { getTaskRepository, Saga, @@ -48,12 +52,14 @@ interface WarmActivationPayload { // The local connect path appends channel CONTEXT.md to initialPrompt and gets // the user's personalization via the workspace-server system prompt; cloud // sends its first message as text and has no client-side system-prompt seam, -// so fold both blocks into the first message here. Order: user's message, then -// personalization (user-level), then channel context (workspace-level). -// Personalization is folded only when there is message text to augment. +// so fold the blocks into the first message here. Order: user's message, then +// personalization (user-level), then channel context (workspace-level), then +// always-on skills. Personalization and always-on skills are folded only when +// there is message text to augment. function buildCloudFirstMessage( messageText: string | undefined, input: TaskCreationInput, + alwaysOnSkills: ResolvedAlwaysOnSkill[], ): { pendingUserMessage?: string; augmented: boolean } { const customInstructionsText = messageText ? buildCustomInstructionsText(input.customInstructions) @@ -63,13 +69,25 @@ function buildCloudFirstMessage( input.channelName, input.channelContextId, ); + const alwaysOnSkillsText = messageText + ? buildAlwaysOnSkillsCloudText(alwaysOnSkills) + : null; const pendingUserMessage = - [messageText, customInstructionsText, channelContextText] + [ + messageText, + customInstructionsText, + channelContextText, + alwaysOnSkillsText, + ] .filter((part): part is string => !!part) .join("\n\n") || undefined; return { pendingUserMessage, - augmented: !!(customInstructionsText || channelContextText), + augmented: !!( + customInstructionsText || + channelContextText || + alwaysOnSkillsText + ), }; } @@ -100,9 +118,21 @@ export class TaskCreationSaga extends Saga< ? undefined : await this.importClaudeSession(input); + // Union point for auto-injected skills: today the host resolves the user's + // global always-on toggles; future scoped sets (per-channel, per-repo) + // merge into this list before injection. + const alwaysOnSkills: ResolvedAlwaysOnSkill[] = + isPiRuntime || taskId || !(input.content || input.filePaths?.length) + ? [] + : await this.readOnlyStep("resolve_always_on_skills", () => + this.deps.host.resolveAlwaysOnSkills({ + includeBodies: input.workspaceMode !== "cloud", + }), + ); + const warmPayload = !isPiRuntime && !taskId && input.workspaceMode === "cloud" - ? await this.prepareWarmActivation(input) + ? await this.prepareWarmActivation(input, alwaysOnSkills) : null; let task = taskId @@ -376,9 +406,12 @@ export class TaskCreationSaga extends Saga< input.content, ) : ""; - return this.deps.host.getCloudPromptTransport( - resolvedContent, - input.filePaths, + return appendAlwaysOnSkillBundles( + this.deps.host.getCloudPromptTransport( + resolvedContent, + input.filePaths, + ), + alwaysOnSkills, ); }; const transport = warmPayload @@ -387,7 +420,11 @@ export class TaskCreationSaga extends Saga< const { pendingUserMessage, augmented } = warmPayload ? warmPayload - : buildCloudFirstMessage(transport?.messageText, input); + : buildCloudFirstMessage( + transport?.messageText, + input, + alwaysOnSkills, + ); // The sandbox echoes pendingUserMessage back once it boots; until then // the optimistic placeholder would show the bare task description with @@ -520,6 +557,11 @@ export class TaskCreationSaga extends Saga< initialPrompt.push(channelContextBlock); } + const alwaysOnSkillsBlock = buildAlwaysOnSkillsBlock(alwaysOnSkills); + if (initialPrompt && alwaysOnSkillsBlock) { + initialPrompt.push(alwaysOnSkillsBlock); + } + await this.step({ name: "agent_session", execute: async () => { @@ -708,6 +750,7 @@ export class TaskCreationSaga extends Saga< // deliver the first message without its attachments. private async prepareWarmActivation( input: TaskCreationInput, + alwaysOnSkills: ResolvedAlwaysOnSkill[], ): Promise { if (!input.content && !input.filePaths?.length) { return null; @@ -716,13 +759,14 @@ export class TaskCreationSaga extends Saga< const resolvedContent = input.content ? await this.deps.host.resolveLocalSkillCommandPrompt(input.content) : ""; - const transport = this.deps.host.getCloudPromptTransport( - resolvedContent, - input.filePaths, + const transport = appendAlwaysOnSkillBundles( + this.deps.host.getCloudPromptTransport(resolvedContent, input.filePaths), + alwaysOnSkills, ); const { pendingUserMessage, augmented } = buildCloudFirstMessage( transport.messageText, input, + alwaysOnSkills, ); const base: WarmActivationPayload = { transport, diff --git a/packages/core/src/task-detail/taskService.test.ts b/packages/core/src/task-detail/taskService.test.ts index 9b147bd059..4a8c30ab46 100644 --- a/packages/core/src/task-detail/taskService.test.ts +++ b/packages/core/src/task-detail/taskService.test.ts @@ -35,6 +35,7 @@ function makeService(): TaskService { detectRepo: vi.fn(async () => null), getFolders: vi.fn(async () => []), addFolder: vi.fn(async () => ({ id: "folder-1", path: "/repo" })), + resolveAlwaysOnSkills: vi.fn(async () => []), track: vi.fn(), } as unknown as ITaskCreationHost; const sessionService = { diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index eea1430b6f..080d421615 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -295,6 +295,13 @@ export interface SettingChangedProperties { old_value?: string | boolean | number; } +export interface SkillAlwaysOnToggledProperties { + skill_source: string; + enabled: boolean; + /** Count of always-on skills after this toggle. */ + total_always_on: number; +} + export interface CustomSoundAddedProperties { // How the clip was captured. source: "recording" | "import"; @@ -1326,6 +1333,7 @@ export const ANALYTICS_EVENTS = { // Settings events SETTING_CHANGED: "Setting changed", + SKILL_ALWAYS_ON_TOGGLED: "Skill always-on toggled", CUSTOM_SOUND_ADDED: "Custom sound added", CUSTOM_SOUND_RECORDING_SILENT: "Custom sound recording silent", @@ -1504,6 +1512,7 @@ export type EventPropertyMap = { // Settings events [ANALYTICS_EVENTS.SETTING_CHANGED]: SettingChangedProperties; + [ANALYTICS_EVENTS.SKILL_ALWAYS_ON_TOGGLED]: SkillAlwaysOnToggledProperties; [ANALYTICS_EVENTS.CUSTOM_SOUND_ADDED]: CustomSoundAddedProperties; [ANALYTICS_EVENTS.CUSTOM_SOUND_RECORDING_SILENT]: never; diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index 46ae7513f0..09f1b1d982 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -3,6 +3,7 @@ import { Check, Copy, FileText, + Lightbulb, Scroll, } from "@phosphor-icons/react"; import { WorkerPoolContextProvider } from "@pierre/diffs/react"; @@ -83,6 +84,7 @@ import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitAc import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult"; import { isUserInitiatedConversationItem } from "@posthog/ui/features/sessions/components/isUserInitiatedConversationItem"; import { mergeConversationItems } from "@posthog/ui/features/sessions/components/mergeConversationItems"; +import { extractAlwaysOnSkills } from "@posthog/ui/features/sessions/components/session-update/alwaysOnSkills"; import { extractCanvasInstructions } from "@posthog/ui/features/sessions/components/session-update/canvasInstructions"; import { extractChannelContext } from "@posthog/ui/features/sessions/components/session-update/channelContext"; import { extractCustomInstructions } from "@posthog/ui/features/sessions/components/session-update/customInstructions"; @@ -361,12 +363,20 @@ function UserBubble({ () => extractCustomInstructions(afterCanvasInstructions), [afterCanvasInstructions], ); - const displayContent = customInstructions + const afterCustomInstructions = customInstructions ? customInstructions.stripped : afterCanvasInstructions; + const alwaysOnSkills = useMemo( + () => extractAlwaysOnSkills(afterCustomInstructions), + [afterCustomInstructions], + ); + const displayContent = alwaysOnSkills + ? alwaysOnSkills.stripped + : afterCustomInstructions; const showChannelContextTag = !!channelContext && bluebirdEnabled; const showCanvasInstructionsTag = !!canvasInstructions && bluebirdEnabled; - const showHeaderChips = showChannelContextTag || showCanvasInstructionsTag; + const showHeaderChips = + showChannelContextTag || showCanvasInstructionsTag || !!alwaysOnSkills; const taskId = useSessionTaskId(); const openChannelContextInSplit = usePanelLayoutStore( (s) => s.openChannelContextInSplit, @@ -435,6 +445,12 @@ function UserBubble({ } /> )} + {alwaysOnSkills && ( + } + label={`Always-on skills (${alwaysOnSkills.mention.names.length})`} + /> + )} )} ; // buildCustomInstructionsText in @posthog/core). The description side instead // appends an `Attached files: ` summary line that the echo carries as // resource_link blocks, not text (see buildCloudTaskDescription). Dedupe and -// upgrade compare on the text with all three stripped so the echo still matches -// its placeholder. +// upgrade compare on the text with all injected blocks stripped so the echo +// still matches its placeholder. function strippedUserContent(content: string): string { const withoutChannel = extractChannelContext(content)?.stripped ?? content; const withoutInstructions = extractCustomInstructions(withoutChannel)?.stripped ?? withoutChannel; - return stripTrailingAttachmentSummary(withoutInstructions); + const withoutAlwaysOnSkills = + extractAlwaysOnSkills(withoutInstructions)?.stripped ?? withoutInstructions; + return stripTrailingAttachmentSummary(withoutAlwaysOnSkills); } // Cloud's initial optimistic is pinned to the top so the user's prompt stays diff --git a/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx b/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx index 00cf6ff966..3372fb16b1 100644 --- a/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx +++ b/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx @@ -2,6 +2,7 @@ import { Check, Copy, FileText, + Lightbulb, Scroll, SlackLogo, } from "@phosphor-icons/react"; @@ -15,6 +16,7 @@ import { useFeatureFlag } from "../../../feature-flags/useFeatureFlag"; import { usePanelLayoutStore } from "../../../panels/panelLayoutStore"; import type { UserMessageAttachment } from "../../userMessageTypes"; import { UserMessageAttachments } from "../UserMessageAttachments"; +import { extractAlwaysOnSkills } from "./alwaysOnSkills"; import { CollapsibleMessageContent } from "./CollapsibleMessageContent"; import { extractCanvasInstructions } from "./canvasInstructions"; import { extractChannelContext } from "./channelContext"; @@ -69,7 +71,8 @@ export const UserMessage = memo(function UserMessage({ // / XML never leaks for // flag-off viewers. The user's saved personalization // () is always-on background, not contextual to this - // message, so it's stripped without a tag. + // message, so it's stripped without a tag. Always-on skills + // () are stripped too, summarized as an inert chip. const bluebirdEnabled = useFeatureFlag( PROJECT_BLUEBIRD_FLAG, import.meta.env.DEV, @@ -92,9 +95,16 @@ export const UserMessage = memo(function UserMessage({ () => extractCustomInstructions(afterCanvasInstructions), [afterCanvasInstructions], ); - const displayContent = customInstructions + const afterCustomInstructions = customInstructions ? customInstructions.stripped : afterCanvasInstructions; + const alwaysOnSkills = useMemo( + () => extractAlwaysOnSkills(afterCustomInstructions), + [afterCustomInstructions], + ); + const displayContent = alwaysOnSkills + ? alwaysOnSkills.stripped + : afterCustomInstructions; const showChannelContextTag = !!channelContext && bluebirdEnabled; const showCanvasInstructionsTag = !!canvasInstructions && bluebirdEnabled; const openChannelContextInSplit = usePanelLayoutStore( @@ -137,7 +147,9 @@ export const UserMessage = memo(function UserMessage({ ) : ( )} - {(showChannelContextTag || showCanvasInstructionsTag) && ( + {(showChannelContextTag || + showCanvasInstructionsTag || + !!alwaysOnSkills) && ( )} + {alwaysOnSkills && ( + } + label={`Always-on skills (${alwaysOnSkills.mention.names.length})`} + /> + )} )} {showAttachmentChips && ( diff --git a/packages/ui/src/features/sessions/components/session-update/alwaysOnSkills.test.ts b/packages/ui/src/features/sessions/components/session-update/alwaysOnSkills.test.ts new file mode 100644 index 0000000000..a4ff6c1637 --- /dev/null +++ b/packages/ui/src/features/sessions/components/session-update/alwaysOnSkills.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { extractAlwaysOnSkills, hasAlwaysOnSkills } from "./alwaysOnSkills"; + +const PREAMBLE = + "The user has marked these skills as always-on: their instructions apply to this entire session, from your first response on, without being explicitly invoked. If a skill conflicts with the user's message, the message wins."; + +describe("extractAlwaysOnSkills", () => { + it("returns null when there is no always-on-skills element", () => { + expect(extractAlwaysOnSkills("just a normal prompt")).toBeNull(); + expect(hasAlwaysOnSkills("just a normal prompt")).toBe(false); + }); + + it("extracts referenced skill names and strips the element (cloud form)", () => { + const content = `Ship the fix\n\n\n${PREAMBLE}\n\n- /i-have-adhd: Focus aid\n- /max (preinstalled PostHog skill)\n`; + const result = extractAlwaysOnSkills(content); + expect(result?.stripped).toBe("Ship the fix"); + expect(result?.mention.names).toEqual(["i-have-adhd", "max"]); + expect(result?.mention.body).toContain("- /i-have-adhd: Focus aid"); + expect(hasAlwaysOnSkills(content)).toBe(true); + }); + + it("extracts inlined skill names (local form)", () => { + const content = `Ship the fix\n\n\n${PREAMBLE}\n\n--- BEGIN ALWAYS-ON SKILL i-have-adhd ---\nStay focused.\n--- END ALWAYS-ON SKILL i-have-adhd ---\nSkill directory: /skills/i-have-adhd\n`; + const result = extractAlwaysOnSkills(content); + expect(result?.stripped).toBe("Ship the fix"); + expect(result?.mention.names).toEqual(["i-have-adhd"]); + }); + + it("preserves user-authored always-on-skills tags", () => { + const content = + "Render this example: - /fake"; + expect(extractAlwaysOnSkills(content)).toBeNull(); + expect(hasAlwaysOnSkills(content)).toBe(false); + }); +}); diff --git a/packages/ui/src/features/sessions/components/session-update/alwaysOnSkills.ts b/packages/ui/src/features/sessions/components/session-update/alwaysOnSkills.ts new file mode 100644 index 0000000000..a228668076 --- /dev/null +++ b/packages/ui/src/features/sessions/components/session-update/alwaysOnSkills.ts @@ -0,0 +1,47 @@ +// A task's initial prompt may carry the user's always-on skills wrapped in an +// ` ... ` element (see +// buildAlwaysOnSkillsCloudText / buildAlwaysOnSkillsBlock in @posthog/core). +// The conversation UI strips the element and shows a compact chip instead of +// rendering skill manifests or bodies inline. +// +// The tag alone is not enough to identify injected metadata: users may include +// the same XML in examples they want displayed verbatim. Match the fixed +// preamble the builders emit so only system-generated blocks are hidden. +const ALWAYS_ON_SKILLS_REGEX = + /]*>(\r?\nThe user has marked these skills as always-on[\s\S]*?)<\/always_on_skills>/; + +const INLINED_SKILL_NAME_REGEX = /^--- BEGIN ALWAYS-ON SKILL (.+) ---$/gm; +const REFERENCED_SKILL_NAME_REGEX = /^- \/([^\s:]+)/gm; + +export interface AlwaysOnSkillsMention { + /** Injected skill names, inlined entries first. Best-effort, for the chip count. */ + names: string[]; + /** The exact text that was sent inside the element. */ + body: string; +} + +export function hasAlwaysOnSkills(content: string): boolean { + return ALWAYS_ON_SKILLS_REGEX.test(content); +} + +// Returns the parsed mention plus the message text with the element removed +// (so the user's own prompt renders cleanly), or null when the content has no +// always-on-skills element. +export function extractAlwaysOnSkills(content: string): { + mention: AlwaysOnSkillsMention; + stripped: string; +} | null { + const match = ALWAYS_ON_SKILLS_REGEX.exec(content); + if (match?.index === undefined) return null; + + const body = match[1].trim(); + const names = [ + ...[...body.matchAll(INLINED_SKILL_NAME_REGEX)].map((m) => m[1]), + ...[...body.matchAll(REFERENCED_SKILL_NAME_REGEX)].map((m) => m[1]), + ]; + const stripped = ( + content.slice(0, match.index) + content.slice(match.index + match[0].length) + ).trim(); + + return { mention: { names, body }, stripped }; +} diff --git a/packages/ui/src/features/settings/settingsStore.test.ts b/packages/ui/src/features/settings/settingsStore.test.ts index 917bf24fa7..68f71b032f 100644 --- a/packages/ui/src/features/settings/settingsStore.test.ts +++ b/packages/ui/src/features/settings/settingsStore.test.ts @@ -492,6 +492,52 @@ describe("getEffectiveCustomInstructions", () => { }); }); +describe("feature settingsStore always-on skills", () => { + beforeEach(async () => { + await resetPersistenceMocks(); + useSettingsStore.setState({ alwaysOnSkills: [] }); + }); + + it("persists toggles and drops invalid refs on rehydrate", async () => { + useSettingsStore + .getState() + .setSkillAlwaysOn({ name: "i-have-adhd", source: "user" }, true); + + await waitForPersistedWrite(); + + const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1]; + expect(JSON.parse(lastCall[1]).state.alwaysOnSkills).toEqual([ + { name: "i-have-adhd", source: "user" }, + ]); + + getItem.mockResolvedValue( + JSON.stringify({ + state: { + alwaysOnSkills: [ + { name: "i-have-adhd", source: "user" }, + // Repo skills are not toggleable; a stale persisted ref must drop. + { name: "repo-skill", source: "repo" }, + { name: 42, source: "user" }, + ], + }, + version: 1, + }), + ); + await useSettingsStore.persist.rehydrate(); + + expect(useSettingsStore.getState().alwaysOnSkills).toEqual([ + { name: "i-have-adhd", source: "user" }, + ]); + }); + + it("ignores toggles for repo-source skills", () => { + useSettingsStore + .getState() + .setSkillAlwaysOn({ name: "repo-skill", source: "repo" }, true); + expect(useSettingsStore.getState().alwaysOnSkills).toEqual([]); + }); +}); + describe("feature settingsStore custom instructions sync persistence", () => { beforeEach(async () => { await resetPersistenceMocks(); diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index 806000165e..615cc522bb 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -1,8 +1,14 @@ import type { UserRepositoryIntegrationRef } from "@posthog/core/integrations/repositories"; +import { + type AlwaysOnSkillRef, + sanitizeAlwaysOnSkillRefs, + toggleAlwaysOnSkillRef, +} from "@posthog/core/skills/alwaysOnSkills"; import type { Adapter, AgentRuntime, ExecutionMode, + SkillInfo, WorkspaceMode, } from "@posthog/shared"; import type { EffortLevel } from "@posthog/shared/domain-types"; @@ -247,6 +253,17 @@ interface SettingsStore { showSidebarWorktrees: boolean; setShowSidebarWorktrees: (enabled: boolean) => void; + // Skills + // Skills injected into every new task's first message. Persisted as + // {name, source} refs and re-resolved against the live list at creation + // (see @posthog/core/skills/alwaysOnSkills). + alwaysOnSkills: AlwaysOnSkillRef[]; + setSkillAlwaysOn: ( + skill: Pick, + enabled: boolean, + ) => void; + setAlwaysOnSkills: (refs: AlwaysOnSkillRef[]) => void; + // Experimental / misc hedgehogMode: boolean; slotMachineMode: boolean; @@ -482,6 +499,18 @@ export const useSettingsStore = create()( setShowSidebarWorktrees: (enabled) => set({ showSidebarWorktrees: enabled }), + // Skills + alwaysOnSkills: [], + setSkillAlwaysOn: (skill, enabled) => + set((state) => ({ + alwaysOnSkills: toggleAlwaysOnSkillRef( + state.alwaysOnSkills, + skill, + enabled, + ), + })), + setAlwaysOnSkills: (refs) => set({ alwaysOnSkills: refs }), + // Experimental / misc hedgehogMode: false, slotMachineMode: false, @@ -620,6 +649,9 @@ export const useSettingsStore = create()( // Sidebar showSidebarWorktrees: state.showSidebarWorktrees, + // Skills + alwaysOnSkills: state.alwaysOnSkills, + // Experimental / misc hedgehogMode: state.hedgehogMode, slotMachineMode: state.slotMachineMode, @@ -658,6 +690,9 @@ export const useSettingsStore = create()( ) { (merged as Record).completionSound = "none"; } + merged.alwaysOnSkills = sanitizeAlwaysOnSkillRefs( + merged.alwaysOnSkills, + ); return merged; }, }, diff --git a/packages/ui/src/features/skills/SkillCard.tsx b/packages/ui/src/features/skills/SkillCard.tsx index 5f896404a9..00bdea4e9b 100644 --- a/packages/ui/src/features/skills/SkillCard.tsx +++ b/packages/ui/src/features/skills/SkillCard.tsx @@ -11,9 +11,10 @@ import type { SkillIssue, } from "@posthog/core/skills/analyzeSkills"; import type { SkillInfo, SkillSource } from "@posthog/shared"; -import { Badge, Flex, Text, Tooltip } from "@radix-ui/themes"; +import { Badge, Flex, Switch, Text, Tooltip } from "@radix-ui/themes"; import { useEffect, useRef } from "react"; import { SkillListCard } from "./SkillListCard"; +import { useAlwaysOnSkill } from "./useAlwaysOnSkill"; export const SOURCE_CONFIG: Record< SkillSource, @@ -54,6 +55,7 @@ export function SkillCard({ }: SkillCardProps) { const config = SOURCE_CONFIG[skill.source]; const Icon = config?.icon ?? Package; + const { canToggle, enabled, setEnabled } = useAlwaysOnSkill(skill); const ref = useRef(null); useEffect(() => { @@ -92,6 +94,18 @@ export function SkillCard({ {skill.repoName} )} + {canToggle && ( + + event.stopPropagation()} + /> + + )} } /> diff --git a/packages/ui/src/features/skills/SkillDetailPanel.tsx b/packages/ui/src/features/skills/SkillDetailPanel.tsx index 0feb3da4d3..763b9cf93f 100644 --- a/packages/ui/src/features/skills/SkillDetailPanel.tsx +++ b/packages/ui/src/features/skills/SkillDetailPanel.tsx @@ -24,6 +24,7 @@ import { Dialog, Flex, ScrollArea, + Switch, Text, TextField, Tooltip, @@ -35,6 +36,7 @@ import { SkillFileEditor } from "./SkillFileEditor"; import { SkillFileTree } from "./SkillFileTree"; import { SkillManifestEditor } from "./SkillManifestEditor"; import { isSkillExistsError, skillErrorDescription } from "./skillErrors"; +import { useAlwaysOnSkill } from "./useAlwaysOnSkill"; import { useSkillContents, useSkillFile } from "./useSkillContents"; import { useDeleteSkill, @@ -84,6 +86,11 @@ export function SkillDetailPanel({ const deleteSkill = useDeleteSkill(); const publishSkill = usePublishSkill(); const importCodexSkill = useImportCodexSkill(); + const { + canToggle: canToggleAlwaysOn, + enabled: alwaysOnEnabled, + setEnabled: setAlwaysOnEnabled, + } = useAlwaysOnSkill(skill); const files = contents?.files ?? []; const isSkillMd = selectedFile === "SKILL.md"; @@ -299,6 +306,20 @@ export function SkillDetailPanel({ )} + {canToggleAlwaysOn && ( + + + Always-on + + + + )} + {issues.length > 0 && ( {issues.map((issue) => ( diff --git a/packages/ui/src/features/skills/SkillsView.tsx b/packages/ui/src/features/skills/SkillsView.tsx index 371dce5002..9ee5182733 100644 --- a/packages/ui/src/features/skills/SkillsView.tsx +++ b/packages/ui/src/features/skills/SkillsView.tsx @@ -1,4 +1,5 @@ import { Lightbulb, MagnifyingGlass, Plus } from "@phosphor-icons/react"; +import { pruneAlwaysOnSkillRefs } from "@posthog/core/skills/alwaysOnSkills"; import { analyzeSkills } from "@posthog/core/skills/analyzeSkills"; import { Tabs, TabsList, TabsTrigger } from "@posthog/quill"; import type { SkillInfo, SkillSource } from "@posthog/shared"; @@ -12,6 +13,7 @@ import { } from "@radix-ui/themes"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ResizableSidebar } from "../../primitives/ResizableSidebar"; +import { useSettingsStore } from "../settings/settingsStore"; import { MarketplaceBrowse } from "./MarketplaceBrowse"; import { NewSkillDialog } from "./NewSkillDialog"; import { SkillSection, SOURCE_CONFIG } from "./SkillCard"; @@ -92,6 +94,16 @@ export function SkillsView() { const analysis = useMemo(() => analyzeSkills(skills), [skills]); + // Drop always-on refs whose skill no longer exists on disk. Only after a + // successful non-empty list — an empty or failed read must not wipe toggles. + const alwaysOnSkills = useSettingsStore((s) => s.alwaysOnSkills); + const setAlwaysOnSkills = useSettingsStore((s) => s.setAlwaysOnSkills); + useEffect(() => { + if (isLoading || skills.length === 0) return; + const pruned = pruneAlwaysOnSkillRefs(alwaysOnSkills, skills); + if (pruned !== alwaysOnSkills) setAlwaysOnSkills(pruned); + }, [alwaysOnSkills, isLoading, setAlwaysOnSkills, skills]); + const grouped = useMemo(() => { const map = new Map(); for (const source of SOURCE_ORDER) { diff --git a/packages/ui/src/features/skills/useAlwaysOnSkill.ts b/packages/ui/src/features/skills/useAlwaysOnSkill.ts new file mode 100644 index 0000000000..f5e138c192 --- /dev/null +++ b/packages/ui/src/features/skills/useAlwaysOnSkill.ts @@ -0,0 +1,32 @@ +import { + isAlwaysOnSkill, + isAlwaysOnSkillSource, +} from "@posthog/core/skills/alwaysOnSkills"; +import type { SkillInfo } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { useCallback } from "react"; +import { track } from "../../shell/analytics"; +import { useSettingsStore } from "../settings/settingsStore"; + +/** Read + toggle a skill's always-on state (Settings-store backed). */ +export function useAlwaysOnSkill(skill: Pick) { + const alwaysOnSkills = useSettingsStore((s) => s.alwaysOnSkills); + const setSkillAlwaysOn = useSettingsStore((s) => s.setSkillAlwaysOn); + + const canToggle = isAlwaysOnSkillSource(skill.source); + const enabled = canToggle && isAlwaysOnSkill(alwaysOnSkills, skill); + + const setEnabled = useCallback( + (next: boolean) => { + setSkillAlwaysOn(skill, next); + track(ANALYTICS_EVENTS.SKILL_ALWAYS_ON_TOGGLED, { + skill_source: skill.source, + enabled: next, + total_always_on: useSettingsStore.getState().alwaysOnSkills.length, + }); + }, + [setSkillAlwaysOn, skill], + ); + + return { canToggle, enabled, setEnabled }; +} diff --git a/packages/ui/src/features/task-detail/taskCreationHostImpl.ts b/packages/ui/src/features/task-detail/taskCreationHostImpl.ts index 6862976fe6..7770ae5bc9 100644 --- a/packages/ui/src/features/task-detail/taskCreationHostImpl.ts +++ b/packages/ui/src/features/task-detail/taskCreationHostImpl.ts @@ -1,11 +1,16 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; import { CLOUD_USAGE_LIMIT_ERROR_MESSAGE } from "@posthog/api-client/posthog-client"; +import { ALWAYS_ON_SKILL_MD_MAX_BYTES } from "@posthog/core/editor/prompt-builder"; import { CLOUD_ARTIFACT_SERVICE, type CloudArtifactClient, } from "@posthog/core/sessions/cloudArtifactIdentifiers"; import type { CloudArtifactService } from "@posthog/core/sessions/cloudArtifactService"; import { getCloudPromptTransport } from "@posthog/core/sessions/cloudPrompt"; +import { + type ResolvedAlwaysOnSkill, + resolveAlwaysOnSkills, +} from "@posthog/core/skills/alwaysOnSkills"; import type { TaskCreationApiClient } from "@posthog/core/task-detail/taskCreationApiClient"; import type { CloudPromptTransport, @@ -24,17 +29,25 @@ import { HOST_TRPC_CLIENT, type HostTrpcClient, } from "@posthog/host-router/client"; -import { expandTildePath, type Workspace } from "@posthog/shared"; +import { + expandTildePath, + stripFrontmatter, + type Workspace, +} from "@posthog/shared"; import { injectable } from "inversify"; import { track } from "../../shell/analytics"; +import { logger } from "../../shell/logger"; import { getAuthenticatedClient } from "../auth/authClientImperative"; import { assertCloudUsageAvailable } from "../billing/preflightCloudUsage"; import { resolveLocalSkillPrompt } from "../message-editor/commands"; import { DEFAULT_PANEL_IDS } from "../panels/panelConstants"; import { usePanelLayoutStore } from "../panels/panelLayoutStore"; import { useProvisioningStore } from "../provisioning/store"; +import { useSettingsStore } from "../settings/settingsStore"; import { takeWarmTaskLease } from "./hooks/warmTaskLease"; +const log = logger.scope("task-creation-host"); + interface EnvironmentHostClient { environment: { get: { @@ -156,6 +169,43 @@ export class TrpcTaskCreationHost implements ITaskCreationHost { ); } + async resolveAlwaysOnSkills(args: { + includeBodies: boolean; + }): Promise { + try { + const refs = useSettingsStore.getState().alwaysOnSkills; + if (refs.length === 0) return []; + const skills = await hostClient().skills.list.query(); + const resolved = resolveAlwaysOnSkills(refs, skills); + if (!args.includeBodies) return resolved; + return await Promise.all( + resolved.map(async (skill) => { + // Bundled skill bodies stay reference-only (available to the session + // via the posthog plugin); oversized manifests are never inlined, so + // skip the read too. + if ( + skill.source === "bundled" || + skill.skillMdBytes > ALWAYS_ON_SKILL_MD_MAX_BYTES + ) { + return skill; + } + const content = await hostClient() + .skills.readFile.query({ + skillPath: skill.path, + filePath: "SKILL.md", + }) + .catch(() => null); + return content + ? { ...skill, body: stripFrontmatter(content) } + : skill; + }), + ); + } catch (error) { + log.warn("Failed to resolve always-on skills", { error }); + return []; + } + } + takeWarmTaskLease(args: { repository: string; branch?: string | null; From 185b4917346f9347ef3934ed78e2d8f396cd4441 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 31 Jul 2026 13:32:51 -0400 Subject: [PATCH 2/2] feat(skills): preview always-on skills in the composer surfaces Shows the skills that will be auto-injected into the next task as chips in the new-task page's "Using:" row (beside the channel CONTEXT.md chip) and in the channel feed composer's floating row next to the local/cloud selector. Clicking a chip opens the skill in Settings; the X turns its always-on toggle off globally. Chips render from the same resolution the saga uses at creation, so stale refs never show. Generated-By: PostHog Code Task-Id: 4e1d8b15-54b7-4555-a21f-150eead107f4 --- .../canvas/components/ChannelHomeComposer.tsx | 14 +++- .../features/skills/AlwaysOnSkillChips.tsx | 56 +++++++++++++++ .../src/features/skills/useAlwaysOnSkill.ts | 25 ++++--- .../task-detail/components/TaskInput.tsx | 69 +++++++++++-------- 4 files changed, 125 insertions(+), 39 deletions(-) create mode 100644 packages/ui/src/features/skills/AlwaysOnSkillChips.tsx diff --git a/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx b/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx index 1d11900152..a7029eb9f8 100644 --- a/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx @@ -1,3 +1,4 @@ +import { resolveAlwaysOnSkills } from "@posthog/core/skills/alwaysOnSkills"; import { isValidConfigValue } from "@posthog/core/task-detail/configOptions"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task } from "@posthog/shared/domain-types"; @@ -7,6 +8,7 @@ import { forwardRef, useCallback, useImperativeHandle, + useMemo, useRef, useState, } from "react"; @@ -25,6 +27,8 @@ import { type AgentAdapter, useSettingsStore, } from "../../settings/settingsStore"; +import { AlwaysOnSkillChips } from "../../skills/AlwaysOnSkillChips"; +import { useSkills } from "../../skills/useSkills"; import { type WorkspaceMode, WorkspaceModeSelect, @@ -140,6 +144,13 @@ export const ChannelHomeComposer = forwardRef< const cloudModeEnabled = useCloudModeEnabled(); const { hasGithubIntegration } = useUserRepositoryIntegration(); + const { data: skillsList } = useSkills(); + const alwaysOnSkillRefs = useSettingsStore((s) => s.alwaysOnSkills); + const alwaysOnSkills = useMemo( + () => resolveAlwaysOnSkills(alwaysOnSkillRefs, skillsList ?? []), + [alwaysOnSkillRefs, skillsList], + ); + // Repo-less channel tasks only run local or cloud (worktree needs a repo), so // collapse any lingering worktree preference down to local for the initial pick. const [workspaceMode, setWorkspaceModeState] = useState(() => @@ -395,7 +406,7 @@ export const ChannelHomeComposer = forwardRef< and the trigger's own fill is translucent, so it carries an opaque backdrop at the button's radius to stop messages showing through. */} {!canvasArmed && ( -
+
+
)} diff --git a/packages/ui/src/features/skills/AlwaysOnSkillChips.tsx b/packages/ui/src/features/skills/AlwaysOnSkillChips.tsx new file mode 100644 index 0000000000..3dc5d5761e --- /dev/null +++ b/packages/ui/src/features/skills/AlwaysOnSkillChips.tsx @@ -0,0 +1,56 @@ +import { Lightbulb, X } from "@phosphor-icons/react"; +import type { ResolvedAlwaysOnSkill } from "@posthog/core/skills/alwaysOnSkills"; +import { Tooltip } from "@radix-ui/themes"; +import { openSettings } from "../settings/hooks/useOpenSettings"; +import { useSkillsSelectionActions } from "./skillsSelectionStore"; +import { setSkillAlwaysOnTracked } from "./useAlwaysOnSkill"; + +interface AlwaysOnSkillChipsProps { + skills: ResolvedAlwaysOnSkill[]; +} + +// Chips for the skills that will be auto-injected into the next task +// (Settings → Skills "Always-on" toggles). Clicking a chip opens the skill in +// settings; the X turns its always-on toggle off — globally, not just for the +// task being composed. +export function AlwaysOnSkillChips({ skills }: AlwaysOnSkillChipsProps) { + const { requestSkill } = useSkillsSelectionActions(); + if (skills.length === 0) return null; + + return ( + <> + {skills.map((skill) => ( + + + + + + + + + ))} + + ); +} diff --git a/packages/ui/src/features/skills/useAlwaysOnSkill.ts b/packages/ui/src/features/skills/useAlwaysOnSkill.ts index f5e138c192..6c79fb36a8 100644 --- a/packages/ui/src/features/skills/useAlwaysOnSkill.ts +++ b/packages/ui/src/features/skills/useAlwaysOnSkill.ts @@ -8,24 +8,29 @@ import { useCallback } from "react"; import { track } from "../../shell/analytics"; import { useSettingsStore } from "../settings/settingsStore"; +/** Store write + analytics in one place, callable outside React (chips). */ +export function setSkillAlwaysOnTracked( + skill: Pick, + enabled: boolean, +): void { + useSettingsStore.getState().setSkillAlwaysOn(skill, enabled); + track(ANALYTICS_EVENTS.SKILL_ALWAYS_ON_TOGGLED, { + skill_source: skill.source, + enabled, + total_always_on: useSettingsStore.getState().alwaysOnSkills.length, + }); +} + /** Read + toggle a skill's always-on state (Settings-store backed). */ export function useAlwaysOnSkill(skill: Pick) { const alwaysOnSkills = useSettingsStore((s) => s.alwaysOnSkills); - const setSkillAlwaysOn = useSettingsStore((s) => s.setSkillAlwaysOn); const canToggle = isAlwaysOnSkillSource(skill.source); const enabled = canToggle && isAlwaysOnSkill(alwaysOnSkills, skill); const setEnabled = useCallback( - (next: boolean) => { - setSkillAlwaysOn(skill, next); - track(ANALYTICS_EVENTS.SKILL_ALWAYS_ON_TOGGLED, { - skill_source: skill.source, - enabled: next, - total_always_on: useSettingsStore.getState().alwaysOnSkills.length, - }); - }, - [setSkillAlwaysOn, skill], + (next: boolean) => setSkillAlwaysOnTracked(skill, next), + [skill], ); return { canToggle, enabled, setEnabled }; diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 2c0a9a8dc8..cbf615766f 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -9,6 +9,7 @@ import type { PiModelSelection, PiThinkingLevel, } from "@posthog/core/pi-runtime/piSessionController"; +import { resolveAlwaysOnSkills } from "@posthog/core/skills/alwaysOnSkills"; import { isValidConfigValue } from "@posthog/core/task-detail/configOptions"; import { useServiceOptional } from "@posthog/di/react"; import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; @@ -86,6 +87,7 @@ import { DEFAULT_WORKSPACE_MODE, useSettingsStore, } from "../../settings/settingsStore"; +import { AlwaysOnSkillChips } from "../../skills/AlwaysOnSkillChips"; import { useSkills } from "../../skills/useSkills"; import { useCloudModeEnabled } from "../hooks/useCloudModeEnabled"; import { @@ -225,6 +227,13 @@ export function TaskInput({ _hasHydrated: settingsHydrated, } = useSettingsStore(); const { data: skills } = useSkills(); + const alwaysOnSkillRefs = useSettingsStore((s) => s.alwaysOnSkills); + // Resolved the same way the saga will at creation, so the "Using:" row + // previews exactly what the next task carries (stale refs drop out). + const alwaysOnSkills = useMemo( + () => resolveAlwaysOnSkills(alwaysOnSkillRefs, skills ?? []), + [alwaysOnSkillRefs, skills], + ); const editorRef = useRef(null); const handleAddSelectionToPrompt = useCallback( @@ -1477,42 +1486,46 @@ export function TaskInput({
)} - {includeChannelContext && ( + {(includeChannelContext || alwaysOnSkills.length > 0) && (
Using: - - {onContextChipClick ? ( - - + + ) : ( + <> {channelName ? `#${channelName} ` : ""}CONTEXT.md + + )} + + - ) : ( - <> - - - {channelName ? `#${channelName} ` : ""}CONTEXT.md - - - )} - - - - + + )} +
)} {effectiveWorkspaceMode === "cloud" &&