Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions packages/core/src/editor/prompt-builder.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -67,6 +71,103 @@ describe("buildCustomInstructionsText", () => {
});
});

const alwaysOnSkill = (
overrides: Partial<ResolvedAlwaysOnSkill> = {},
): 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("<always_on_skills>\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</always_on_skills>")).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)",
Expand Down
70 changes: 70 additions & 0 deletions packages/core/src/editor/prompt-builder.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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 `<always_on_skills>\n${ALWAYS_ON_SKILLS_PREAMBLE}\n\n${lines.join("\n")}\n</always_on_skills>`;
}

// 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: `<always_on_skills>\n${sections.join("\n")}\n</always_on_skills>`,
};
}
35 changes: 34 additions & 1 deletion packages/core/src/sessions/cloudPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down
120 changes: 120 additions & 0 deletions packages/core/src/skills/alwaysOnSkills.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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" },
]);
});
});
Loading