pi-cursor-sdk 0.3.6 → 0.3.8
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 +25 -0
- package/README.md +16 -5
- 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 +19 -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-record-utils.js +19 -0
- package/dist/cursor-skill-tool.js +1 -1
- package/docs/cursor-dogfood-checklist.md +1 -1
- package/docs/cursor-live-smoke-checklist.md +1 -1
- package/docs/cursor-model-ux-spec.md +2 -0
- package/docs/cursor-testing-lessons.md +25 -1
- package/docs/platform-smoke-implementation.md +2 -0
- package/docs/platform-smoke.md +1 -1
- package/package.json +2 -2
- package/scripts/build.mjs +2 -1
- package/scripts/lib/local-resume-smoke-harness.mjs +1 -6
- package/scripts/local-resume-smoke.mjs +43 -4
- package/scripts/platform-smoke/card-detect.mjs +2 -4
- package/scripts/platform-smoke/live-suite-runner.mjs +5 -7
- package/scripts/platform-smoke/pty-capture.mjs +3 -10
- package/scripts/platform-smoke/render-ansi.mjs +2 -5
- 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 +16 -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-record-utils.ts +19 -0
- package/src/cursor-skill-tool.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.8 - 2026-09-19
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Narrow SDK replay and MCP bridge arguments to JSON objects before emitting Pi tool calls, preserving valid payloads without serialization or casts. This supports Pi's tightened transcript types and reports non-JSON arguments before dispatch.
|
|
8
|
+
- Compile source and test fixtures against published Pi 0.85.1, pinned official source, and the fork. Native packed-host checks require common provider, replay, and runtime behavior on every host. The fork additionally qualifies legacy `SystemMessage.replace` checkpoints; current official Pi removed that host feature, which is not required by this extension.
|
|
9
|
+
- Load the already-shipped TypeScript source graph through Pi's native loader so provider streams and replay helpers remain host-owned even with local Pi peer packages installed. Keep compiled output for scripts and programmatic consumers.
|
|
10
|
+
- Preserve hyperlink text and split terminal control sequences when checking platform-smoke read cards, using Node's terminal-control stripping rather than a custom regular expression.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Native source loading fixes host peer identity at the cost of more initial startup/load work. On macOS/Node 24.21.0/Pi fork `90e6`, three fresh-process, warm-cache loader runs measured 0.85–1.24 s for source versus 0.08–0.13 s for dist; these are not TUI startup timings or guarantees for other environments or repeated `/reload`.
|
|
15
|
+
- **Filter migration required:** update old dist-targeting extension filters to preserve include/exclude intent (`+dist/index.js` → `+src/index.ts`, `-dist/index.js` → `-src/index.ts`, `!dist/**` → `!src/**`). The old exclusion alone no longer disables Cursor; retain `-dist/index.js` alongside `-src/index.ts` for rollback. See README's “Existing extension filters”; settings are not rewritten automatically.
|
|
16
|
+
|
|
17
|
+
## 0.3.7 - 2026-09-16
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- 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.
|
|
22
|
+
|
|
23
|
+
- 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.
|
|
24
|
+
- Fingerprint replayed instructions and tool definitions plus individual system-message content/section/tool deltas, so unchanged transcripts stay incremental and changed context reboots correctly.
|
|
25
|
+
- Preserve current Pi instructions in fresh cloud prompts, and ignore system updates when finding pending replay/bridge tool results and steering input.
|
|
26
|
+
- 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.
|
|
27
|
+
|
|
3
28
|
## 0.3.6 - 2026-08-18
|
|
4
29
|
|
|
5
30
|
### 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
|
|
|
@@ -70,6 +70,15 @@ Alternative GitHub install:
|
|
|
70
70
|
pi install https://github.com/fitchmultz/pi-cursor-sdk
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
+
### Existing extension filters
|
|
74
|
+
|
|
75
|
+
The Pi entrypoint is now `src/index.ts`. Update any filters in this package's `extensions` setting that target the old `dist/` entry, preserving their include/exclude intent:
|
|
76
|
+
|
|
77
|
+
- Exact filters: `+dist/index.js` → `+src/index.ts`; `-dist/index.js` → `-src/index.ts`.
|
|
78
|
+
- Glob filters: for example, `dist/**` → `src/**` and `!dist/**` → `!src/**`. Adjust other old-dist patterns to match the new entrypoint with the same intent.
|
|
79
|
+
|
|
80
|
+
To keep Cursor disabled across upgrades and rollback, retain `-dist/index.js` and add `-src/index.ts`; the old exclusion alone no longer disables Cursor. The package does not rewrite user or project settings.
|
|
81
|
+
|
|
73
82
|
### Project-local install
|
|
74
83
|
|
|
75
84
|
Use `-l` if you want the package recorded in the current project's `.pi/settings.json` instead of your global pi settings:
|
|
@@ -91,11 +100,13 @@ Without `--approve`, the project-local extension still runs after Pi trusts the
|
|
|
91
100
|
For development from this repository:
|
|
92
101
|
|
|
93
102
|
```bash
|
|
94
|
-
npm install # runs prepare
|
|
103
|
+
npm install # runs prepare to build dist/ for scripts and programmatic consumers
|
|
95
104
|
pi --approve -e . --model cursor/grok-4.6
|
|
96
105
|
```
|
|
97
106
|
|
|
98
|
-
After editing `src/`, run `npm run build`
|
|
107
|
+
Pi loads the packaged `src/index.ts` graph through its native TypeScript loader. This keeps Pi imports bound to the running host even when another Pi version is installed alongside the extension. `dist/` remains shipped for scripts and programmatic consumers. After editing `src/`, run `npm run build` for validation and compiled consumers, then restart Pi.
|
|
108
|
+
|
|
109
|
+
The host-identity fix adds initial startup/load work: three fresh-process loader measurements on macOS with Node 24.21.0, Pi fork `90e6`, a warm Jiti disk cache, and network denied took 0.85–1.24 s for source versus 0.08–0.13 s for dist. These are environment-specific loader timings, not full TUI startup measurements or timing guarantees. That host caches extension factories for the same cwd; the initial-load cost does not describe every in-process `/reload`.
|
|
99
110
|
|
|
100
111
|
## Configure your Cursor SDK API key
|
|
101
112
|
|
|
@@ -708,7 +719,7 @@ npm install
|
|
|
708
719
|
CURSOR_API_KEY="your-key" pi --approve -e . --model cursor/grok-4.6
|
|
709
720
|
```
|
|
710
721
|
|
|
711
|
-
After editing `src/`, run `npm run build`
|
|
722
|
+
After editing `src/`, run `npm run build` for validation and compiled consumers, then restart Pi to load the changed source graph.
|
|
712
723
|
|
|
713
724
|
Maintainer design notes live in [`docs/cursor-model-ux-spec.md`](docs/cursor-model-ux-spec.md).
|
|
714
725
|
|
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,12 +3,13 @@ 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";
|
|
10
10
|
import { formatInactiveCursorReplayTrace } from "./cursor-native-replay-trace.js";
|
|
11
11
|
import { partitionNativeToolsByActiveContext } from "./cursor-native-replay-routing.js";
|
|
12
|
+
import { isJsonObject } from "./cursor-record-utils.js";
|
|
12
13
|
export const DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS = 5 * 60 * 1000;
|
|
13
14
|
const CURSOR_NATIVE_REPLAY_TOOL_ID_PATTERN = /^(cursor-replay-\d+-\d+)-tool-\d+$/;
|
|
14
15
|
let cursorNativeReplayIdleDisposeMs = DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS;
|
|
@@ -50,8 +51,7 @@ function splitTextIntoReplayDeltas(text) {
|
|
|
50
51
|
}
|
|
51
52
|
return deltas;
|
|
52
53
|
}
|
|
53
|
-
async function emitTextDeltas(
|
|
54
|
-
const emitter = new CursorPartialContentEmitter(stream, partial, -1, true);
|
|
54
|
+
async function emitTextDeltas(emitter, deltas) {
|
|
55
55
|
for (const delta of deltas) {
|
|
56
56
|
emitter.appendTextDelta(delta);
|
|
57
57
|
await Promise.resolve();
|
|
@@ -80,7 +80,7 @@ export function flushPendingCursorLiveRunTraceEventsToStream(stream, partial, ru
|
|
|
80
80
|
if (run.disposed)
|
|
81
81
|
return;
|
|
82
82
|
const turn = {
|
|
83
|
-
emitter: new CursorPartialContentEmitter(stream, partial, -1, true),
|
|
83
|
+
emitter: options?.emitter ?? new CursorPartialContentEmitter(stream, partial, -1, true),
|
|
84
84
|
emittedText: "",
|
|
85
85
|
};
|
|
86
86
|
while (true) {
|
|
@@ -110,6 +110,15 @@ function emitCursorLiveQueuedEvent(turn, event, run) {
|
|
|
110
110
|
else if (event.type === "thinking-completed") {
|
|
111
111
|
turn.emitter.closeThinking();
|
|
112
112
|
}
|
|
113
|
+
else if (event.type === "text-completed") {
|
|
114
|
+
turn.emitter.completeTextMessage();
|
|
115
|
+
// Logical separators also keep suffix/prefix dedup aware of SDK messages,
|
|
116
|
+
// including boundaries queued across Pi tool-use turns.
|
|
117
|
+
if (turn.emittedText)
|
|
118
|
+
turn.emittedText += CURSOR_TEXT_MESSAGE_SEPARATOR;
|
|
119
|
+
if (run?.emittedText)
|
|
120
|
+
run.emittedText += CURSOR_TEXT_MESSAGE_SEPARATOR;
|
|
121
|
+
}
|
|
113
122
|
else if (event.type === "text-delta") {
|
|
114
123
|
turn.emittedText += event.text;
|
|
115
124
|
if (run)
|
|
@@ -120,6 +129,8 @@ function emitCursorLiveQueuedEvent(turn, event, run) {
|
|
|
120
129
|
function emitCursorNativeToolUseTurn(stream, partial, model, context, run, toolResultInputTokens, tools, debugRecorder) {
|
|
121
130
|
const shouldTerminate = run.done && !run.finalText?.trim() && !cursorLiveRuns.peekEvent(run);
|
|
122
131
|
for (const tool of tools) {
|
|
132
|
+
if (!isJsonObject(tool.args))
|
|
133
|
+
throw new Error("Cursor replay tool arguments must be a JSON object");
|
|
123
134
|
const contentIndex = partial.content.length;
|
|
124
135
|
partial.content.push({
|
|
125
136
|
type: "toolCall",
|
|
@@ -164,6 +175,8 @@ function emitInactiveCursorReplayTrace(turn, tools, debugRecorder) {
|
|
|
164
175
|
}
|
|
165
176
|
function emitCursorBridgeToolUseTurn(stream, partial, model, context, run, toolResultInputTokens, requests) {
|
|
166
177
|
for (const request of requests) {
|
|
178
|
+
if (!isJsonObject(request.args))
|
|
179
|
+
throw new Error("Cursor bridge tool arguments must be a JSON object");
|
|
167
180
|
const contentIndex = partial.content.length;
|
|
168
181
|
partial.content.push({
|
|
169
182
|
type: "toolCall",
|
|
@@ -231,7 +244,7 @@ export async function drainCursorLiveRunTurn(stream, partial, model, context, ru
|
|
|
231
244
|
let outcome;
|
|
232
245
|
let outcomeDetails = {};
|
|
233
246
|
const turn = {
|
|
234
|
-
emitter: new CursorPartialContentEmitter(stream, partial, -1, true),
|
|
247
|
+
emitter: options.emitter ?? new CursorPartialContentEmitter(stream, partial, -1, true),
|
|
235
248
|
emittedText: "",
|
|
236
249
|
};
|
|
237
250
|
try {
|
|
@@ -291,7 +304,7 @@ export async function drainCursorLiveRunTurn(stream, partial, model, context, ru
|
|
|
291
304
|
turn.emitter.closeAll();
|
|
292
305
|
const finalText = trimCurrentTurnAlreadyEmittedCursorText(run.finalText ?? run.textDeltas.join(""), turn.emittedText, run.emittedText);
|
|
293
306
|
if (finalText) {
|
|
294
|
-
await emitTextDeltas(
|
|
307
|
+
await emitTextDeltas(turn.emitter, splitTextIntoReplayDeltas(finalText));
|
|
295
308
|
}
|
|
296
309
|
applyCursorUsage(partial, model, context, cursorLiveRuns.takeTurnInputTokens(run, toolResultInputTokens), {
|
|
297
310
|
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));
|
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
/** Narrow untyped SDK/MCP arguments at the Pi transcript boundary without rewriting them. */
|
|
2
|
+
export function isJsonObject(value) {
|
|
3
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
4
|
+
&& isJsonValue(value, new Set());
|
|
5
|
+
}
|
|
6
|
+
function isJsonValue(value, ancestors) {
|
|
7
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
8
|
+
return true;
|
|
9
|
+
if (typeof value === "number")
|
|
10
|
+
return Number.isFinite(value);
|
|
11
|
+
if (typeof value !== "object" || ancestors.has(value))
|
|
12
|
+
return false;
|
|
13
|
+
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
|
|
14
|
+
return false;
|
|
15
|
+
ancestors.add(value);
|
|
16
|
+
const valid = Object.values(value).every(entry => isJsonValue(entry, ancestors));
|
|
17
|
+
ancestors.delete(value);
|
|
18
|
+
return valid;
|
|
19
|
+
}
|
|
1
20
|
export function asRecord(value) {
|
|
2
21
|
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
3
22
|
}
|
|
@@ -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();
|
|
@@ -4,7 +4,7 @@ Short maintainer checklist for **minimal-surface** validation after prompt, brid
|
|
|
4
4
|
|
|
5
5
|
## Minimal environment
|
|
6
6
|
|
|
7
|
-
- Build first after any `src/` edit: `npm run build` (
|
|
7
|
+
- Build first after any `src/` edit: `npm run build` (validation and compiled-script consumers); restart Pi to load the changed `src/index.ts` manifest graph
|
|
8
8
|
- Extension only: `pi --approve -e . --cursor-no-fast --model cursor/grok-4.6`
|
|
9
9
|
- Fresh session dir: `--session-dir /tmp/pi-cursor-dogfood-<id>`
|
|
10
10
|
- Baseline surface (no ambient Cursor MCP/rules):
|
|
@@ -8,7 +8,7 @@ Use this manual checklist during development and debugging of Cursor provider/ru
|
|
|
8
8
|
|
|
9
9
|
## Inner-loop rule
|
|
10
10
|
|
|
11
|
-
- Build first: `npm run build` after any `src/` edit
|
|
11
|
+
- Build first: `npm run build` after any `src/` edit validates emit and updates `dist/` for scripts/programmatic consumers. Pi loads the packaged `src/index.ts` graph; restart Pi after source changes. The cloud/steering/local-resume/provider-debug launchers rebuild automatically even when run directly with `node scripts/...`; `smoke:live`/`smoke:visual`/`smoke:isolated` build via their npm scripts. Packed-install gates must exercise the tarball's actual manifest, not a checkout path.
|
|
12
12
|
- Run from a clean working tree except for the intended branch diff.
|
|
13
13
|
- Use the local extension under test: `pi --approve -e . --cursor-no-fast --model cursor/grok-4.6`.
|
|
14
14
|
- Use a temporary `--session-dir` for every run.
|
|
@@ -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.
|