pi-cursor-sdk 0.3.6 → 0.3.7
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 +11 -0
- package/README.md +2 -2
- package/dist/context.js +19 -4
- package/dist/cursor-agents-context.js +22 -8
- package/dist/cursor-context-tools.js +3 -1
- package/dist/cursor-live-run-coordinator.js +9 -6
- package/dist/cursor-partial-content-emitter.js +12 -0
- package/dist/cursor-pi-context.js +30 -0
- package/dist/cursor-provider-live-run-drain.js +14 -6
- package/dist/cursor-provider-turn-coordinator.js +18 -0
- package/dist/cursor-provider-turn-emit.js +6 -0
- package/dist/cursor-provider-turn-prepare.js +10 -5
- package/dist/cursor-skill-tool.js +1 -1
- package/docs/cursor-model-ux-spec.md +2 -0
- package/docs/cursor-testing-lessons.md +7 -1
- package/package.json +1 -1
- package/src/context.ts +27 -4
- package/src/cursor-agents-context.ts +24 -8
- package/src/cursor-context-tools.ts +3 -1
- package/src/cursor-live-run-coordinator.ts +10 -6
- package/src/cursor-partial-content-emitter.ts +13 -0
- package/src/cursor-pi-context.ts +44 -0
- package/src/cursor-provider-live-run-drain.ts +13 -9
- package/src/cursor-provider-turn-coordinator.ts +15 -0
- package/src/cursor-provider-turn-emit.ts +6 -0
- package/src/cursor-provider-turn-prepare.ts +10 -5
- package/src/cursor-skill-tool.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.7 - 2026-09-16
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Preserve completed Cursor SDK assistant-message boundaries in streamed and persisted output: separate progress messages from the next message without splitting token chunks, duplicating the SDK final result, or adding whitespace to an exact final answer. Carry boundaries through local native-replay drain as well as direct/cloud turns.
|
|
8
|
+
|
|
9
|
+
- Support transcript-only Pi provider requests via the host's public replay helpers, without requiring those exports on stock Pi 0.84.0 or 0.85.1. Preserve current instructions, section updates, and explicit empty replay-tool snapshots.
|
|
10
|
+
- Fingerprint replayed instructions and tool definitions plus individual system-message content/section/tool deltas, so unchanged transcripts stay incremental and changed context reboots correctly.
|
|
11
|
+
- Preserve current Pi instructions in fresh cloud prompts, and ignore system updates when finding pending replay/bridge tool results and steering input.
|
|
12
|
+
- Handle Pi's XML-wrapped tool/rule/skill sections and project-context serialization without duplicating local rules or skill catalogs. Cloud keeps project instructions and omits the local skill catalog.
|
|
13
|
+
|
|
3
14
|
## 0.3.6 - 2026-08-18
|
|
4
15
|
|
|
5
16
|
### Fixed
|
package/README.md
CHANGED
|
@@ -51,10 +51,10 @@ If pi started without a key, run `/cursor-refresh-models` after `/login` to refr
|
|
|
51
51
|
## Requirements
|
|
52
52
|
|
|
53
53
|
- Node.js 22.19+
|
|
54
|
-
- Pi 0.84.0 or later;
|
|
54
|
+
- Pi 0.84.0 or later; stock Pi 0.84.0 and 0.85.1 are supported without a fork. Pi core peer metadata remains optional and uses `"*"` ranges per Pi package guidance
|
|
55
55
|
- a Cursor SDK API key saved through `/login`, available as `CURSOR_API_KEY`, or passed with pi's `--api-key`
|
|
56
56
|
|
|
57
|
-
No global `@cursor/sdk` install is required. This package depends on exact `@cursor/sdk@1.0.27`, so normal package installation brings in the SDK version this extension was built and tested against. Cursor SDK 1.0.27 declares its Node ConnectRPC transport dependency directly, so npm installs place `@connectrpc/connect-node` where the SDK can resolve it. The extension intentionally does not bundle `@cursor/sdk` or its platform packages, because packing from one maintainer OS can otherwise ship the wrong optional SDK binary for another OS. Cursor SDK 1.0.27 keeps the older `sqlite3 -> node-gyp@8` dependency chain out of the runtime tree, so deprecated install warnings for `inflight`, `rimraf`, `glob@7`, `npmlog`, `gauge`, `are-we-there-yet`, and `tar@6` from that chain are not expected. Older Pi and Cursor SDK compatibility paths are not maintained.
|
|
57
|
+
No global `@cursor/sdk` install is required. This package depends on exact `@cursor/sdk@1.0.27`, so normal package installation brings in the SDK version this extension was built and tested against. Cursor SDK 1.0.27 declares its Node ConnectRPC transport dependency directly, so npm installs place `@connectrpc/connect-node` where the SDK can resolve it. The extension intentionally does not bundle `@cursor/sdk` or its platform packages, because packing from one maintainer OS can otherwise ship the wrong optional SDK binary for another OS. Cursor SDK 1.0.27 keeps the older `sqlite3 -> node-gyp@8` dependency chain out of the runtime tree, so deprecated install warnings for `inflight`, `rimraf`, `glob@7`, `npmlog`, `gauge`, `are-we-there-yet`, and `tar@6` from that chain are not expected. Pi hosts that pass transcript-only provider contexts are also supported through their public replay helpers, detected at runtime; no fork-only API or dependency is required. Older Pi (before 0.84.0) and Cursor SDK compatibility paths are not maintained.
|
|
58
58
|
|
|
59
59
|
## Install
|
|
60
60
|
|
package/dist/context.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { getCursorConversationMessages, resolveCursorPiContext } from "./cursor-pi-context.js";
|
|
2
3
|
import { convertToLlm } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import { CURSOR_PI_BRIDGE_PREFERENCE_TEXT } from "./cursor-bridge-contract.js";
|
|
4
5
|
import { getCursorReplayPromptLabel } from "./cursor-tool-presentation-registry.js";
|
|
@@ -53,7 +54,7 @@ function getCursorBootstrapTailSections(options = {}) {
|
|
|
53
54
|
];
|
|
54
55
|
}
|
|
55
56
|
function normalizePiContextMessages(messages) {
|
|
56
|
-
return convertToLlm(messages);
|
|
57
|
+
return convertToLlm(getCursorConversationMessages({ messages }));
|
|
57
58
|
}
|
|
58
59
|
function isTextBlock(block) {
|
|
59
60
|
return block.type === "text";
|
|
@@ -102,6 +103,9 @@ function formatToolCall(toolCall) {
|
|
|
102
103
|
}
|
|
103
104
|
function sanitizeSystemPromptForCursor(systemPrompt) {
|
|
104
105
|
let sanitized = systemPrompt;
|
|
106
|
+
// Transcript-era Pi wraps its built-in tool catalog/rules in XML sections.
|
|
107
|
+
sanitized = sanitized.replace(/<tools>\n[\s\S]*?\n\nIn addition to the tools above, you may have access to other custom tools depending on the project\.\n<\/tools>/g, "Pi tool catalog omitted: Cursor can call only Cursor SDK tools exposed in this run.");
|
|
108
|
+
sanitized = sanitized.replace(/<rules>\n[\s\S]*?\n<\/rules>\n\n(?=<docs>\nPi documentation )/g, "<rules>\n- Be concise in your responses.\n- Show file paths clearly when working with files.\n</rules>\n\n");
|
|
105
109
|
sanitized = sanitized.replace(/Available tools:\n[\s\S]*?\n\nIn addition to the tools above, you may have access to other custom tools depending on the project\.\n\n/g, "Pi tool catalog omitted: Cursor can call only Cursor SDK tools exposed in this run.\n\n");
|
|
106
110
|
sanitized = sanitized.replace(/Guidelines:\n[\s\S]*?\n\nPi documentation /g, "Guidelines:\n- Be concise in your responses.\n- Show file paths clearly when working with files.\n\nPi documentation ");
|
|
107
111
|
// Keep the Agent Skills catalog. Cursor-specific skill activation wording is normalized
|
|
@@ -210,11 +214,21 @@ function serializeMessageForFingerprint(message, index) {
|
|
|
210
214
|
return hashCursorContextValue(`assistant:${message.timestamp ?? index}:${JSON.stringify(message.content)}`);
|
|
211
215
|
case "toolResult":
|
|
212
216
|
return hashCursorContextValue(`toolResult:${message.timestamp ?? index}:${message.toolCallId}:${message.toolName}:${JSON.stringify(message.content)}:${message.isError === true}`);
|
|
217
|
+
default:
|
|
218
|
+
// System messages are handled by serializeRawPiMessageForFingerprint.
|
|
219
|
+
throw new Error("Unsupported Pi message role in Cursor context fingerprint.");
|
|
213
220
|
}
|
|
214
221
|
}
|
|
215
222
|
function serializeRawPiMessageForFingerprint(message, index) {
|
|
216
223
|
const role = message.role;
|
|
217
224
|
switch (role) {
|
|
225
|
+
case "system": {
|
|
226
|
+
const entry = message;
|
|
227
|
+
return hashCursorContextValue(JSON.stringify({
|
|
228
|
+
role, content: entry.content, sections: entry.sections,
|
|
229
|
+
toolsAdded: entry.toolsAdded, toolsRemoved: entry.toolsRemoved,
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
218
232
|
case "branchSummary": {
|
|
219
233
|
const entry = message;
|
|
220
234
|
return hashCursorContextValue(`branchSummary:${entry.timestamp ?? index}:${entry.fromId ?? ""}:${entry.summary ?? ""}`);
|
|
@@ -253,7 +267,7 @@ function parseCursorContextFingerprint(fingerprint) {
|
|
|
253
267
|
}
|
|
254
268
|
export function computeCursorContextFingerprint(context) {
|
|
255
269
|
const payload = {
|
|
256
|
-
systemHash: hashCursorContextValue(context
|
|
270
|
+
systemHash: hashCursorContextValue(JSON.stringify(resolveCursorPiContext(context))),
|
|
257
271
|
messageHashes: context.messages.map((message, index) => serializeRawPiMessageForFingerprint(message, index)),
|
|
258
272
|
};
|
|
259
273
|
return JSON.stringify(payload);
|
|
@@ -316,8 +330,9 @@ export function buildCursorPrompt(context, options = {}) {
|
|
|
316
330
|
if (options.toolManifest) {
|
|
317
331
|
sectionsBeforeMessages.push(options.toolManifest);
|
|
318
332
|
}
|
|
319
|
-
|
|
320
|
-
|
|
333
|
+
const { systemPrompt } = resolveCursorPiContext(context);
|
|
334
|
+
if (systemPrompt) {
|
|
335
|
+
sectionsBeforeMessages.push(`System instructions from pi:\n${sanitizeSystemPromptForCursor(systemPrompt)}`);
|
|
321
336
|
}
|
|
322
337
|
const messages = normalizePiContextMessages(context.messages);
|
|
323
338
|
const messageSections = messages
|
|
@@ -73,10 +73,19 @@ export function shouldSuppressPiAgentsContext(model, contextFiles, settingSource
|
|
|
73
73
|
export function serializePiProjectInstructionsBlock(file) {
|
|
74
74
|
return `${PI_PROJECT_INSTRUCTIONS_OPEN_PREFIX}${file.path}">\n${file.content}\n${PI_PROJECT_INSTRUCTIONS_CLOSE}\n\n`;
|
|
75
75
|
}
|
|
76
|
-
/**
|
|
77
|
-
export function serializePiProjectContextSection(contextFiles) {
|
|
76
|
+
/** The two supported Pi project-context serializations; legacy includes outer spacing. */
|
|
77
|
+
export function serializePiProjectContextSection(contextFiles, format = "legacy") {
|
|
78
78
|
if (contextFiles.length === 0)
|
|
79
79
|
return "";
|
|
80
|
+
if (format === "transcript") {
|
|
81
|
+
return [
|
|
82
|
+
"<project_context>",
|
|
83
|
+
"Project-specific instructions and guidelines:",
|
|
84
|
+
"",
|
|
85
|
+
contextFiles.map((file) => serializePiProjectInstructionsBlock(file).trimEnd()).join("\n\n"),
|
|
86
|
+
"</project_context>",
|
|
87
|
+
].join("\n");
|
|
88
|
+
}
|
|
80
89
|
return `${PI_PROJECT_CONTEXT_OPEN}${contextFiles.map(serializePiProjectInstructionsBlock).join("")}${PI_PROJECT_CONTEXT_CLOSE}`;
|
|
81
90
|
}
|
|
82
91
|
/** Remove pi context blocks that overlap Cursor setting sources. */
|
|
@@ -92,12 +101,17 @@ export function removePiAgentsContextFromSystemPrompt(systemPrompt, contextFiles
|
|
|
92
101
|
}
|
|
93
102
|
if (!removedAny)
|
|
94
103
|
return systemPrompt;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
// Match only the exact known files in either supported serialization; never
|
|
105
|
+
// strip arbitrary user-authored XML or neighboring custom sections.
|
|
106
|
+
for (const format of ["legacy", "transcript"]) {
|
|
107
|
+
const originalSection = serializePiProjectContextSection(contextFiles, format);
|
|
108
|
+
const start = systemPrompt.indexOf(originalSection);
|
|
109
|
+
if (start < 0)
|
|
110
|
+
continue;
|
|
111
|
+
const replacementSection = serializePiProjectContextSection(retainedContextFiles, format);
|
|
112
|
+
return systemPrompt.slice(0, start) + replacementSection + systemPrompt.slice(start + originalSection.length);
|
|
113
|
+
}
|
|
114
|
+
return systemPrompt;
|
|
101
115
|
}
|
|
102
116
|
export function resolveCursorFacingSystemPrompt(systemPrompt, model, systemPromptOptions, settingSourcesRaw, agentDir, runtime = "local") {
|
|
103
117
|
if (runtime === "cloud" || !systemPromptOptions)
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { resolveCursorPiContext } from "./cursor-pi-context.js";
|
|
1
2
|
/** Tool names from the provider context snapshot at stream start (not live pi.getActiveTools()). */
|
|
2
3
|
export function getActiveContextToolNames(context) {
|
|
3
|
-
|
|
4
|
+
const { tools } = resolveCursorPiContext(context);
|
|
5
|
+
return tools === undefined ? undefined : new Set(tools.map((tool) => tool.name));
|
|
4
6
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getCursorConversationMessages } from "./cursor-pi-context.js";
|
|
1
2
|
import { consumeCursorLiveToolResults, createCursorLiveRunAccountingState, recordCursorLiveSdkTurnEnded, takeCursorLiveSdkTurnUsage, takeCursorLiveTurnInputTokens, } from "./cursor-live-run-accounting.js";
|
|
2
3
|
import { getCursorSessionScopeKey } from "./cursor-session-scope.js";
|
|
3
4
|
import { installCursorSdkProcessErrorGuard } from "./cursor-sdk-process-error-guard.js";
|
|
@@ -24,16 +25,17 @@ async function cancelCursorLiveSdkRun(run) {
|
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
27
|
export function hasTrailingUserMessagesAfterToolResults(context) {
|
|
27
|
-
|
|
28
|
+
const messages = getCursorConversationMessages(context);
|
|
29
|
+
let index = messages.length - 1;
|
|
28
30
|
let sawTrailingUser = false;
|
|
29
|
-
while (index >= 0 &&
|
|
31
|
+
while (index >= 0 && messages[index]?.role === "user") {
|
|
30
32
|
sawTrailingUser = true;
|
|
31
33
|
index -= 1;
|
|
32
34
|
}
|
|
33
35
|
if (!sawTrailingUser)
|
|
34
36
|
return false;
|
|
35
37
|
let sawToolResult = false;
|
|
36
|
-
while (index >= 0 &&
|
|
38
|
+
while (index >= 0 && messages[index]?.role === "toolResult") {
|
|
37
39
|
sawToolResult = true;
|
|
38
40
|
index -= 1;
|
|
39
41
|
}
|
|
@@ -269,12 +271,13 @@ export function createCursorLiveRunCoordinator(deps) {
|
|
|
269
271
|
return taken.sdkTurnUsage;
|
|
270
272
|
},
|
|
271
273
|
getPendingFromContext(context, getReplayId) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
+
const messages = getCursorConversationMessages(context);
|
|
275
|
+
let index = messages.length - 1;
|
|
276
|
+
while (index >= 0 && messages[index]?.role === "user") {
|
|
274
277
|
index -= 1;
|
|
275
278
|
}
|
|
276
279
|
for (; index >= 0; index -= 1) {
|
|
277
|
-
const message =
|
|
280
|
+
const message = messages[index];
|
|
278
281
|
if (message.role !== "toolResult")
|
|
279
282
|
break;
|
|
280
283
|
const replayId = getReplayId(message.toolCallId);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const DEFAULT_THINKING_TRACE_MAX_CHARS = 50000;
|
|
2
|
+
export const CURSOR_TEXT_MESSAGE_SEPARATOR = "\n\n";
|
|
2
3
|
export class CursorPartialContentEmitter {
|
|
3
4
|
stream;
|
|
4
5
|
partial;
|
|
@@ -6,6 +7,7 @@ export class CursorPartialContentEmitter {
|
|
|
6
7
|
mutuallyExclusive;
|
|
7
8
|
thinkingContentIndex = -1;
|
|
8
9
|
textContentIndex = -1;
|
|
10
|
+
textMessageCompleted = false;
|
|
9
11
|
activityTraceChars = 0;
|
|
10
12
|
activityTraceTruncated = false;
|
|
11
13
|
constructor(stream, partial, thinkingMaxChars = DEFAULT_THINKING_TRACE_MAX_CHARS, mutuallyExclusive = true) {
|
|
@@ -79,12 +81,22 @@ export class CursorPartialContentEmitter {
|
|
|
79
81
|
partial: this.partial,
|
|
80
82
|
});
|
|
81
83
|
}
|
|
84
|
+
completeTextMessage() {
|
|
85
|
+
// Defer the separator until more text arrives: an exact final answer must
|
|
86
|
+
// not gain trailing whitespace. A fresh Pi tool-use turn needs no prefix.
|
|
87
|
+
this.textMessageCompleted = this.partial.content.some((block) => block.type === "text" && block.text.length > 0);
|
|
88
|
+
}
|
|
82
89
|
appendTextDelta(delta, options) {
|
|
83
90
|
const closeThinking = options?.closeThinking ?? this.mutuallyExclusive;
|
|
84
91
|
if (closeThinking)
|
|
85
92
|
this.closeThinking();
|
|
86
93
|
if (!delta)
|
|
87
94
|
return;
|
|
95
|
+
if (this.textMessageCompleted) {
|
|
96
|
+
this.textMessageCompleted = false;
|
|
97
|
+
this.appendTextDelta(CURSOR_TEXT_MESSAGE_SEPARATOR, { closeThinking: false });
|
|
98
|
+
this.closeText();
|
|
99
|
+
}
|
|
88
100
|
if (this.textContentIndex < 0) {
|
|
89
101
|
this.textContentIndex = this.partial.content.length;
|
|
90
102
|
this.partial.content.push({ type: "text", text: "" });
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as piAi from "@earendil-works/pi-ai";
|
|
2
|
+
const optionalHelpers = piAi;
|
|
3
|
+
const transcriptHelpers = typeof optionalHelpers.normalizeContext === "function"
|
|
4
|
+
&& typeof optionalHelpers.getCurrentSystemPrompt === "function"
|
|
5
|
+
&& typeof optionalHelpers.getCurrentTools === "function"
|
|
6
|
+
? optionalHelpers : undefined;
|
|
7
|
+
export function isCursorSystemMessage(message) {
|
|
8
|
+
return message.role === "system";
|
|
9
|
+
}
|
|
10
|
+
/** Conversation-only view for Cursor text/history and trailing tool-result scans. */
|
|
11
|
+
export function getCursorConversationMessages(context) {
|
|
12
|
+
return context.messages.filter((message) => !isCursorSystemMessage(message));
|
|
13
|
+
}
|
|
14
|
+
export function resolveCursorPiContext(context) {
|
|
15
|
+
if (!transcriptHelpers) {
|
|
16
|
+
if (context.messages.some(isCursorSystemMessage)) {
|
|
17
|
+
throw new Error("Pi transcript context requires the host's public transcript replay helpers.");
|
|
18
|
+
}
|
|
19
|
+
return { systemPrompt: context.systemPrompt ?? "", tools: context.tools };
|
|
20
|
+
}
|
|
21
|
+
const messages = transcriptHelpers.normalizeContext(context).messages;
|
|
22
|
+
const legacyWithoutTools = ("systemPrompt" in context || "tools" in context)
|
|
23
|
+
&& context.tools === undefined && !context.messages.some(isCursorSystemMessage);
|
|
24
|
+
return {
|
|
25
|
+
systemPrompt: transcriptHelpers.getCurrentSystemPrompt(messages),
|
|
26
|
+
// A messages-only provider request is authoritative, including no declarations.
|
|
27
|
+
// An explicitly legacy shorthand context retains its absent-snapshot semantics.
|
|
28
|
+
tools: legacyWithoutTools ? undefined : transcriptHelpers.getCurrentTools(messages),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -3,7 +3,7 @@ import { CursorLiveRunAbortError, createCursorLiveRunCoordinator, hasTrailingUse
|
|
|
3
3
|
import { deleteCursorNativeToolDisplay, recordCursorNativeToolDisplay, } from "./cursor-native-tool-display-state.js";
|
|
4
4
|
import { resetSessionCursorAgent } from "./cursor-session-agent.js";
|
|
5
5
|
import { applyCursorUsage } from "./cursor-usage-accounting.js";
|
|
6
|
-
import { CursorPartialContentEmitter } from "./cursor-partial-content-emitter.js";
|
|
6
|
+
import { CURSOR_TEXT_MESSAGE_SEPARATOR, CursorPartialContentEmitter } from "./cursor-partial-content-emitter.js";
|
|
7
7
|
import { emitDisplayOnlyTraceBlock } from "./cursor-display-only-trace.js";
|
|
8
8
|
import { trimCurrentTurnAlreadyEmittedCursorText } from "./cursor-run-final-text.js";
|
|
9
9
|
import { formatCursorSdkAbortMessage, resolveCursorSdkAbortCause } from "./cursor-provider-errors.js";
|
|
@@ -50,8 +50,7 @@ function splitTextIntoReplayDeltas(text) {
|
|
|
50
50
|
}
|
|
51
51
|
return deltas;
|
|
52
52
|
}
|
|
53
|
-
async function emitTextDeltas(
|
|
54
|
-
const emitter = new CursorPartialContentEmitter(stream, partial, -1, true);
|
|
53
|
+
async function emitTextDeltas(emitter, deltas) {
|
|
55
54
|
for (const delta of deltas) {
|
|
56
55
|
emitter.appendTextDelta(delta);
|
|
57
56
|
await Promise.resolve();
|
|
@@ -80,7 +79,7 @@ export function flushPendingCursorLiveRunTraceEventsToStream(stream, partial, ru
|
|
|
80
79
|
if (run.disposed)
|
|
81
80
|
return;
|
|
82
81
|
const turn = {
|
|
83
|
-
emitter: new CursorPartialContentEmitter(stream, partial, -1, true),
|
|
82
|
+
emitter: options?.emitter ?? new CursorPartialContentEmitter(stream, partial, -1, true),
|
|
84
83
|
emittedText: "",
|
|
85
84
|
};
|
|
86
85
|
while (true) {
|
|
@@ -110,6 +109,15 @@ function emitCursorLiveQueuedEvent(turn, event, run) {
|
|
|
110
109
|
else if (event.type === "thinking-completed") {
|
|
111
110
|
turn.emitter.closeThinking();
|
|
112
111
|
}
|
|
112
|
+
else if (event.type === "text-completed") {
|
|
113
|
+
turn.emitter.completeTextMessage();
|
|
114
|
+
// Logical separators also keep suffix/prefix dedup aware of SDK messages,
|
|
115
|
+
// including boundaries queued across Pi tool-use turns.
|
|
116
|
+
if (turn.emittedText)
|
|
117
|
+
turn.emittedText += CURSOR_TEXT_MESSAGE_SEPARATOR;
|
|
118
|
+
if (run?.emittedText)
|
|
119
|
+
run.emittedText += CURSOR_TEXT_MESSAGE_SEPARATOR;
|
|
120
|
+
}
|
|
113
121
|
else if (event.type === "text-delta") {
|
|
114
122
|
turn.emittedText += event.text;
|
|
115
123
|
if (run)
|
|
@@ -231,7 +239,7 @@ export async function drainCursorLiveRunTurn(stream, partial, model, context, ru
|
|
|
231
239
|
let outcome;
|
|
232
240
|
let outcomeDetails = {};
|
|
233
241
|
const turn = {
|
|
234
|
-
emitter: new CursorPartialContentEmitter(stream, partial, -1, true),
|
|
242
|
+
emitter: options.emitter ?? new CursorPartialContentEmitter(stream, partial, -1, true),
|
|
235
243
|
emittedText: "",
|
|
236
244
|
};
|
|
237
245
|
try {
|
|
@@ -291,7 +299,7 @@ export async function drainCursorLiveRunTurn(stream, partial, model, context, ru
|
|
|
291
299
|
turn.emitter.closeAll();
|
|
292
300
|
const finalText = trimCurrentTurnAlreadyEmittedCursorText(run.finalText ?? run.textDeltas.join(""), turn.emittedText, run.emittedText);
|
|
293
301
|
if (finalText) {
|
|
294
|
-
await emitTextDeltas(
|
|
302
|
+
await emitTextDeltas(turn.emitter, splitTextIntoReplayDeltas(finalText));
|
|
295
303
|
}
|
|
296
304
|
applyCursorUsage(partial, model, context, cursorLiveRuns.takeTurnInputTokens(run, toolResultInputTokens), {
|
|
297
305
|
runtime: "local",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CURSOR_TEXT_MESSAGE_SEPARATOR } from "./cursor-partial-content-emitter.js";
|
|
1
2
|
import { cursorLiveRuns } from "./cursor-provider-live-run-drain.js";
|
|
2
3
|
import { truncateCursorDisplayLine } from "./cursor-display-text.js";
|
|
3
4
|
import { recordDiscardedIncompleteStartedToolCall, } from "./cursor-sdk-event-debug.js";
|
|
@@ -29,6 +30,7 @@ export class CursorSdkTurnCoordinator {
|
|
|
29
30
|
lifecycleEmitter;
|
|
30
31
|
contentEmitter;
|
|
31
32
|
sdkTurnUsage;
|
|
33
|
+
hasPendingTextMessage = false;
|
|
32
34
|
constructor(options) {
|
|
33
35
|
this.stream = options.stream;
|
|
34
36
|
this.partial = options.partial;
|
|
@@ -106,6 +108,8 @@ export class CursorSdkTurnCoordinator {
|
|
|
106
108
|
cursorLiveRuns.recordSdkTurnEnded(this.liveRun, sdkTurnUsage);
|
|
107
109
|
}
|
|
108
110
|
if (update.type === "text-delta") {
|
|
111
|
+
if (update.text)
|
|
112
|
+
this.hasPendingTextMessage = true;
|
|
109
113
|
this.textDeltas.push(update.text);
|
|
110
114
|
if (this.liveRun) {
|
|
111
115
|
cursorLiveRuns.queueEvent(this.liveRun, { type: "text-delta", text: update.text });
|
|
@@ -206,6 +210,20 @@ export class CursorSdkTurnCoordinator {
|
|
|
206
210
|
}
|
|
207
211
|
handleStep(stepEnvelope) {
|
|
208
212
|
const stepType = getField(stepEnvelope, "type");
|
|
213
|
+
if (stepType === "assistantMessage") {
|
|
214
|
+
// SDK onStep completes a message; onDelta chunks within it are tokens,
|
|
215
|
+
// not separate messages. Keep the boundary in both output and final-text
|
|
216
|
+
// deduplication, without replaying the completed step's text a second time.
|
|
217
|
+
if (this.hasPendingTextMessage) {
|
|
218
|
+
this.hasPendingTextMessage = false;
|
|
219
|
+
this.textDeltas.push(CURSOR_TEXT_MESSAGE_SEPARATOR);
|
|
220
|
+
if (this.liveRun)
|
|
221
|
+
cursorLiveRuns.queueEvent(this.liveRun, { type: "text-completed" });
|
|
222
|
+
else
|
|
223
|
+
this.contentEmitter.completeTextMessage();
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
209
227
|
const step = getField(stepEnvelope, "message") ? stepEnvelope : undefined;
|
|
210
228
|
const rawStepToolCall = getField(step, "message");
|
|
211
229
|
if (stepType !== "toolCall")
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CursorLiveRunAbortError } from "./cursor-live-run-coordinator.js";
|
|
2
|
+
import { CursorPartialContentEmitter } from "./cursor-partial-content-emitter.js";
|
|
2
3
|
import { cursorLiveRuns, drainCursorLiveRunTurn, flushPendingCursorLiveRunTraceEventsToStream, settleCursorLiveToolBatch, } from "./cursor-provider-live-run-drain.js";
|
|
3
4
|
import { buildIncompleteCursorToolRunOutcome, } from "./cursor-incomplete-tool-visibility.js";
|
|
4
5
|
export async function emitCursorLiveTurn(emitParams) {
|
|
@@ -7,6 +8,9 @@ export async function emitCursorLiveTurn(emitParams) {
|
|
|
7
8
|
throw new Error("emitCursorLiveTurn requires a live run");
|
|
8
9
|
const { liveRun, turnCoordinator } = prepared.runtime;
|
|
9
10
|
const { options, model } = params;
|
|
11
|
+
// Drain and abort recovery write to the same partial. Keep its open text block
|
|
12
|
+
// and deferred message boundary together; a new Pi turn still gets a new emitter.
|
|
13
|
+
const emitter = new CursorPartialContentEmitter(params.stream, params.partial, -1, true);
|
|
10
14
|
try {
|
|
11
15
|
await cursorLiveRuns.withRunLease(liveRun, options?.signal, async () => {
|
|
12
16
|
await cursorLiveRuns.waitForProgress(liveRun, options?.signal);
|
|
@@ -14,6 +18,7 @@ export async function emitCursorLiveTurn(emitParams) {
|
|
|
14
18
|
turnCoordinator.closeTraceBlock();
|
|
15
19
|
await drainCursorLiveRunTurn(params.stream, params.partial, model, params.context, liveRun, 0, {
|
|
16
20
|
mode: "emit",
|
|
21
|
+
emitter,
|
|
17
22
|
signal: options?.signal,
|
|
18
23
|
debugRecorder: sdkEventDebug,
|
|
19
24
|
});
|
|
@@ -25,6 +30,7 @@ export async function emitCursorLiveTurn(emitParams) {
|
|
|
25
30
|
turnCoordinator.closeTraceBlock();
|
|
26
31
|
flushPendingCursorLiveRunTraceEventsToStream(params.stream, params.partial, liveRun, {
|
|
27
32
|
includeTracesBehindQueuedTools: true,
|
|
33
|
+
emitter,
|
|
28
34
|
});
|
|
29
35
|
}
|
|
30
36
|
throw caught;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getCursorConversationMessages, resolveCursorPiContext } from "./cursor-pi-context.js";
|
|
1
2
|
import { configureCursorSdkHttp1 } from "./cursor-http1.js";
|
|
2
3
|
import { installCursorMcpToolTimeoutOverride } from "./cursor-mcp-timeout-override.js";
|
|
3
4
|
import { ensureCursorRipgrepPath } from "./cursor-ripgrep-path.js";
|
|
@@ -25,12 +26,16 @@ import { loadCursorSdk } from "./cursor-sdk-runtime.js";
|
|
|
25
26
|
function buildCursorCloudPromptContext(context, handoff) {
|
|
26
27
|
if (handoff === "bootstrap")
|
|
27
28
|
return context;
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
// Fresh cloud runs omit history, not the current Pi instructions. Replay before
|
|
30
|
+
// selecting the user message, otherwise transcript-only inputs lose the prompt.
|
|
31
|
+
const current = resolveCursorPiContext(context);
|
|
32
|
+
const messages = getCursorConversationMessages(context);
|
|
33
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
34
|
+
const message = messages[index];
|
|
30
35
|
if (message.role === "user")
|
|
31
|
-
return { ...
|
|
36
|
+
return { ...current, messages: [message] };
|
|
32
37
|
}
|
|
33
|
-
return { ...
|
|
38
|
+
return { ...current, messages: messages.slice(-1) };
|
|
34
39
|
}
|
|
35
40
|
const CLOUD_SEND_PLAN = { mode: "bootstrap", resetAgent: false, reason: "initial" };
|
|
36
41
|
export function resolveCursorProviderTurnConfig(cwd) {
|
|
@@ -69,7 +74,7 @@ async function prepareCursorCloudProviderTurn(prepareParams) {
|
|
|
69
74
|
repo: resolvedConfig.cloud.repo.value,
|
|
70
75
|
branch: resolvedConfig.cloud.branch.value,
|
|
71
76
|
}),
|
|
72
|
-
hasPriorContext: context.
|
|
77
|
+
hasPriorContext: getCursorConversationMessages(context).length > 1,
|
|
73
78
|
});
|
|
74
79
|
if (!preflight.ok)
|
|
75
80
|
throw new Error(formatCursorCloudPreflightError(preflight));
|
|
@@ -8,7 +8,7 @@ import { resolveCursorPiToolBridgeEnabled } from "./cursor-pi-tool-bridge-env.js
|
|
|
8
8
|
import { resolveEffectiveCursorConfigForContext } from "./cursor-runtime-state.js";
|
|
9
9
|
export const CURSOR_ACTIVATE_SKILL_TOOL_NAME = "cursor_activate_skill";
|
|
10
10
|
export const CURSOR_ACTIVATE_SKILL_MCP_NAME = "pi__cursor_activate_skill";
|
|
11
|
-
const AVAILABLE_SKILLS_SECTION_PATTERN =
|
|
11
|
+
const AVAILABLE_SKILLS_SECTION_PATTERN = /<skills>\nThe following skills provide specialized instructions for specific tasks\.[\s\S]*?<\/available_skills>\n<\/skills>|\n\nThe following skills provide specialized instructions for specific tasks\.[\s\S]*?<\/available_skills>/;
|
|
12
12
|
const MAX_SKILL_RESOURCES = 80;
|
|
13
13
|
const RESOURCE_DIR_NAMES = ["scripts", "references", "assets"];
|
|
14
14
|
let currentSkillsByName = new Map();
|
|
@@ -8,6 +8,8 @@ Implemented design target. This file describes the intended Cursor model UX and
|
|
|
8
8
|
|
|
9
9
|
Current implementation notes:
|
|
10
10
|
|
|
11
|
+
- Provider contexts support stock Pi 0.84.0/0.85.1 shorthand fields and transcript-only hosts. `cursor-pi-context.ts` feature-detects public Pi replay helpers through a static namespace import; no new export is required from stock Pi. Current instructions and tools are replayed natively, system deltas are fingerprinted, and system messages are excluded from Cursor conversation formatting and trailing-result scans. Fresh cloud context preserves current instructions while excluding history. Native replay uses the request tool snapshot (including empty); the pi bridge retains its separate registry-owned surface. XML prompt sections are sanitized/deduplicated alongside the stock format.
|
|
12
|
+
|
|
11
13
|
- Cursor context variants use `base@context` pi model IDs.
|
|
12
14
|
- Cursor `reasoning`, `effort`, and boolean `thinking` parameters are driven by pi native thinking when the Cursor SDK exposes those controls.
|
|
13
15
|
- Cursor `fast` is extension state by default; models that expose `fast` also get selection-only `:fast` / `:slow` virtual aliases for per-agent overrides.
|
|
@@ -33,7 +33,7 @@ When changing provider/runtime behavior, ask whether the bug spans **pi extensio
|
|
|
33
33
|
Native replay routing intentionally uses two layers:
|
|
34
34
|
|
|
35
35
|
1. **Extension resync** (`before_agent_start`, `turn_start`) updates pi's active tool set via `syncRegisteredNativeCursorToolsForModel`. This fixes the common case where plan-mode execute strips `grep`/`find`/`cursor` before the next turn.
|
|
36
|
-
2. **Provider routing** uses the
|
|
36
|
+
2. **Provider routing** uses the **request tool snapshot** captured when `streamCursor()` starts (`getActiveContextToolNames` in `src/cursor-context-tools.ts`): legacy `context.tools` on stock Pi 0.84/0.85, or tools replayed by the host's public helpers on transcript-only Pi. An explicit empty snapshot is not an absent legacy snapshot. It does not read live `pi.getActiveTools()` mid-stream. The bridge intentionally retains its separate registry-owned surface.
|
|
37
37
|
|
|
38
38
|
`src/cursor-native-replay-routing.ts` centralizes provider-side routing against the same `context.tools` snapshot:
|
|
39
39
|
|
|
@@ -48,6 +48,12 @@ Disposition outcomes:
|
|
|
48
48
|
|
|
49
49
|
If resync runs but `context.tools` is still stale (e.g. only `read` listed), the provider must **not** emit `toolUse` for inactive tools. `test/cursor-native-replay-stress.test.ts` covers that stale-snapshot path.
|
|
50
50
|
|
|
51
|
+
## Stock/transcript provider contract
|
|
52
|
+
|
|
53
|
+
`test/cursor-provider-pi-context.test.ts` drives real `ModelRuntime` / `ModelRegistry` requests into `streamCursor`, with only Cursor SDK execution mocked. Run it against each supported host with all Pi peer imports pinned to that host (including nested native imports); a top-level package version alone is not resolution evidence. Cover stock 0.84.0, stock 0.85.1, and the transcript host. Transcript-only cases are not stock features and are skipped there.
|
|
54
|
+
|
|
55
|
+
The test covers bootstrap/incremental prompts, empty-vs-absent request tools, native replay/drain, cloud fresh/bootstrap selection, and actual host prompt serialization for context files and skills. It is offline contract evidence, not a replacement for the required live platform/cloud release gates.
|
|
56
|
+
|
|
51
57
|
## Auth: use `auth.json`, not only env
|
|
52
58
|
|
|
53
59
|
pi resolves Cursor auth in this order:
|
package/package.json
CHANGED
package/src/context.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { getCursorConversationMessages, resolveCursorPiContext } from "./cursor-pi-context.js";
|
|
2
3
|
import type { Context, Message, ToolCall } from "@earendil-works/pi-ai";
|
|
3
4
|
import { convertToLlm } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import type { AgentModeOption, SDKImage } from "@cursor/sdk";
|
|
@@ -85,7 +86,7 @@ function getCursorBootstrapTailSections(
|
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
function normalizePiContextMessages(messages: Context["messages"]): Message[] {
|
|
88
|
-
return convertToLlm(messages as Parameters<typeof convertToLlm>[0]);
|
|
89
|
+
return convertToLlm(getCursorConversationMessages({ messages }) as Parameters<typeof convertToLlm>[0]);
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
function isTextBlock(block: { type: string }): block is { type: "text"; text: string } {
|
|
@@ -137,6 +138,15 @@ function formatToolCall(toolCall: ToolCall): string {
|
|
|
137
138
|
|
|
138
139
|
function sanitizeSystemPromptForCursor(systemPrompt: string): string {
|
|
139
140
|
let sanitized = systemPrompt;
|
|
141
|
+
// Transcript-era Pi wraps its built-in tool catalog/rules in XML sections.
|
|
142
|
+
sanitized = sanitized.replace(
|
|
143
|
+
/<tools>\n[\s\S]*?\n\nIn addition to the tools above, you may have access to other custom tools depending on the project\.\n<\/tools>/g,
|
|
144
|
+
"Pi tool catalog omitted: Cursor can call only Cursor SDK tools exposed in this run.",
|
|
145
|
+
);
|
|
146
|
+
sanitized = sanitized.replace(
|
|
147
|
+
/<rules>\n[\s\S]*?\n<\/rules>\n\n(?=<docs>\nPi documentation )/g,
|
|
148
|
+
"<rules>\n- Be concise in your responses.\n- Show file paths clearly when working with files.\n</rules>\n\n",
|
|
149
|
+
);
|
|
140
150
|
sanitized = sanitized.replace(
|
|
141
151
|
/Available tools:\n[\s\S]*?\n\nIn addition to the tools above, you may have access to other custom tools depending on the project\.\n\n/g,
|
|
142
152
|
"Pi tool catalog omitted: Cursor can call only Cursor SDK tools exposed in this run.\n\n",
|
|
@@ -279,12 +289,24 @@ function serializeMessageForFingerprint(message: Message, index: number): string
|
|
|
279
289
|
return hashCursorContextValue(
|
|
280
290
|
`toolResult:${message.timestamp ?? index}:${message.toolCallId}:${message.toolName}:${JSON.stringify(message.content)}:${message.isError === true}`,
|
|
281
291
|
);
|
|
292
|
+
default:
|
|
293
|
+
// System messages are handled by serializeRawPiMessageForFingerprint.
|
|
294
|
+
throw new Error("Unsupported Pi message role in Cursor context fingerprint.");
|
|
282
295
|
}
|
|
283
296
|
}
|
|
284
297
|
|
|
285
298
|
function serializeRawPiMessageForFingerprint(message: Context["messages"][number], index: number): string {
|
|
286
299
|
const role = (message as { role?: string }).role;
|
|
287
300
|
switch (role) {
|
|
301
|
+
case "system": {
|
|
302
|
+
const entry = message as {
|
|
303
|
+
content: unknown; sections?: unknown; toolsAdded?: unknown; toolsRemoved?: unknown;
|
|
304
|
+
};
|
|
305
|
+
return hashCursorContextValue(JSON.stringify({
|
|
306
|
+
role, content: entry.content, sections: entry.sections,
|
|
307
|
+
toolsAdded: entry.toolsAdded, toolsRemoved: entry.toolsRemoved,
|
|
308
|
+
}));
|
|
309
|
+
}
|
|
288
310
|
case "branchSummary": {
|
|
289
311
|
const entry = message as { summary?: string; fromId?: string; timestamp?: number };
|
|
290
312
|
return hashCursorContextValue(
|
|
@@ -337,7 +359,7 @@ function parseCursorContextFingerprint(fingerprint: string): CursorContextFinger
|
|
|
337
359
|
|
|
338
360
|
export function computeCursorContextFingerprint(context: Context): string {
|
|
339
361
|
const payload: CursorContextFingerprintPayload = {
|
|
340
|
-
systemHash: hashCursorContextValue(context
|
|
362
|
+
systemHash: hashCursorContextValue(JSON.stringify(resolveCursorPiContext(context))),
|
|
341
363
|
messageHashes: context.messages.map((message, index) => serializeRawPiMessageForFingerprint(message, index)),
|
|
342
364
|
};
|
|
343
365
|
return JSON.stringify(payload);
|
|
@@ -412,8 +434,9 @@ export function buildCursorPrompt(context: Context, options: CursorPromptOptions
|
|
|
412
434
|
sectionsBeforeMessages.push(options.toolManifest);
|
|
413
435
|
}
|
|
414
436
|
|
|
415
|
-
|
|
416
|
-
|
|
437
|
+
const { systemPrompt } = resolveCursorPiContext(context);
|
|
438
|
+
if (systemPrompt) {
|
|
439
|
+
sectionsBeforeMessages.push(`System instructions from pi:\n${sanitizeSystemPromptForCursor(systemPrompt)}`);
|
|
417
440
|
}
|
|
418
441
|
|
|
419
442
|
const messages = normalizePiContextMessages(context.messages);
|
|
@@ -114,9 +114,21 @@ export function serializePiProjectInstructionsBlock(file: PiAgentsContextFile):
|
|
|
114
114
|
return `${PI_PROJECT_INSTRUCTIONS_OPEN_PREFIX}${file.path}">\n${file.content}\n${PI_PROJECT_INSTRUCTIONS_CLOSE}\n\n`;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
/**
|
|
118
|
-
export function serializePiProjectContextSection(
|
|
117
|
+
/** The two supported Pi project-context serializations; legacy includes outer spacing. */
|
|
118
|
+
export function serializePiProjectContextSection(
|
|
119
|
+
contextFiles: readonly PiAgentsContextFile[],
|
|
120
|
+
format: "legacy" | "transcript" = "legacy",
|
|
121
|
+
): string {
|
|
119
122
|
if (contextFiles.length === 0) return "";
|
|
123
|
+
if (format === "transcript") {
|
|
124
|
+
return [
|
|
125
|
+
"<project_context>",
|
|
126
|
+
"Project-specific instructions and guidelines:",
|
|
127
|
+
"",
|
|
128
|
+
contextFiles.map((file) => serializePiProjectInstructionsBlock(file).trimEnd()).join("\n\n"),
|
|
129
|
+
"</project_context>",
|
|
130
|
+
].join("\n");
|
|
131
|
+
}
|
|
120
132
|
return `${PI_PROJECT_CONTEXT_OPEN}${contextFiles.map(serializePiProjectInstructionsBlock).join("")}${PI_PROJECT_CONTEXT_CLOSE}`;
|
|
121
133
|
}
|
|
122
134
|
|
|
@@ -138,12 +150,16 @@ export function removePiAgentsContextFromSystemPrompt(
|
|
|
138
150
|
}
|
|
139
151
|
if (!removedAny) return systemPrompt;
|
|
140
152
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
153
|
+
// Match only the exact known files in either supported serialization; never
|
|
154
|
+
// strip arbitrary user-authored XML or neighboring custom sections.
|
|
155
|
+
for (const format of ["legacy", "transcript"] as const) {
|
|
156
|
+
const originalSection = serializePiProjectContextSection(contextFiles, format);
|
|
157
|
+
const start = systemPrompt.indexOf(originalSection);
|
|
158
|
+
if (start < 0) continue;
|
|
159
|
+
const replacementSection = serializePiProjectContextSection(retainedContextFiles, format);
|
|
160
|
+
return systemPrompt.slice(0, start) + replacementSection + systemPrompt.slice(start + originalSection.length);
|
|
161
|
+
}
|
|
162
|
+
return systemPrompt;
|
|
147
163
|
}
|
|
148
164
|
|
|
149
165
|
export function resolveCursorFacingSystemPrompt(
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Context } from "@earendil-works/pi-ai";
|
|
2
|
+
import { resolveCursorPiContext } from "./cursor-pi-context.js";
|
|
2
3
|
|
|
3
4
|
/** Tool names from the provider context snapshot at stream start (not live pi.getActiveTools()). */
|
|
4
5
|
export function getActiveContextToolNames(context: Context): ReadonlySet<string> | undefined {
|
|
5
|
-
|
|
6
|
+
const { tools } = resolveCursorPiContext(context);
|
|
7
|
+
return tools === undefined ? undefined : new Set(tools.map((tool) => tool.name));
|
|
6
8
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Context, ToolResultMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
import { getCursorConversationMessages } from "./cursor-pi-context.js";
|
|
2
3
|
import type { SDKAgent } from "@cursor/sdk";
|
|
3
4
|
import {
|
|
4
5
|
consumeCursorLiveToolResults,
|
|
@@ -27,6 +28,7 @@ export type CursorLiveQueuedEvent =
|
|
|
27
28
|
| { type: "thinking-delta"; text: string }
|
|
28
29
|
| { type: "thinking-completed" }
|
|
29
30
|
| { type: "text-delta"; text: string }
|
|
31
|
+
| { type: "text-completed" }
|
|
30
32
|
| { type: "tool"; tool: CursorNativeToolDisplayItem }
|
|
31
33
|
| { type: "bridge-tool"; request: CursorPiBridgeToolRequest };
|
|
32
34
|
|
|
@@ -155,16 +157,17 @@ interface CursorLiveRunPrivateState {
|
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
export function hasTrailingUserMessagesAfterToolResults(context: Context): boolean {
|
|
158
|
-
|
|
160
|
+
const messages = getCursorConversationMessages(context);
|
|
161
|
+
let index = messages.length - 1;
|
|
159
162
|
let sawTrailingUser = false;
|
|
160
|
-
while (index >= 0 &&
|
|
163
|
+
while (index >= 0 && messages[index]?.role === "user") {
|
|
161
164
|
sawTrailingUser = true;
|
|
162
165
|
index -= 1;
|
|
163
166
|
}
|
|
164
167
|
if (!sawTrailingUser) return false;
|
|
165
168
|
|
|
166
169
|
let sawToolResult = false;
|
|
167
|
-
while (index >= 0 &&
|
|
170
|
+
while (index >= 0 && messages[index]?.role === "toolResult") {
|
|
168
171
|
sawToolResult = true;
|
|
169
172
|
index -= 1;
|
|
170
173
|
}
|
|
@@ -418,13 +421,14 @@ export function createCursorLiveRunCoordinator(deps: CursorLiveRunCoordinatorDep
|
|
|
418
421
|
},
|
|
419
422
|
|
|
420
423
|
getPendingFromContext(context, getReplayId): CursorLiveRun | undefined {
|
|
421
|
-
|
|
422
|
-
|
|
424
|
+
const messages = getCursorConversationMessages(context);
|
|
425
|
+
let index = messages.length - 1;
|
|
426
|
+
while (index >= 0 && messages[index]?.role === "user") {
|
|
423
427
|
index -= 1;
|
|
424
428
|
}
|
|
425
429
|
|
|
426
430
|
for (; index >= 0; index -= 1) {
|
|
427
|
-
const message =
|
|
431
|
+
const message = messages[index];
|
|
428
432
|
if (message.role !== "toolResult") break;
|
|
429
433
|
const replayId = getReplayId(message.toolCallId);
|
|
430
434
|
if (replayId) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AssistantMessage, AssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
2
2
|
|
|
3
3
|
const DEFAULT_THINKING_TRACE_MAX_CHARS = 50000;
|
|
4
|
+
export const CURSOR_TEXT_MESSAGE_SEPARATOR = "\n\n";
|
|
4
5
|
|
|
5
6
|
export interface CursorPartialContentEmitterOptions {
|
|
6
7
|
stream: AssistantMessageEventStream;
|
|
@@ -12,6 +13,7 @@ export interface CursorPartialContentEmitterOptions {
|
|
|
12
13
|
export class CursorPartialContentEmitter {
|
|
13
14
|
private thinkingContentIndex = -1;
|
|
14
15
|
private textContentIndex = -1;
|
|
16
|
+
private textMessageCompleted = false;
|
|
15
17
|
private activityTraceChars = 0;
|
|
16
18
|
private activityTraceTruncated = false;
|
|
17
19
|
|
|
@@ -86,10 +88,21 @@ export class CursorPartialContentEmitter {
|
|
|
86
88
|
});
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
completeTextMessage(): void {
|
|
92
|
+
// Defer the separator until more text arrives: an exact final answer must
|
|
93
|
+
// not gain trailing whitespace. A fresh Pi tool-use turn needs no prefix.
|
|
94
|
+
this.textMessageCompleted = this.partial.content.some((block) => block.type === "text" && block.text.length > 0);
|
|
95
|
+
}
|
|
96
|
+
|
|
89
97
|
appendTextDelta(delta: string, options?: { closeThinking?: boolean }): void {
|
|
90
98
|
const closeThinking = options?.closeThinking ?? this.mutuallyExclusive;
|
|
91
99
|
if (closeThinking) this.closeThinking();
|
|
92
100
|
if (!delta) return;
|
|
101
|
+
if (this.textMessageCompleted) {
|
|
102
|
+
this.textMessageCompleted = false;
|
|
103
|
+
this.appendTextDelta(CURSOR_TEXT_MESSAGE_SEPARATOR, { closeThinking: false });
|
|
104
|
+
this.closeText();
|
|
105
|
+
}
|
|
93
106
|
if (this.textContentIndex < 0) {
|
|
94
107
|
this.textContentIndex = this.partial.content.length;
|
|
95
108
|
this.partial.content.push({ type: "text", text: "" });
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as piAi from "@earendil-works/pi-ai";
|
|
2
|
+
import type { Context, Message, Tool } from "@earendil-works/pi-ai";
|
|
3
|
+
|
|
4
|
+
// Stock 0.84/0.85 do not export transcript helpers. Keep the namespace import in
|
|
5
|
+
// Pi's static extension graph: the host loader supplies its own peer module.
|
|
6
|
+
// Context remains the common structural input (its shorthand fields are optional);
|
|
7
|
+
// transcript hosts pass messages only. Never implement Pi's replay rules here.
|
|
8
|
+
interface PiTranscriptHelpers {
|
|
9
|
+
normalizeContext(context: Context): { messages: readonly { role: string }[] };
|
|
10
|
+
getCurrentSystemPrompt(messages: readonly { role: string }[]): string;
|
|
11
|
+
getCurrentTools(messages: readonly { role: string }[]): Tool[];
|
|
12
|
+
}
|
|
13
|
+
const optionalHelpers = piAi as typeof piAi & Partial<PiTranscriptHelpers>;
|
|
14
|
+
const transcriptHelpers = typeof optionalHelpers.normalizeContext === "function"
|
|
15
|
+
&& typeof optionalHelpers.getCurrentSystemPrompt === "function"
|
|
16
|
+
&& typeof optionalHelpers.getCurrentTools === "function"
|
|
17
|
+
? optionalHelpers as PiTranscriptHelpers : undefined;
|
|
18
|
+
|
|
19
|
+
export function isCursorSystemMessage(message: { role: string }): boolean {
|
|
20
|
+
return message.role === "system";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Conversation-only view for Cursor text/history and trailing tool-result scans. */
|
|
24
|
+
export function getCursorConversationMessages(context: Pick<Context, "messages">): Message[] {
|
|
25
|
+
return context.messages.filter((message) => !isCursorSystemMessage(message));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveCursorPiContext(context: Context): { systemPrompt: string; tools: Tool[] | undefined } {
|
|
29
|
+
if (!transcriptHelpers) {
|
|
30
|
+
if (context.messages.some(isCursorSystemMessage)) {
|
|
31
|
+
throw new Error("Pi transcript context requires the host's public transcript replay helpers.");
|
|
32
|
+
}
|
|
33
|
+
return { systemPrompt: context.systemPrompt ?? "", tools: context.tools };
|
|
34
|
+
}
|
|
35
|
+
const messages = transcriptHelpers.normalizeContext(context).messages;
|
|
36
|
+
const legacyWithoutTools = ("systemPrompt" in context || "tools" in context)
|
|
37
|
+
&& context.tools === undefined && !context.messages.some(isCursorSystemMessage);
|
|
38
|
+
return {
|
|
39
|
+
systemPrompt: transcriptHelpers.getCurrentSystemPrompt(messages),
|
|
40
|
+
// A messages-only provider request is authoritative, including no declarations.
|
|
41
|
+
// An explicitly legacy shorthand context retains its absent-snapshot semantics.
|
|
42
|
+
tools: legacyWithoutTools ? undefined : transcriptHelpers.getCurrentTools(messages),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
import { type CursorPiBridgeToolRequest } from "./cursor-pi-tool-bridge.js";
|
|
22
22
|
import { resetSessionCursorAgent } from "./cursor-session-agent.js";
|
|
23
23
|
import { applyCursorUsage } from "./cursor-usage-accounting.js";
|
|
24
|
-
import { CursorPartialContentEmitter } from "./cursor-partial-content-emitter.js";
|
|
24
|
+
import { CURSOR_TEXT_MESSAGE_SEPARATOR, CursorPartialContentEmitter } from "./cursor-partial-content-emitter.js";
|
|
25
25
|
import { emitDisplayOnlyTraceBlock } from "./cursor-display-only-trace.js";
|
|
26
26
|
import { trimCurrentTurnAlreadyEmittedCursorText } from "./cursor-run-final-text.js";
|
|
27
27
|
import { formatCursorSdkAbortMessage, resolveCursorSdkAbortCause } from "./cursor-provider-errors.js";
|
|
@@ -88,11 +88,9 @@ function splitTextIntoReplayDeltas(text: string): string[] {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
async function emitTextDeltas(
|
|
91
|
-
|
|
92
|
-
partial: AssistantMessage,
|
|
91
|
+
emitter: CursorPartialContentEmitter,
|
|
93
92
|
deltas: string[],
|
|
94
93
|
): Promise<string> {
|
|
95
|
-
const emitter = new CursorPartialContentEmitter(stream, partial, -1, true);
|
|
96
94
|
for (const delta of deltas) {
|
|
97
95
|
emitter.appendTextDelta(delta);
|
|
98
96
|
await Promise.resolve();
|
|
@@ -121,11 +119,11 @@ export function flushPendingCursorLiveRunTraceEventsToStream(
|
|
|
121
119
|
stream: AssistantMessageEventStream,
|
|
122
120
|
partial: AssistantMessage,
|
|
123
121
|
run: CursorLiveRun,
|
|
124
|
-
options?: { includeTracesBehindQueuedTools?: boolean },
|
|
122
|
+
options?: { includeTracesBehindQueuedTools?: boolean; emitter?: CursorPartialContentEmitter },
|
|
125
123
|
): void {
|
|
126
124
|
if (run.disposed) return;
|
|
127
125
|
const turn: CursorLiveTurnState = {
|
|
128
|
-
emitter: new CursorPartialContentEmitter(stream, partial, -1, true),
|
|
126
|
+
emitter: options?.emitter ?? new CursorPartialContentEmitter(stream, partial, -1, true),
|
|
129
127
|
emittedText: "",
|
|
130
128
|
};
|
|
131
129
|
while (true) {
|
|
@@ -157,6 +155,12 @@ function emitCursorLiveQueuedEvent(
|
|
|
157
155
|
turn.emitter.appendThinkingDelta(event.text);
|
|
158
156
|
} else if (event.type === "thinking-completed") {
|
|
159
157
|
turn.emitter.closeThinking();
|
|
158
|
+
} else if (event.type === "text-completed") {
|
|
159
|
+
turn.emitter.completeTextMessage();
|
|
160
|
+
// Logical separators also keep suffix/prefix dedup aware of SDK messages,
|
|
161
|
+
// including boundaries queued across Pi tool-use turns.
|
|
162
|
+
if (turn.emittedText) turn.emittedText += CURSOR_TEXT_MESSAGE_SEPARATOR;
|
|
163
|
+
if (run?.emittedText) run.emittedText += CURSOR_TEXT_MESSAGE_SEPARATOR;
|
|
160
164
|
} else if (event.type === "text-delta") {
|
|
161
165
|
turn.emittedText += event.text;
|
|
162
166
|
if (run) run.emittedText += event.text;
|
|
@@ -296,7 +300,7 @@ export async function drainCursorLiveRunTurn(
|
|
|
296
300
|
context: Context,
|
|
297
301
|
run: CursorLiveRun,
|
|
298
302
|
toolResultInputTokens: number,
|
|
299
|
-
options: { mode: CursorLiveRunDrainMode; signal?: AbortSignal; debugRecorder?: CursorSdkEventDebugRecorder },
|
|
303
|
+
options: { mode: CursorLiveRunDrainMode; signal?: AbortSignal; debugRecorder?: CursorSdkEventDebugRecorder; emitter?: CursorPartialContentEmitter },
|
|
300
304
|
): Promise<CursorLiveRunDrainOutcome> {
|
|
301
305
|
const debugRecorder = options.debugRecorder ?? run.debugRecorder;
|
|
302
306
|
debugRecorder?.recordDrainEvent("turn_start", {
|
|
@@ -308,7 +312,7 @@ export async function drainCursorLiveRunTurn(
|
|
|
308
312
|
let outcome: CursorLiveRunDrainOutcome | undefined;
|
|
309
313
|
let outcomeDetails: Record<string, unknown> = {};
|
|
310
314
|
const turn: CursorLiveTurnState = {
|
|
311
|
-
emitter: new CursorPartialContentEmitter(stream, partial, -1, true),
|
|
315
|
+
emitter: options.emitter ?? new CursorPartialContentEmitter(stream, partial, -1, true),
|
|
312
316
|
emittedText: "",
|
|
313
317
|
};
|
|
314
318
|
|
|
@@ -378,7 +382,7 @@ export async function drainCursorLiveRunTurn(
|
|
|
378
382
|
turn.emitter.closeAll();
|
|
379
383
|
const finalText = trimCurrentTurnAlreadyEmittedCursorText(run.finalText ?? run.textDeltas.join(""), turn.emittedText, run.emittedText);
|
|
380
384
|
if (finalText) {
|
|
381
|
-
await emitTextDeltas(
|
|
385
|
+
await emitTextDeltas(turn.emitter, splitTextIntoReplayDeltas(finalText));
|
|
382
386
|
}
|
|
383
387
|
applyCursorUsage(partial, model, context, cursorLiveRuns.takeTurnInputTokens(run, toolResultInputTokens), {
|
|
384
388
|
runtime: "local",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AssistantMessage, AssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { InteractionUpdate } from "@cursor/sdk";
|
|
3
|
+
import { CURSOR_TEXT_MESSAGE_SEPARATOR } from "./cursor-partial-content-emitter.js";
|
|
3
4
|
import type { CursorLiveRun } from "./cursor-live-run-coordinator.js";
|
|
4
5
|
import { cursorLiveRuns } from "./cursor-provider-live-run-drain.js";
|
|
5
6
|
import { truncateCursorDisplayLine } from "./cursor-display-text.js";
|
|
@@ -65,6 +66,7 @@ export class CursorSdkTurnCoordinator {
|
|
|
65
66
|
private readonly lifecycleEmitter: CursorToolLifecycleEmitter;
|
|
66
67
|
private readonly contentEmitter;
|
|
67
68
|
private sdkTurnUsage?: CursorSdkTurnUsage;
|
|
69
|
+
private hasPendingTextMessage = false;
|
|
68
70
|
|
|
69
71
|
constructor(options: CursorSdkTurnCoordinatorOptions) {
|
|
70
72
|
this.stream = options.stream;
|
|
@@ -153,6 +155,7 @@ export class CursorSdkTurnCoordinator {
|
|
|
153
155
|
cursorLiveRuns.recordSdkTurnEnded(this.liveRun, sdkTurnUsage);
|
|
154
156
|
}
|
|
155
157
|
if (update.type === "text-delta") {
|
|
158
|
+
if (update.text) this.hasPendingTextMessage = true;
|
|
156
159
|
this.textDeltas.push(update.text);
|
|
157
160
|
if (this.liveRun) {
|
|
158
161
|
cursorLiveRuns.queueEvent(this.liveRun, { type: "text-delta", text: update.text });
|
|
@@ -247,6 +250,18 @@ export class CursorSdkTurnCoordinator {
|
|
|
247
250
|
|
|
248
251
|
handleStep(stepEnvelope: unknown): void {
|
|
249
252
|
const stepType = getField(stepEnvelope, "type");
|
|
253
|
+
if (stepType === "assistantMessage") {
|
|
254
|
+
// SDK onStep completes a message; onDelta chunks within it are tokens,
|
|
255
|
+
// not separate messages. Keep the boundary in both output and final-text
|
|
256
|
+
// deduplication, without replaying the completed step's text a second time.
|
|
257
|
+
if (this.hasPendingTextMessage) {
|
|
258
|
+
this.hasPendingTextMessage = false;
|
|
259
|
+
this.textDeltas.push(CURSOR_TEXT_MESSAGE_SEPARATOR);
|
|
260
|
+
if (this.liveRun) cursorLiveRuns.queueEvent(this.liveRun, { type: "text-completed" });
|
|
261
|
+
else this.contentEmitter.completeTextMessage();
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
250
265
|
const step = getField(stepEnvelope, "message") ? stepEnvelope : undefined;
|
|
251
266
|
const rawStepToolCall = getField(step, "message");
|
|
252
267
|
if (stepType !== "toolCall") return;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CursorLiveRunAbortError } from "./cursor-live-run-coordinator.js";
|
|
2
|
+
import { CursorPartialContentEmitter } from "./cursor-partial-content-emitter.js";
|
|
2
3
|
import {
|
|
3
4
|
cursorLiveRuns,
|
|
4
5
|
drainCursorLiveRunTurn,
|
|
@@ -28,6 +29,9 @@ export async function emitCursorLiveTurn(emitParams: EmitCursorLiveTurnParams):
|
|
|
28
29
|
const { liveRun, turnCoordinator } = prepared.runtime;
|
|
29
30
|
|
|
30
31
|
const { options, model } = params;
|
|
32
|
+
// Drain and abort recovery write to the same partial. Keep its open text block
|
|
33
|
+
// and deferred message boundary together; a new Pi turn still gets a new emitter.
|
|
34
|
+
const emitter = new CursorPartialContentEmitter(params.stream, params.partial, -1, true);
|
|
31
35
|
try {
|
|
32
36
|
await cursorLiveRuns.withRunLease(liveRun, options?.signal, async () => {
|
|
33
37
|
await cursorLiveRuns.waitForProgress(liveRun, options?.signal);
|
|
@@ -35,6 +39,7 @@ export async function emitCursorLiveTurn(emitParams: EmitCursorLiveTurnParams):
|
|
|
35
39
|
turnCoordinator.closeTraceBlock();
|
|
36
40
|
await drainCursorLiveRunTurn(params.stream, params.partial, model, params.context, liveRun, 0, {
|
|
37
41
|
mode: "emit",
|
|
42
|
+
emitter,
|
|
38
43
|
signal: options?.signal,
|
|
39
44
|
debugRecorder: sdkEventDebug,
|
|
40
45
|
});
|
|
@@ -45,6 +50,7 @@ export async function emitCursorLiveTurn(emitParams: EmitCursorLiveTurnParams):
|
|
|
45
50
|
turnCoordinator.closeTraceBlock();
|
|
46
51
|
flushPendingCursorLiveRunTraceEventsToStream(params.stream, params.partial, liveRun, {
|
|
47
52
|
includeTracesBehindQueuedTools: true,
|
|
53
|
+
emitter,
|
|
48
54
|
});
|
|
49
55
|
}
|
|
50
56
|
throw caught;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Context, SimpleStreamOptions } from "@earendil-works/pi-ai";
|
|
2
|
+
import { getCursorConversationMessages, resolveCursorPiContext } from "./cursor-pi-context.js";
|
|
2
3
|
import type { AgentModeOption, ModelSelection, SDKAgent } from "@cursor/sdk";
|
|
3
4
|
import { configureCursorSdkHttp1 } from "./cursor-http1.js";
|
|
4
5
|
import { installCursorMcpToolTimeoutOverride } from "./cursor-mcp-timeout-override.js";
|
|
@@ -80,11 +81,15 @@ interface PrepareCursorProviderTurnContext extends PrepareCursorProviderTurnPara
|
|
|
80
81
|
|
|
81
82
|
function buildCursorCloudPromptContext(context: Context, handoff: "fresh" | "bootstrap" | "never"): Context {
|
|
82
83
|
if (handoff === "bootstrap") return context;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
// Fresh cloud runs omit history, not the current Pi instructions. Replay before
|
|
85
|
+
// selecting the user message, otherwise transcript-only inputs lose the prompt.
|
|
86
|
+
const current = resolveCursorPiContext(context);
|
|
87
|
+
const messages = getCursorConversationMessages(context);
|
|
88
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
89
|
+
const message = messages[index];
|
|
90
|
+
if (message.role === "user") return { ...current, messages: [message] };
|
|
86
91
|
}
|
|
87
|
-
return { ...
|
|
92
|
+
return { ...current, messages: messages.slice(-1) };
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
const CLOUD_SEND_PLAN: CursorSessionSendPlan = { mode: "bootstrap", resetAgent: false, reason: "initial" };
|
|
@@ -135,7 +140,7 @@ async function prepareCursorCloudProviderTurn(
|
|
|
135
140
|
repo: resolvedConfig.cloud.repo.value,
|
|
136
141
|
branch: resolvedConfig.cloud.branch.value,
|
|
137
142
|
}),
|
|
138
|
-
hasPriorContext: context.
|
|
143
|
+
hasPriorContext: getCursorConversationMessages(context).length > 1,
|
|
139
144
|
});
|
|
140
145
|
if (!preflight.ok) throw new Error(formatCursorCloudPreflightError(preflight));
|
|
141
146
|
if (getPendingCursorLiveRun(context) || getActiveCursorLiveRunForCurrentScope()) {
|
package/src/cursor-skill-tool.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { resolveEffectiveCursorConfigForContext } from "./cursor-runtime-state.j
|
|
|
18
18
|
export const CURSOR_ACTIVATE_SKILL_TOOL_NAME = "cursor_activate_skill";
|
|
19
19
|
export const CURSOR_ACTIVATE_SKILL_MCP_NAME = "pi__cursor_activate_skill";
|
|
20
20
|
|
|
21
|
-
const AVAILABLE_SKILLS_SECTION_PATTERN =
|
|
21
|
+
const AVAILABLE_SKILLS_SECTION_PATTERN = /<skills>\nThe following skills provide specialized instructions for specific tasks\.[\s\S]*?<\/available_skills>\n<\/skills>|\n\nThe following skills provide specialized instructions for specific tasks\.[\s\S]*?<\/available_skills>/;
|
|
22
22
|
const MAX_SKILL_RESOURCES = 80;
|
|
23
23
|
const RESOURCE_DIR_NAMES = ["scripts", "references", "assets"] as const;
|
|
24
24
|
|