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.
Files changed (40) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +16 -5
  3. package/dist/context.js +19 -4
  4. package/dist/cursor-agents-context.js +22 -8
  5. package/dist/cursor-context-tools.js +3 -1
  6. package/dist/cursor-live-run-coordinator.js +9 -6
  7. package/dist/cursor-partial-content-emitter.js +12 -0
  8. package/dist/cursor-pi-context.js +30 -0
  9. package/dist/cursor-provider-live-run-drain.js +19 -6
  10. package/dist/cursor-provider-turn-coordinator.js +18 -0
  11. package/dist/cursor-provider-turn-emit.js +6 -0
  12. package/dist/cursor-provider-turn-prepare.js +10 -5
  13. package/dist/cursor-record-utils.js +19 -0
  14. package/dist/cursor-skill-tool.js +1 -1
  15. package/docs/cursor-dogfood-checklist.md +1 -1
  16. package/docs/cursor-live-smoke-checklist.md +1 -1
  17. package/docs/cursor-model-ux-spec.md +2 -0
  18. package/docs/cursor-testing-lessons.md +25 -1
  19. package/docs/platform-smoke-implementation.md +2 -0
  20. package/docs/platform-smoke.md +1 -1
  21. package/package.json +2 -2
  22. package/scripts/build.mjs +2 -1
  23. package/scripts/lib/local-resume-smoke-harness.mjs +1 -6
  24. package/scripts/local-resume-smoke.mjs +43 -4
  25. package/scripts/platform-smoke/card-detect.mjs +2 -4
  26. package/scripts/platform-smoke/live-suite-runner.mjs +5 -7
  27. package/scripts/platform-smoke/pty-capture.mjs +3 -10
  28. package/scripts/platform-smoke/render-ansi.mjs +2 -5
  29. package/src/context.ts +27 -4
  30. package/src/cursor-agents-context.ts +24 -8
  31. package/src/cursor-context-tools.ts +3 -1
  32. package/src/cursor-live-run-coordinator.ts +10 -6
  33. package/src/cursor-partial-content-emitter.ts +13 -0
  34. package/src/cursor-pi-context.ts +44 -0
  35. package/src/cursor-provider-live-run-drain.ts +16 -9
  36. package/src/cursor-provider-turn-coordinator.ts +15 -0
  37. package/src/cursor-provider-turn-emit.ts +6 -0
  38. package/src/cursor-provider-turn-prepare.ts +10 -5
  39. package/src/cursor-record-utils.ts +19 -0
  40. package/src/cursor-skill-tool.ts +1 -1
@@ -21,13 +21,14 @@ 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";
28
28
  import { formatInactiveCursorReplayTrace } from "./cursor-native-replay-trace.js";
29
29
  import { partitionNativeToolsByActiveContext } from "./cursor-native-replay-routing.js";
30
30
  import type { CursorSdkEventDebugRecorder } from "./cursor-sdk-event-debug.js";
31
+ import { isJsonObject } from "./cursor-record-utils.js";
31
32
 
32
33
  export const DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS = 5 * 60 * 1000;
33
34
  const CURSOR_NATIVE_REPLAY_TOOL_ID_PATTERN = /^(cursor-replay-\d+-\d+)-tool-\d+$/;
@@ -88,11 +89,9 @@ function splitTextIntoReplayDeltas(text: string): string[] {
88
89
  }
89
90
 
