pi-cursor-sdk 0.1.15 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -1
- package/README.md +20 -8
- package/docs/cursor-live-smoke-checklist.md +267 -0
- package/docs/cursor-model-ux-spec.md +15 -5
- package/docs/cursor-native-tool-replay.md +16 -5
- package/package.json +12 -5
- package/scripts/steering-rpc-smoke.mjs +238 -0
- package/scripts/tmux-live-smoke.sh +418 -0
- package/scripts/validate-smoke-jsonl.mjs +152 -0
- package/src/context.ts +180 -5
- package/src/cursor-bridge-contract.ts +27 -0
- package/src/cursor-edit-diff.ts +11 -0
- package/src/cursor-env-boolean.ts +22 -0
- package/src/cursor-live-run-accounting.ts +65 -0
- package/src/cursor-live-run-coordinator.ts +483 -0
- package/src/cursor-native-tool-display-registration.ts +93 -0
- package/src/cursor-native-tool-display-replay.ts +465 -0
- package/src/cursor-native-tool-display-state.ts +78 -0
- package/src/cursor-native-tool-display-tools.ts +102 -0
- package/src/cursor-native-tool-display.ts +10 -639
- package/src/cursor-partial-content-emitter.ts +121 -0
- package/src/cursor-pi-tool-bridge-abort.ts +133 -0
- package/src/cursor-pi-tool-bridge-diagnostics.ts +179 -0
- package/src/cursor-pi-tool-bridge-mcp.ts +118 -0
- package/src/cursor-pi-tool-bridge-run.ts +384 -0
- package/src/cursor-pi-tool-bridge-server.ts +182 -0
- package/src/cursor-pi-tool-bridge-snapshot.ts +88 -0
- package/src/cursor-pi-tool-bridge-types.ts +80 -0
- package/src/cursor-pi-tool-bridge.ts +77 -602
- package/src/cursor-provider-live-run-drain.ts +379 -0
- package/src/cursor-provider-turn-coordinator.ts +456 -0
- package/src/cursor-provider.ts +133 -1092
- package/src/cursor-question-tool.ts +7 -2
- package/src/cursor-record-utils.ts +26 -0
- package/src/cursor-sdk-output-filter.ts +100 -0
- package/src/cursor-sensitive-text.ts +37 -0
- package/src/cursor-session-agent.ts +372 -0
- package/src/cursor-session-cwd.ts +14 -19
- package/src/cursor-session-scope.ts +65 -0
- package/src/cursor-state.ts +38 -10
- package/src/cursor-tool-transcript.ts +28 -1229
- package/src/cursor-transcript-tool-formatters.ts +641 -0
- package/src/cursor-transcript-tool-specs.ts +441 -0
- package/src/cursor-transcript-utils.ts +276 -0
- package/src/cursor-usage-accounting.ts +71 -0
- package/src/index.ts +20 -3
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Validate assistant presence and usage fields in pi session JSONL files under a smoke directory.
|
|
4
|
+
*/
|
|
5
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { join, relative } from "node:path";
|
|
7
|
+
|
|
8
|
+
function printHelp() {
|
|
9
|
+
console.log(`Validate assistant presence and usage metadata in pi smoke session JSONL files.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
node scripts/validate-smoke-jsonl.mjs <smoke-dir>
|
|
13
|
+
SMOKE_DIR=/tmp/pi-cursor-smoke node scripts/validate-smoke-jsonl.mjs
|
|
14
|
+
|
|
15
|
+
Arguments:
|
|
16
|
+
smoke-dir Directory containing smoke session subdirs and JSONL files.
|
|
17
|
+
Defaults to SMOKE_DIR when the positional arg is omitted.
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
-h, --help Show this help.
|
|
21
|
+
|
|
22
|
+
Exit codes:
|
|
23
|
+
0 every scanned JSONL file has at least one assistant message and valid assistant usage metadata
|
|
24
|
+
1 invalid arguments, unreadable directory, invalid JSONL, empty/no-assistant files, or usage validation failures
|
|
25
|
+
2 no JSONL files found under the smoke directory
|
|
26
|
+
|
|
27
|
+
Enforced invariants:
|
|
28
|
+
- each scanned JSONL file contains parseable JSONL records
|
|
29
|
+
- each scanned JSONL file contains at least one persisted assistant message
|
|
30
|
+
- every persisted assistant message has usage metadata
|
|
31
|
+
- assistant usage input/output/totalTokens are non-negative numbers
|
|
32
|
+
- assistant usage cacheRead/cacheWrite are exactly 0
|
|
33
|
+
|
|
34
|
+
Notes:
|
|
35
|
+
- Prints one JSON summary line per scanned session file.
|
|
36
|
+
- Does not print session message contents or secrets.`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fail(message) {
|
|
40
|
+
console.error(`validate-smoke-jsonl: ${message}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function collectJsonlFiles(root) {
|
|
45
|
+
const files = [];
|
|
46
|
+
function walk(dir) {
|
|
47
|
+
for (const name of readdirSync(dir)) {
|
|
48
|
+
const path = join(dir, name);
|
|
49
|
+
const st = statSync(path);
|
|
50
|
+
if (st.isDirectory()) walk(path);
|
|
51
|
+
else if (path.endsWith(".jsonl")) files.push(path);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
walk(root);
|
|
55
|
+
return files.sort();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isNonNegativeNumber(value) {
|
|
59
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isBadUsage(usage) {
|
|
63
|
+
return (
|
|
64
|
+
!usage ||
|
|
65
|
+
typeof usage !== "object" ||
|
|
66
|
+
!isNonNegativeNumber(usage.input) ||
|
|
67
|
+
!isNonNegativeNumber(usage.output) ||
|
|
68
|
+
!isNonNegativeNumber(usage.totalTokens) ||
|
|
69
|
+
usage.cacheRead !== 0 ||
|
|
70
|
+
usage.cacheWrite !== 0
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseJsonlFile(file) {
|
|
75
|
+
const lines = readFileSync(file, "utf8")
|
|
76
|
+
.split(/\r?\n/)
|
|
77
|
+
.map((line) => line.trim())
|
|
78
|
+
.filter(Boolean);
|
|
79
|
+
const records = [];
|
|
80
|
+
let parseErrorCount = 0;
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
try {
|
|
83
|
+
records.push(JSON.parse(line));
|
|
84
|
+
} catch {
|
|
85
|
+
parseErrorCount += 1;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { lineCount: lines.length, records, parseErrorCount };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function main() {
|
|
92
|
+
const args = process.argv.slice(2);
|
|
93
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
94
|
+
printHelp();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (args.length > 1) {
|
|
99
|
+
fail("too many arguments; pass only the smoke directory");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const smokeDir = args[0] ?? process.env.SMOKE_DIR;
|
|
103
|
+
if (!smokeDir) {
|
|
104
|
+
fail("missing smoke directory; pass a path or set SMOKE_DIR");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let files;
|
|
108
|
+
try {
|
|
109
|
+
files = collectJsonlFiles(smokeDir);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (files.length === 0) {
|
|
115
|
+
console.error(`validate-smoke-jsonl: no JSONL files under ${smokeDir}`);
|
|
116
|
+
process.exit(2);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let failures = 0;
|
|
120
|
+
for (const file of files) {
|
|
121
|
+
let summary;
|
|
122
|
+
try {
|
|
123
|
+
const { lineCount, records, parseErrorCount } = parseJsonlFile(file);
|
|
124
|
+
const messages = records.filter((record) => record.type === "message").map((record) => record.message);
|
|
125
|
+
const assistants = messages.filter((message) => message?.role === "assistant");
|
|
126
|
+
const usage = assistants.map((message) => message.usage).filter(Boolean);
|
|
127
|
+
const badUsage = assistants.map((message) => message.usage).filter(isBadUsage);
|
|
128
|
+
const fileFailure = lineCount === 0 || parseErrorCount > 0 || assistants.length === 0 || usage.length !== assistants.length || badUsage.length > 0;
|
|
129
|
+
if (fileFailure) failures += 1;
|
|
130
|
+
summary = {
|
|
131
|
+
file: relative(smokeDir, file),
|
|
132
|
+
lineCount,
|
|
133
|
+
parseErrorCount,
|
|
134
|
+
messageCount: messages.length,
|
|
135
|
+
assistantCount: assistants.length,
|
|
136
|
+
usageCount: usage.length,
|
|
137
|
+
badUsageCount: badUsage.length,
|
|
138
|
+
};
|
|
139
|
+
} catch (error) {
|
|
140
|
+
failures += 1;
|
|
141
|
+
summary = {
|
|
142
|
+
file: relative(smokeDir, file),
|
|
143
|
+
readError: error instanceof Error ? error.message : String(error),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
console.log(JSON.stringify(summary));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
process.exit(failures === 0 ? 0 : 1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
main();
|
package/src/context.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import type { Context, Message, ToolCall } from "@earendil-works/pi-ai";
|
|
3
|
+
import { convertToLlm } from "@earendil-works/pi-coding-agent";
|
|
2
4
|
import type { SDKImage } from "@cursor/sdk";
|
|
5
|
+
import { getCursorPiBridgeContractText } from "./cursor-bridge-contract.js";
|
|
3
6
|
import { getCursorReplayPromptLabel } from "./cursor-tool-names.js";
|
|
4
7
|
|
|
5
8
|
export interface CursorPrompt {
|
|
@@ -13,9 +16,14 @@ export interface CursorPromptOptions {
|
|
|
13
16
|
imageTokenEstimate?: number;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
|
-
const
|
|
19
|
+
export const CURSOR_APPROX_CHARS_PER_TOKEN = 4;
|
|
20
|
+
export const CURSOR_IMAGE_TOKEN_ESTIMATE = 1200;
|
|
17
21
|
const SECTION_SEPARATOR = "\n\n";
|
|
18
22
|
|
|
23
|
+
function normalizePiContextMessages(messages: Context["messages"]): Message[] {
|
|
24
|
+
return convertToLlm(messages as Parameters<typeof convertToLlm>[0]);
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
function isTextBlock(block: { type: string }): block is { type: "text"; text: string } {
|
|
20
28
|
return block.type === "text";
|
|
21
29
|
}
|
|
@@ -131,7 +139,7 @@ function applyPromptBudget(
|
|
|
131
139
|
return [...sectionsBeforeMessages, ...messageSections.map((section) => section.text), ...sectionsAfterMessages];
|
|
132
140
|
}
|
|
133
141
|
|
|
134
|
-
const charsPerToken = options.charsPerToken ??
|
|
142
|
+
const charsPerToken = options.charsPerToken ?? CURSOR_APPROX_CHARS_PER_TOKEN;
|
|
135
143
|
const maxChars = Math.max(1, Math.floor(maxInputTokens * charsPerToken));
|
|
136
144
|
const requiredMessageSections = messageSections.filter((section) => section.index === latestUserMessageIndex);
|
|
137
145
|
const requiredCost = [...sectionsBeforeMessages, ...requiredMessageSections.map((section) => section.text), ...sectionsAfterMessages].reduce(
|
|
@@ -167,11 +175,177 @@ function applyPromptBudget(
|
|
|
167
175
|
return [...sectionsBeforeMessages, ...budgetNotice, ...includedMessages, ...sectionsAfterMessages];
|
|
168
176
|
}
|
|
169
177
|
|
|
178
|
+
export function estimateCursorTextTokens(text: string, options: Pick<CursorPromptOptions, "charsPerToken"> = {}): number {
|
|
179
|
+
const charsPerToken = options.charsPerToken ?? CURSOR_APPROX_CHARS_PER_TOKEN;
|
|
180
|
+
return Math.ceil(text.length / charsPerToken);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function estimateCursorPromptTokens(prompt: CursorPrompt, options: Pick<CursorPromptOptions, "charsPerToken" | "imageTokenEstimate"> = {}): number {
|
|
184
|
+
return estimateCursorTextTokens(prompt.text, options) + prompt.images.length * (options.imageTokenEstimate ?? CURSOR_IMAGE_TOKEN_ESTIMATE);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function estimateCursorPromptMessageTokens(message: Message, options: Pick<CursorPromptOptions, "charsPerToken"> = {}): number {
|
|
188
|
+
const text = formatMessage(message);
|
|
189
|
+
return text ? estimateCursorTextTokens(text, options) : 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function estimateCursorContextTokens(context: Context, options: CursorPromptOptions = {}): number {
|
|
193
|
+
return estimateCursorPromptTokens(buildCursorPrompt(context, options), options);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface CursorContextFingerprintPayload {
|
|
197
|
+
systemHash: string;
|
|
198
|
+
messageHashes: string[];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function hashCursorContextValue(value: string): string {
|
|
202
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function serializeMessageForFingerprint(message: Message, index: number): string {
|
|
206
|
+
switch (message.role) {
|
|
207
|
+
case "user": {
|
|
208
|
+
const text =
|
|
209
|
+
typeof message.content === "string"
|
|
210
|
+
? message.content
|
|
211
|
+
: JSON.stringify(message.content);
|
|
212
|
+
return hashCursorContextValue(`user:${message.timestamp ?? index}:${text}`);
|
|
213
|
+
}
|
|
214
|
+
case "assistant":
|
|
215
|
+
return hashCursorContextValue(`assistant:${message.timestamp ?? index}:${JSON.stringify(message.content)}`);
|
|
216
|
+
case "toolResult":
|
|
217
|
+
return hashCursorContextValue(
|
|
218
|
+
`toolResult:${message.timestamp ?? index}:${message.toolCallId}:${message.toolName}:${JSON.stringify(message.content)}:${message.isError === true}`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function serializeRawPiMessageForFingerprint(message: Context["messages"][number], index: number): string {
|
|
224
|
+
const role = (message as { role?: string }).role;
|
|
225
|
+
switch (role) {
|
|
226
|
+
case "branchSummary": {
|
|
227
|
+
const entry = message as { summary?: string; fromId?: string; timestamp?: number };
|
|
228
|
+
return hashCursorContextValue(
|
|
229
|
+
`branchSummary:${entry.timestamp ?? index}:${entry.fromId ?? ""}:${entry.summary ?? ""}`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
case "compactionSummary": {
|
|
233
|
+
const entry = message as { summary?: string; tokensBefore?: number; timestamp?: number };
|
|
234
|
+
return hashCursorContextValue(
|
|
235
|
+
`compactionSummary:${entry.timestamp ?? index}:${entry.tokensBefore ?? ""}:${entry.summary ?? ""}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
case "custom": {
|
|
239
|
+
const entry = message as { customType?: string; content?: unknown; timestamp?: number };
|
|
240
|
+
return hashCursorContextValue(
|
|
241
|
+
`custom:${entry.timestamp ?? index}:${entry.customType ?? ""}:${JSON.stringify(entry.content)}`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
case "bashExecution": {
|
|
245
|
+
const entry = message as {
|
|
246
|
+
command?: string;
|
|
247
|
+
output?: string;
|
|
248
|
+
exitCode?: number | null;
|
|
249
|
+
cancelled?: boolean;
|
|
250
|
+
excludeFromContext?: boolean;
|
|
251
|
+
timestamp?: number;
|
|
252
|
+
};
|
|
253
|
+
if (entry.excludeFromContext) {
|
|
254
|
+
return hashCursorContextValue(`bashExecution:excluded:${entry.timestamp ?? index}`);
|
|
255
|
+
}
|
|
256
|
+
return hashCursorContextValue(
|
|
257
|
+
`bashExecution:${entry.timestamp ?? index}:${entry.command ?? ""}:${entry.output ?? ""}:${entry.exitCode ?? ""}:${entry.cancelled === true}`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
default:
|
|
261
|
+
return serializeMessageForFingerprint(message as Message, index);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function parseCursorContextFingerprint(fingerprint: string): CursorContextFingerprintPayload | undefined {
|
|
266
|
+
try {
|
|
267
|
+
const parsed = JSON.parse(fingerprint) as CursorContextFingerprintPayload;
|
|
268
|
+
if (!parsed || typeof parsed.systemHash !== "string" || !Array.isArray(parsed.messageHashes)) return undefined;
|
|
269
|
+
if (!parsed.messageHashes.every((entry) => typeof entry === "string")) return undefined;
|
|
270
|
+
return parsed;
|
|
271
|
+
} catch {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function computeCursorContextFingerprint(context: Context): string {
|
|
277
|
+
const payload: CursorContextFingerprintPayload = {
|
|
278
|
+
systemHash: hashCursorContextValue(context.systemPrompt ?? ""),
|
|
279
|
+
messageHashes: context.messages.map((message, index) => serializeRawPiMessageForFingerprint(message, index)),
|
|
280
|
+
};
|
|
281
|
+
return JSON.stringify(payload);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function shouldBootstrapCursorSend(
|
|
285
|
+
sendState: { bootstrapped: boolean; contextFingerprint: string },
|
|
286
|
+
context: Context,
|
|
287
|
+
): boolean {
|
|
288
|
+
if (!sendState.bootstrapped) return true;
|
|
289
|
+
const previous = parseCursorContextFingerprint(sendState.contextFingerprint);
|
|
290
|
+
if (!previous) return true;
|
|
291
|
+
const current = parseCursorContextFingerprint(computeCursorContextFingerprint(context));
|
|
292
|
+
if (!current) return true;
|
|
293
|
+
if (current.systemHash !== previous.systemHash) return true;
|
|
294
|
+
if (current.messageHashes.length < previous.messageHashes.length) return true;
|
|
295
|
+
if (current.messageHashes.length > previous.messageHashes.length) {
|
|
296
|
+
for (let index = previous.messageHashes.length; index < context.messages.length; index += 1) {
|
|
297
|
+
const role = (context.messages[index] as { role?: string }).role;
|
|
298
|
+
if (role === "branchSummary" || role === "compactionSummary") return true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
for (let index = 0; index < previous.messageHashes.length; index += 1) {
|
|
302
|
+
if (current.messageHashes[index] !== previous.messageHashes[index]) return true;
|
|
303
|
+
}
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function buildCursorIncrementalPrompt(context: Context, options: CursorPromptOptions = {}): CursorPrompt {
|
|
308
|
+
// Incremental sends omit the full Cursor SDK tool boundary block; the session agent retains prior bootstrap context.
|
|
309
|
+
const messages = normalizePiContextMessages(context.messages);
|
|
310
|
+
const latestUserMessageIndex = getLatestUserMessageIndex(messages);
|
|
311
|
+
const latestUserMessage = latestUserMessageIndex >= 0 ? messages[latestUserMessageIndex] : undefined;
|
|
312
|
+
const latestUserText = latestUserMessage ? formatMessage(latestUserMessage) : undefined;
|
|
313
|
+
const sectionsBeforeMessages = [
|
|
314
|
+
"Continue the conversation using Cursor SDK capabilities only. Do not list, promise, or call pi-only tools from earlier context as if they were available.",
|
|
315
|
+
];
|
|
316
|
+
if (context.systemPrompt) {
|
|
317
|
+
sectionsBeforeMessages.push(`System instructions from pi:\n${sanitizeSystemPromptForCursor(context.systemPrompt)}`);
|
|
318
|
+
}
|
|
319
|
+
const latestUserMessageSections =
|
|
320
|
+
latestUserText && latestUserMessageIndex >= 0 ? [{ index: latestUserMessageIndex, text: latestUserText }] : [];
|
|
321
|
+
const images = extractLatestImages(messages);
|
|
322
|
+
const imageTokenReserve = images.length * (options.imageTokenEstimate ?? 0);
|
|
323
|
+
const budgetOptions =
|
|
324
|
+
options.maxInputTokens === undefined
|
|
325
|
+
? options
|
|
326
|
+
: { ...options, maxInputTokens: Math.max(1, options.maxInputTokens - imageTokenReserve) };
|
|
327
|
+
const parts = applyPromptBudget(sectionsBeforeMessages, latestUserMessageSections, [], latestUserMessageIndex, budgetOptions);
|
|
328
|
+
return { text: parts.join(SECTION_SEPARATOR), images };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function buildCursorSendPrompt(
|
|
332
|
+
context: Context,
|
|
333
|
+
options: CursorPromptOptions,
|
|
334
|
+
sendState: { bootstrapped: boolean; contextFingerprint: string },
|
|
335
|
+
): { prompt: CursorPrompt; bootstrap: boolean } {
|
|
336
|
+
const bootstrap = shouldBootstrapCursorSend(sendState, context);
|
|
337
|
+
if (bootstrap) {
|
|
338
|
+
return { prompt: buildCursorPrompt(context, options), bootstrap: true };
|
|
339
|
+
}
|
|
340
|
+
return { prompt: buildCursorIncrementalPrompt(context, options), bootstrap: false };
|
|
341
|
+
}
|
|
342
|
+
|
|
170
343
|
export function buildCursorPrompt(context: Context, options: CursorPromptOptions = {}): CursorPrompt {
|
|
171
344
|
const sectionsBeforeMessages: string[] = [
|
|
172
345
|
[
|
|
173
346
|
"Cursor SDK tool boundary:",
|
|
174
347
|
"You can call only tools actually exposed by Cursor SDK in this run. Pi tool names, replay tool names, and transcript tool names are context only, not callable capabilities.",
|
|
348
|
+
getCursorPiBridgeContractText(),
|
|
175
349
|
"If asked to list or exercise available tools, list and exercise Cursor SDK tools only; do not claim access to pi-side tools from the system prompt unless Cursor exposes an equivalent tool that runs.",
|
|
176
350
|
"Use pi__cursor_ask_question for material choices if exposed.",
|
|
177
351
|
"Web: use Cursor web/search/browser/MCP or say web search is not configured; do not claim WebSearch/WebFetch unless Cursor executes them.",
|
|
@@ -184,7 +358,8 @@ export function buildCursorPrompt(context: Context, options: CursorPromptOptions
|
|
|
184
358
|
sectionsBeforeMessages.push(`System instructions from pi:\n${sanitizeSystemPromptForCursor(context.systemPrompt)}`);
|
|
185
359
|
}
|
|
186
360
|
|
|
187
|
-
const
|
|
361
|
+
const messages = normalizePiContextMessages(context.messages);
|
|
362
|
+
const messageSections = messages
|
|
188
363
|
.map((msg, index) => {
|
|
189
364
|
const text = formatMessage(msg);
|
|
190
365
|
return text ? { index, text } : undefined;
|
|
@@ -196,7 +371,7 @@ export function buildCursorPrompt(context: Context, options: CursorPromptOptions
|
|
|
196
371
|
"If web research is requested, do not claim it unless a Cursor web/search/browser/MCP tool ran.",
|
|
197
372
|
].join("\n"),
|
|
198
373
|
];
|
|
199
|
-
const images = extractLatestImages(
|
|
374
|
+
const images = extractLatestImages(messages);
|
|
200
375
|
const imageTokenReserve = images.length * (options.imageTokenEstimate ?? 0);
|
|
201
376
|
const budgetOptions =
|
|
202
377
|
options.maxInputTokens === undefined
|
|
@@ -206,7 +381,7 @@ export function buildCursorPrompt(context: Context, options: CursorPromptOptions
|
|
|
206
381
|
sectionsBeforeMessages,
|
|
207
382
|
messageSections,
|
|
208
383
|
sectionsAfterMessages,
|
|
209
|
-
getLatestUserMessageIndex(
|
|
384
|
+
getLatestUserMessageIndex(messages),
|
|
210
385
|
budgetOptions,
|
|
211
386
|
);
|
|
212
387
|
const text = parts.join(SECTION_SEPARATOR);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const CURSOR_PI_BRIDGE_MCP_TOOL_PREFIX = "pi__";
|
|
2
|
+
|
|
3
|
+
const CURSOR_PI_BRIDGE_CONTRACT_LINES = [
|
|
4
|
+
"Pi bridge contract:",
|
|
5
|
+
`${CURSOR_PI_BRIDGE_MCP_TOOL_PREFIX}* names are live Cursor MCP bridge tool names only when exposed in the current run.`,
|
|
6
|
+
`Call the ${CURSOR_PI_BRIDGE_MCP_TOOL_PREFIX}* MCP tool name, not the real pi tool name shown in pi history or transcripts.`,
|
|
7
|
+
"Bridged calls execute through normal pi tool flow, so pi shows the real pi tool name and returns a normal pi tool result.",
|
|
8
|
+
"Replay IDs, replay labels, and transcript tool names are display-only/context-only, not callable tools.",
|
|
9
|
+
"Cursor-native host tools, settings, plugins, and configured MCP servers are separate from the pi bridge.",
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
export function getCursorPiBridgeContractText(): string {
|
|
13
|
+
return CURSOR_PI_BRIDGE_CONTRACT_LINES.join("\n");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildCursorPiBridgeMcpToolDescription(options: {
|
|
17
|
+
piToolName: string;
|
|
18
|
+
mcpToolName: string;
|
|
19
|
+
piToolDescription: string;
|
|
20
|
+
}): string {
|
|
21
|
+
return [
|
|
22
|
+
options.piToolDescription,
|
|
23
|
+
"",
|
|
24
|
+
getCursorPiBridgeContractText(),
|
|
25
|
+
`This run exposes real pi tool ${options.piToolName} as Cursor MCP tool ${options.mcpToolName}.`,
|
|
26
|
+
].join("\n");
|
|
27
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const CURSOR_EDIT_DIFF_FIELD_ORDER = ["diffString", "diff", "unifiedDiff", "patch"] as const;
|
|
2
|
+
|
|
3
|
+
export function resolveCursorEditDiff(source: unknown): string | undefined {
|
|
4
|
+
if (!source || typeof source !== "object") return undefined;
|
|
5
|
+
const record = source as Record<string, unknown>;
|
|
6
|
+
for (const key of CURSOR_EDIT_DIFF_FIELD_ORDER) {
|
|
7
|
+
const value = record[key];
|
|
8
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
9
|
+
}
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "none", "no", "disabled"]);
|
|
2
|
+
const ENABLED_ENV_VALUES = new Set(["1", "true", "on", "yes", "enabled"]);
|
|
3
|
+
|
|
4
|
+
function normalizeEnvBoolean(raw: string | undefined): string | undefined {
|
|
5
|
+
const normalized = raw?.trim().toLowerCase();
|
|
6
|
+
return normalized || undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function parseOptionalEnvBoolean(raw: string | undefined): boolean | undefined {
|
|
10
|
+
const normalized = normalizeEnvBoolean(raw);
|
|
11
|
+
if (!normalized) return undefined;
|
|
12
|
+
if (DISABLED_ENV_VALUES.has(normalized)) return false;
|
|
13
|
+
if (ENABLED_ENV_VALUES.has(normalized)) return true;
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseEnvBoolean(
|
|
18
|
+
raw: string | undefined,
|
|
19
|
+
defaultValue: boolean,
|
|
20
|
+
): boolean {
|
|
21
|
+
return parseOptionalEnvBoolean(raw) ?? defaultValue;
|
|
22
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Context, Message, ToolResultMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
import { CURSOR_APPROX_CHARS_PER_TOKEN, estimateCursorPromptMessageTokens } from "./context.js";
|
|
3
|
+
|
|
4
|
+
export interface CursorLiveRunAccountingState {
|
|
5
|
+
promptInputTokens: number;
|
|
6
|
+
promptInputTokensReported: boolean;
|
|
7
|
+
consumedToolResultIds: ReadonlySet<string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CursorLiveToolResultConsumption {
|
|
11
|
+
state: CursorLiveRunAccountingState;
|
|
12
|
+
toolResults: ToolResultMessage[];
|
|
13
|
+
toolResultInputTokens: number;
|
|
14
|
+
toolCallIds: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createCursorLiveRunAccountingState(promptInputTokens: number): CursorLiveRunAccountingState {
|
|
18
|
+
return {
|
|
19
|
+
promptInputTokens,
|
|
20
|
+
promptInputTokensReported: false,
|
|
21
|
+
consumedToolResultIds: new Set(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function asToolResultMessage(message: Message): ToolResultMessage | undefined {
|
|
26
|
+
return message.role === "toolResult" ? message : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function consumeCursorLiveToolResults(
|
|
30
|
+
state: CursorLiveRunAccountingState,
|
|
31
|
+
context: Context,
|
|
32
|
+
isMatchingToolResult: (toolResult: ToolResultMessage) => boolean,
|
|
33
|
+
): CursorLiveToolResultConsumption {
|
|
34
|
+
const consumedToolResultIds = new Set(state.consumedToolResultIds);
|
|
35
|
+
const toolResults: ToolResultMessage[] = [];
|
|
36
|
+
let toolResultInputTokens = 0;
|
|
37
|
+
|
|
38
|
+
for (const message of context.messages) {
|
|
39
|
+
const toolResult = asToolResultMessage(message);
|
|
40
|
+
if (!toolResult) continue;
|
|
41
|
+
if (consumedToolResultIds.has(toolResult.toolCallId)) continue;
|
|
42
|
+
if (!isMatchingToolResult(toolResult)) continue;
|
|
43
|
+
consumedToolResultIds.add(toolResult.toolCallId);
|
|
44
|
+
toolResults.push(toolResult);
|
|
45
|
+
toolResultInputTokens += estimateCursorPromptMessageTokens(toolResult, { charsPerToken: CURSOR_APPROX_CHARS_PER_TOKEN });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
state: { ...state, consumedToolResultIds },
|
|
50
|
+
toolResults,
|
|
51
|
+
toolResultInputTokens,
|
|
52
|
+
toolCallIds: toolResults.map((toolResult) => toolResult.toolCallId),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function takeCursorLiveTurnInputTokens(
|
|
57
|
+
state: CursorLiveRunAccountingState,
|
|
58
|
+
toolResultInputTokens: number,
|
|
59
|
+
): { state: CursorLiveRunAccountingState; sessionInputTokens: number } {
|
|
60
|
+
const promptInputTokens = state.promptInputTokensReported ? 0 : state.promptInputTokens;
|
|
61
|
+
return {
|
|
62
|
+
state: state.promptInputTokensReported ? state : { ...state, promptInputTokensReported: true },
|
|
63
|
+
sessionInputTokens: promptInputTokens + toolResultInputTokens,
|
|
64
|
+
};
|
|
65
|
+
}
|