Skip to content

Commit 0f57f8a

Browse files
authored
feat(pi-cursor-agent): Inject pi context as cursor rules (#12)
1 parent 950a492 commit 0f57f8a

11 files changed

Lines changed: 442 additions & 5 deletions

File tree

pi-cursor-agent/package-lock.json

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pi-cursor-agent/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
3737
"dependencies": {
3838
"@bufbuild/protobuf": "1.10.0",
3939
"@connectrpc/connect": "^1.7.0",
40-
"@connectrpc/connect-node": "^1.7.0"
40+
"@connectrpc/connect-node": "^1.7.0",
41+
"yaml": "^2.8.0"
4142
},
4243
"devDependencies": {
4344
"@biomejs/biome": "^2.4.2",

pi-cursor-agent/src/bridge/cursor-to-pi/executors/request-context.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os from "node:os";
2+
import type { CursorRule } from "../../../__generated__/agent/v1/cursor_rules_pb";
23
import type { McpToolDefinition } from "../../../__generated__/agent/v1/mcp_pb";
34
import { GitRepoInfo } from "../../../__generated__/agent/v1/repo_pb";
45
import type { RequestContextArgs } from "../../../__generated__/agent/v1/request_context_exec_pb";
@@ -23,11 +24,17 @@ export class LocalRequestContextExecutor
2324
{
2425
private readonly tools: McpToolDefinition[];
2526
private readonly workspacePaths: string[];
27+
private readonly rules: CursorRule[];
2628
private readonly gitExecutor: LocalGitExecutor;
2729

28-
constructor(tools: McpToolDefinition[], workspacePaths: string[]) {
30+
constructor(
31+
tools: McpToolDefinition[],
32+
workspacePaths: string[],
33+
rules: CursorRule[] = [],
34+
) {
2935
this.tools = tools;
3036
this.workspacePaths = workspacePaths;
37+
this.rules = rules;
3138
this.gitExecutor = new LocalGitExecutor();
3239
}
3340

@@ -42,7 +49,7 @@ export class LocalRequestContextExecutor
4249
]);
4350

4451
const requestContext = new RequestContext({
45-
rules: [],
52+
rules: this.rules,
4653
env,
4754
repositoryInfo: [],
4855
tools: this.tools,

pi-cursor-agent/src/bridge/cursor-to-pi/local-resource-provider/provider.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { CursorRule } from "../../../__generated__/agent/v1/cursor_rules_pb";
12
import type { McpToolDefinition } from "../../../__generated__/agent/v1/mcp_pb";
23
import {
34
backgroundShellResource,
@@ -46,6 +47,7 @@ interface LocalResourceProviderOptions {
4647
ctx: PiToolContext;
4748
requestContextTools?: McpToolDefinition[];
4849
workspacePaths?: string[];
50+
cursorRules?: CursorRule[];
4951
}
5052

5153
export class LocalResourceProvider extends RegistryResourceAccessor {
@@ -63,6 +65,7 @@ export class LocalResourceProvider extends RegistryResourceAccessor {
6365
new LocalRequestContextExecutor(
6466
requestContextTools,
6567
resolvedWorkspacePaths,
68+
options.cursorRules ?? [],
6669
),
6770
);
6871

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { CursorRule } from "../../__generated__/agent/v1/cursor_rules_pb";
2+
import { parsePiSystemPrompt } from "./parser";
3+
import { buildCursorRules } from "./rules-builder";
4+
5+
export interface PreparedPiContext {
6+
rules: CursorRule[];
7+
cleanedPrompt: string;
8+
}
9+
10+
export async function preparePiContext(
11+
systemPrompt: string,
12+
): Promise<PreparedPiContext> {
13+
const parsed = parsePiSystemPrompt(systemPrompt);
14+
const rules = await buildCursorRules(parsed);
15+
return { rules, cleanedPrompt: parsed.cleanedPrompt };
16+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/** Parse Pi system prompt into structured components for Cursor's RequestContext.rules. */
2+
3+
export interface PiContextFile {
4+
path: string;
5+
content: string;
6+
}
7+
8+
export interface PiSkillRef {
9+
name: string;
10+
description: string;
11+
location: string;
12+
}
13+
14+
export interface ParsedPiContext {
15+
contextFiles: PiContextFile[];
16+
skills: PiSkillRef[];
17+
cleanedPrompt: string;
18+
}
19+
20+
// Stable structural markers only — no description text that may change across Pi versions.
21+
const CONTEXT_HEADING = "# Project Context";
22+
const SKILLS_OPEN = "<available_skills>";
23+
const SKILLS_CLOSE = "</available_skills>";
24+
25+
const SKILL_RE =
26+
/<skill>\s*<name>([\s\S]*?)<\/name>\s*<description>([\s\S]*?)<\/description>\s*<location>([\s\S]*?)<\/location>\s*<\/skill>/g;
27+
28+
function unescapeXml(s: string): string {
29+
return s
30+
.replace(/&amp;/g, "&")
31+
.replace(/&lt;/g, "<")
32+
.replace(/&gt;/g, ">")
33+
.replace(/&quot;/g, '"')
34+
.replace(/&apos;/g, "'");
35+
}
36+
37+
function extractContextFiles(prompt: string): PiContextFile[] {
38+
const start = prompt.indexOf(CONTEXT_HEADING);
39+
if (start === -1) return [];
40+
41+
let end = prompt.length;
42+
for (const marker of [SKILLS_OPEN, "\nCurrent date: "]) {
43+
const idx = prompt.indexOf(marker, start);
44+
if (idx !== -1 && idx < end) end = idx;
45+
}
46+
47+
return prompt
48+
.slice(start, end)
49+
.split(/^(?=## \/)/m)
50+
.slice(1)
51+
.flatMap((block) => {
52+
const nl = block.indexOf("\n");
53+
if (nl === -1) return [];
54+
const path = block.slice(3, nl).trim();
55+
const content = block.slice(nl + 1).trim();
56+
return path && content ? [{ path, content }] : [];
57+
});
58+
}
59+
60+
function extractSkills(prompt: string): PiSkillRef[] {
61+
const openIdx = prompt.indexOf(SKILLS_OPEN);
62+
if (openIdx === -1) return [];
63+
64+
const closeIdx = prompt.indexOf(SKILLS_CLOSE, openIdx);
65+
if (closeIdx === -1) return [];
66+
67+
const xml = prompt.slice(openIdx, closeIdx + SKILLS_CLOSE.length);
68+
const skills: PiSkillRef[] = [];
69+
70+
for (const m of xml.matchAll(SKILL_RE)) {
71+
if (!m[1] || !m[2] || !m[3]) continue;
72+
const name = unescapeXml(m[1].trim());
73+
const description = unescapeXml(m[2].trim());
74+
const location = unescapeXml(m[3].trim());
75+
if (name && description && location) {
76+
skills.push({ name, description, location });
77+
}
78+
}
79+
80+
return skills;
81+
}
82+
83+
const PRESERVED_PATTERNS = [
84+
/^Pi documentation[^\n]*(?:\n- [^\n]*)*/m,
85+
/^Current date: .+$/m,
86+
/^Current working directory: .+$/m,
87+
];
88+
89+
function buildCleanedPrompt(original: string, hasExtracted: boolean): string {
90+
const lines = PRESERVED_PATTERNS.map((re) => original.match(re)?.[0]).filter(
91+
(s): s is string => s != null,
92+
);
93+
94+
return lines.length === 0 && !hasExtracted ? original : lines.join("\n");
95+
}
96+
97+
export function parsePiSystemPrompt(systemPrompt: string): ParsedPiContext {
98+
if (!systemPrompt) {
99+
return { contextFiles: [], skills: [], cleanedPrompt: "" };
100+
}
101+
102+
try {
103+
const contextFiles = extractContextFiles(systemPrompt);
104+
const skills = extractSkills(systemPrompt);
105+
const hasExtracted = contextFiles.length > 0 || skills.length > 0;
106+
107+
return {
108+
contextFiles,
109+
skills,
110+
cleanedPrompt: buildCleanedPrompt(systemPrompt, hasExtracted),
111+
};
112+
} catch {
113+
return { contextFiles: [], skills: [], cleanedPrompt: systemPrompt };
114+
}
115+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/** Convert parsed Pi context into CursorRule[] for Cursor's RequestContext.rules. */
2+
3+
import { readFile } from "node:fs/promises";
4+
import { parse as parseYaml } from "yaml";
5+
import {
6+
CursorRule,
7+
CursorRuleType,
8+
CursorRuleTypeAgentFetched,
9+
CursorRuleTypeGlobal,
10+
} from "../../__generated__/agent/v1/cursor_rules_pb";
11+
import type { ParsedPiContext, PiSkillRef } from "./parser";
12+
13+
function parseFrontmatter(content: string): {
14+
frontmatter: Record<string, unknown>;
15+
body: string;
16+
} {
17+
const s = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
18+
if (!s.startsWith("---")) return { frontmatter: {}, body: s };
19+
20+
const end = s.indexOf("\n---", 3);
21+
if (end === -1) return { frontmatter: {}, body: s };
22+
23+
const parsed = parseYaml(s.slice(4, end));
24+
return {
25+
frontmatter: (parsed ?? {}) as Record<string, unknown>,
26+
body: s.slice(end + 4).trim(),
27+
};
28+
}
29+
30+
function globalRule(path: string, content: string): CursorRule {
31+
return new CursorRule({
32+
fullPath: path,
33+
content,
34+
type: new CursorRuleType({
35+
type: { case: "global", value: new CursorRuleTypeGlobal() },
36+
}),
37+
});
38+
}
39+
40+
function agentFetchedRuleType(description: string): CursorRuleType {
41+
return new CursorRuleType({
42+
type: {
43+
case: "agentFetched",
44+
value: new CursorRuleTypeAgentFetched({ description }),
45+
},
46+
});
47+
}
48+
49+
async function agentFetchedRule(skill: PiSkillRef): Promise<CursorRule> {
50+
try {
51+
const raw = await readFile(skill.location, "utf-8");
52+
const { body } = parseFrontmatter(raw);
53+
return new CursorRule({
54+
fullPath: skill.location,
55+
content: body || raw,
56+
type: agentFetchedRuleType(skill.description),
57+
});
58+
} catch {
59+
return new CursorRule({
60+
fullPath: skill.location,
61+
content: skill.description,
62+
type: agentFetchedRuleType(skill.description),
63+
});
64+
}
65+
}
66+
67+
export async function buildCursorRules(
68+
parsed: ParsedPiContext,
69+
): Promise<CursorRule[]> {
70+
const globals = parsed.contextFiles.map((f) => globalRule(f.path, f.content));
71+
const skills = await Promise.all(parsed.skills.map(agentFetchedRule));
72+
return [...globals, ...skills];
73+
}

pi-cursor-agent/src/bridge/pi-to-cursor/request-builder.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ interface BuildRunRequestParams {
234234
conversationState: ConversationStateStructure | undefined;
235235
mcpToolDefinitions?: McpToolDefinition[];
236236
state?: CursorStateStore;
237+
systemPromptOverride?: string;
237238
}
238239

239240
interface BuildRunRequestResult {
@@ -244,9 +245,14 @@ interface BuildRunRequestResult {
244245
export function buildRunRequest(
245246
params: BuildRunRequestParams,
246247
): BuildRunRequestResult {
248+
const content =
249+
params.systemPromptOverride ??
250+
params.context.systemPrompt ??
251+
"You are a helpful assistant.";
252+
247253
const systemPromptJson = JSON.stringify({
248254
role: "system",
249-
content: params.context.systemPrompt || "You are a helpful assistant.",
255+
content: content,
250256
});
251257
const systemPromptBytes = new TextEncoder().encode(systemPromptJson);
252258
const systemPromptId = getBlobId(systemPromptBytes);

pi-cursor-agent/src/provider/stream.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
rejectPendingForSession,
2929
type ToolExecRequest,
3030
} from "../bridge/cursor-to-pi/tool-bridge";
31+
import { preparePiContext } from "../bridge/pi-context";
3132
import {
3233
buildRunRequest,
3334
getContextTools,
@@ -396,9 +397,12 @@ export function streamCursorAgent(
396397
getChannel: () => channel,
397398
};
398399

400+
const piContext = await preparePiContext(context.systemPrompt ?? "");
401+
399402
const resources = new LocalResourceProvider({
400403
ctx: piToolCtx,
401404
requestContextTools,
405+
cursorRules: piContext.rules,
402406
});
403407

404408
const blobStore = agentStore.getBlobStore();
@@ -412,6 +416,7 @@ export function streamCursorAgent(
412416
conversationState: agentStore.getConversationStateStructure(),
413417
mcpToolDefinitions: requestContextTools,
414418
state: overlayState,
419+
systemPromptOverride: piContext.cleanedPrompt,
415420
});
416421
agentStore.conversationStateStructure = conversationState;
417422

0 commit comments

Comments
 (0)