90
91
  async function emitTextDeltas(
91
- stream: AssistantMessageEventStream,
92
- partial: AssistantMessage,
92
+ emitter: CursorPartialContentEmitter,
93
93
  deltas: string[],
94
94
  ): Promise<string> {
95
- const emitter = new CursorPartialContentEmitter(stream, partial, -1, true);
96
95
  for (const delta of deltas) {
97
96
  emitter.appendTextDelta(delta);
98
97
  await Promise.resolve();
@@ -121,11 +120,11 @@ export function flushPendingCursorLiveRunTraceEventsToStream(
121
120
  stream: AssistantMessageEventStream,
122
121
  partial: AssistantMessage,
123
122
  run: CursorLiveRun,
124
- options?: { includeTracesBehindQueuedTools?: boolean },
123
+ options?: { includeTracesBehindQueuedTools?: boolean; emitter?: CursorPartialContentEmitter },
125
124
  ): void {
126
125
  if (run.disposed) return;
127
126
  const turn: CursorLiveTurnState = {
128
- emitter: new CursorPartialContentEmitter(stream, partial, -1, true),
127
+ emitter: options?.emitter ?? new CursorPartialContentEmitter(stream, partial, -1, true),
129
128
  emittedText: "",
130
129
  };
131
130
  while (true) {
@@ -157,6 +156,12 @@ function emitCursorLiveQueuedEvent(
157
156
  turn.emitter.appendThinkingDelta(event.text);
158
157
  } else if (event.type === "thinking-completed") {
159
158
  turn.emitter.closeThinking();
159
+ } else if (event.type === "text-completed") {
160
+ turn.emitter.completeTextMessage();
161
+ // Logical separators also keep suffix/prefix dedup aware of SDK messages,
162
+ // including boundaries queued across Pi tool-use turns.
163
+ if (turn.emittedText) turn.emittedText += CURSOR_TEXT_MESSAGE_SEPARATOR;
164
+ if (run?.emittedText) run.emittedText += CURSOR_TEXT_MESSAGE_SEPARATOR;
160
165
  } else if (event.type === "text-delta") {
161
166
  turn.emittedText += event.text;
162
167
  if (run) run.emittedText += event.text;
@@ -176,6 +181,7 @@ function emitCursorNativeToolUseTurn(
176
181
  ): void {
177
182
  const shouldTerminate = run.done && !run.finalText?.trim() && !cursorLiveRuns.peekEvent(run);
178
183
  for (const tool of tools) {
184
+ if (!isJsonObject(tool.args)) throw new Error("Cursor replay tool arguments must be a JSON object");
179
185
  const contentIndex = partial.content.length;
180
186
  partial.content.push({
181
187
  type: "toolCall",
@@ -232,6 +238,7 @@ function emitCursorBridgeToolUseTurn(
232
238
  requests: CursorPiBridgeToolRequest[],
233
239
  ): void {
234
240
  for (const request of requests) {
241
+ if (!isJsonObject(request.args)) throw new Error("Cursor bridge tool arguments must be a JSON object");
235
242
  const contentIndex = partial.content.length;
236
243
  partial.content.push({
237
244
  type: "toolCall",
@@ -296,7 +303,7 @@ export async function drainCursorLiveRunTurn(
296
303
  context: Context,
297
304
  run: CursorLiveRun,
298
305
  toolResultInputTokens: number,
299
- options: { mode: CursorLiveRunDrainMode; signal?: AbortSignal; debugRecorder?: CursorSdkEventDebugRecorder },
306
+ options: { mode: CursorLiveRunDrainMode; signal?: AbortSignal; debugRecorder?: CursorSdkEventDebugRecorder; emitter?: CursorPartialContentEmitter },
300
307
  ): Promise<CursorLiveRunDrainOutcome> {
301
308
  const debugRecorder = options.debugRecorder ?? run.debugRecorder;
302
309
  debugRecorder?.recordDrainEvent("turn_start", {
@@ -308,7 +315,7 @@ export async function drainCursorLiveRunTurn(
308
315
  let outcome: CursorLiveRunDrainOutcome | undefined;
309
316
  let outcomeDetails: Record<string, unknown> = {};
310
317
  const turn: CursorLiveTurnState = {
311
- emitter: new CursorPartialContentEmitter(stream, partial, -1, true),
318
+ emitter: options.emitter ?? new CursorPartialContentEmitter(stream, partial, -1, true),
312
319
  emittedText: "",
313
320
  };
314
321
 
@@ -378,7 +385,7 @@ export async function drainCursorLiveRunTurn(
378
385
  turn.emitter.closeAll();
379
386
  const finalText = trimCurrentTurnAlreadyEmittedCursorText(run.finalText ?? run.textDeltas.join(""), turn.emittedText, run.emittedText);
380
387
  if (finalText) {
381
- await emitTextDeltas(stream, partial, splitTextIntoReplayDeltas(finalText));
388
+ await emitTextDeltas(turn.emitter, splitTextIntoReplayDeltas(finalText));
382
389
  }
383
390
  applyCursorUsage(partial, model, context, cursorLiveRuns.takeTurnInputTokens(run, toolResultInputTokens), {
384
391
  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
- for (let index = context.messages.length - 1; index >= 0; index -= 1) {
84
- const message = context.messages[index];
85
- if (message.role === "user") return { ...context, messages: [message] };
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 { ...context, messages: context.messages.slice(-1) };
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.messages.length > 1,
143
+ hasPriorContext: getCursorConversationMessages(context).length > 1,
139
144
  });
140
145
  if (!preflight.ok) throw new Error(formatCursorCloudPreflightError(preflight));
141
146
  if (getPendingCursorLiveRun(context) || getActiveCursorLiveRunForCurrentScope()) {
@@ -1,3 +1,22 @@
1
+ import type { JsonValue } from "@earendil-works/pi-ai";
2
+
3
+ /** Narrow untyped SDK/MCP arguments at the Pi transcript boundary without rewriting them. */
4
+ export function isJsonObject(value: unknown): value is Record<string, JsonValue> {
5
+ return value !== null && typeof value === "object" && !Array.isArray(value)
6
+ && isJsonValue(value, new Set());
7
+ }
8
+
9
+ function isJsonValue(value: unknown, ancestors: Set<object>): boolean {
10
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
11
+ if (typeof value === "number") return Number.isFinite(value);
12
+ if (typeof value !== "object" || ancestors.has(value)) return false;
13
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
14
+ ancestors.add(value);
15
+ const valid = Object.values(value).every(entry => isJsonValue(entry, ancestors));
16
+ ancestors.delete(value);
17
+ return valid;
18
+ }
19
+
1
20
  export function asRecord(value: unknown): Record<string, unknown> | undefined {
2
21
  return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
3
22
  }
@@ -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 = /\n\nThe following skills provide specialized instructions for specific tasks\.[\s\S]*?<\/available_skills>/;
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