Skip to content
Open
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
11 changes: 10 additions & 1 deletion packages/core/src/editor/cloud-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
serializeCloudPrompt,
unescapeXmlAttr,
} from "@posthog/shared";
import { skillTagsToSlashCommands } from "../message-editor/skillTags";

export type ReadFileAsBase64 = (filePath: string) => Promise<string | null>;

Expand Down Expand Up @@ -111,7 +112,11 @@ function normalizePromptText(prompt: string): string {
return prompt.replace(/\n{3,}/g, "\n\n").trim();
}

export function stripAbsoluteFileTags(prompt: string): string {
export function stripSkillTags(prompt: string): string {
return skillTagsToSlashCommands(prompt);
}

export function stripAttachmentTags(prompt: string): string {
return normalizePromptText(
prompt
.replaceAll(ABSOLUTE_FILE_TAG_REGEX, (match, rawPath: string) => {
Expand All @@ -122,6 +127,10 @@ export function stripAbsoluteFileTags(prompt: string): string {
);
}

export function stripAbsoluteFileTags(prompt: string): string {
return stripSkillTags(stripAttachmentTags(prompt));
}
Comment thread
tatoalo marked this conversation as resolved.

export function getAbsoluteAttachmentPaths(
prompt: string,
filePaths: string[] = [],
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/message-editor/content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<skill name="local-skill" source="user" path="/Users/alessandro/.claude/skills/local-skill" />',
);
expect(xmlToContent(contentToXml(content)).segments).toEqual(
content.segments,
);
});

it("extractFilePaths includes folder chips alongside file chips", () => {
const content: EditorContent = {
segments: [
Expand Down
38 changes: 26 additions & 12 deletions packages/core/src/message-editor/content.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { escapeXmlAttr, unescapeXmlAttr } from "@posthog/shared";
import { escapeXmlAttr, type UploadableSkillSource } from "@posthog/shared";
import { isUploadableSkillSource, parseXmlAttrs } from "./skillTags";

export interface MentionChip {
type:
Expand All @@ -15,6 +16,9 @@ export interface MentionChip {
label: string;
pastedText?: boolean;
chipId?: string;
skillPath?: string;
skillSource?: UploadableSkillSource;
skillName?: string;
}

export interface FileAttachment {
Expand Down Expand Up @@ -60,6 +64,9 @@ export function contentToXml(content: EditorContent): string {
inlineFilePaths.add(chip.id);
return `<folder path="${escapedId}" />`;
case "command":
if (chip.skillPath && chip.skillSource) {
return `<skill name="${escapeXmlAttr(chip.skillName ?? chip.label)}" source="${escapeXmlAttr(chip.skillSource)}" path="${escapeXmlAttr(chip.skillPath)}" />`;
}
if (chip.id && chip.id !== chip.label && isAbsolutePathLike(chip.id)) {
return `<folder path="${escapedId}" />`;
}
Expand Down Expand Up @@ -97,8 +104,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;
const ATTR_REGEX = /(\w+)="([^"]*)"/g;
/<(file|folder|skill|error|experiment|insight|feature_flag|github_issue|github_pr)\b([^>]*?)\s*\/>/g;

export function deriveFileLabel(filePath: string): string {
const segments = filePath.split("/").filter(Boolean);
Expand All @@ -107,16 +113,8 @@ export function deriveFileLabel(filePath: string): string {
return parentDir ? `${parentDir}/${fileName}` : fileName;
}

function parseAttrs(raw: string): Record<string, string> {
const attrs: Record<string, string> = {};
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;
Expand All @@ -128,6 +126,22 @@ 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 || !isUploadableSkillSource(source)) {
return null;
}
return {
type: "command",
id: path,
label: name,
skillPath: path,
skillSource: source,
skillName: name,
};
}
case "error":
case "experiment":
case "insight":
Expand Down
63 changes: 63 additions & 0 deletions packages/core/src/message-editor/skillTags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { type UploadableSkillSource, unescapeXmlAttr } from "@posthog/shared";

const SKILL_TAG_REGEX = /<skill\b([^>]*?)\s*\/>/g;
const XML_ATTR_REGEX = /(\w+)="([^"]*)"/g;

export interface UploadableSkillTag {
name: string;
source: UploadableSkillSource;
path: string;
}

export function parseXmlAttrs(raw: string): Record<string, string> {
const attrs: Record<string, string> = {};
for (const match of raw.matchAll(XML_ATTR_REGEX)) {
attrs[match[1]] = unescapeXmlAttr(match[2]);
}
return attrs;
}

export 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>) => 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;
}
15 changes: 13 additions & 2 deletions packages/core/src/message-editor/suggestions.ts
Original file line number Diff line number Diff line change
@@ -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<SkillSource, "bundled">;
path: string;
};
}

export interface FileItemLike {
Expand All @@ -27,6 +32,9 @@ export interface CommandSuggestionShape<T extends CommandLike> {
id: string;
label: string;
description?: string;
skillPath?: string;
skillSource?: Exclude<SkillSource, "bundled">;
skillName?: string;
command: T;
}

Expand Down Expand Up @@ -75,9 +83,12 @@ export function shapeCommandSuggestions<T extends CommandLike>(
commands: T[],
): CommandSuggestionShape<T>[] {
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,
}));
}
Expand Down
36 changes: 31 additions & 5 deletions packages/core/src/sessions/cloudPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@ import {
buildCloudTaskDescription,
getAbsoluteAttachmentPaths,
stripAbsoluteFileTags,
stripAttachmentTags,
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 { getFileName, pathToFileUri } from "@posthog/shared";
import type { CloudSkillBundleRef } from "./cloudArtifactIdentifiers";

const FILE_URI_PREFIX = "file://";

export interface CloudPromptTransport {
filePaths: string[];
skillBundles: CloudSkillBundleRef[];
messageText?: string;
promptText: string;
}
Expand Down Expand Up @@ -61,6 +66,22 @@ function collectBlockAttachmentPaths(prompt: ContentBlock[]): string[] {
return Array.from(new Set(filePaths));
}

function collectSkillBundleRefs(prompt: string): CloudSkillBundleRef[] {
const refs: CloudSkillBundleRef[] = [];
const seen = new Set<string>();

for (const tag of collectUploadableSkillTags(prompt)) {
const key = `${tag.source}:${tag.path}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
refs.push(tag);
}

return refs;
}

function summarizePrompt(text: string, filePaths: string[]): string {
if (filePaths.length === 0) {
return text.trim();
Expand All @@ -78,27 +99,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<ContentBlock, { type: "text" }> =>
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),
};
Expand All @@ -111,9 +136,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) {
Expand Down
15 changes: 12 additions & 3 deletions packages/core/src/sessions/sessionEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ 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";

Expand Down Expand Up @@ -208,8 +209,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);
}

/**
Expand All @@ -218,7 +219,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";
Expand Down
Loading
Loading