pi-cursor-sdk 0.1.8 → 0.1.9
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 +18 -0
- package/README.md +2 -2
- package/docs/cursor-model-ux-spec.md +18 -36
- package/package.json +1 -1
- package/src/context-window-cache.ts +6 -0
- package/src/cursor-native-tool-display.ts +8 -2
- package/src/cursor-provider.ts +105 -15
- package/src/cursor-state.ts +10 -1
- package/src/cursor-tool-transcript.ts +9 -6
- package/src/model-discovery.ts +58 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.9 - 2026-05-14
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Clean up recorded native Cursor tool replay outputs when abandoned replay runs are disposed, avoiding retained file or command output in process memory.
|
|
8
|
+
- Restore `/cursor-fast` state when session persistence fails during command handling.
|
|
9
|
+
- Preserve distinct same-payload Cursor tool completions while deduplicating duplicate SDK completion surfaces.
|
|
10
|
+
- Respect exact `model@context` context-window cache overrides before falling back to parsed base-model context values.
|
|
11
|
+
- Emit native replay text block endings with saved content indexes instead of searching by object identity.
|
|
12
|
+
- Redact discovery failure details with the same secret patterns used for stream errors.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Update fallback Sonnet 4.6 context variants from `300k` to the current `200k` catalog variant.
|
|
17
|
+
- Skip ambiguous Cursor SDK aliases shared by multiple base models or colliding with base model IDs, preventing misleading pi model rows.
|
|
18
|
+
- Reduce context-window cache reloads during model catalog registration.
|
|
19
|
+
- Document image carry-forward as a product decision rather than silently changing current latest-user-message image forwarding behavior.
|
|
20
|
+
|
|
3
21
|
## 0.1.8 - 2026-05-14
|
|
4
22
|
|
|
5
23
|
### Changed
|
package/README.md
CHANGED
|
@@ -137,7 +137,7 @@ How to read model IDs:
|
|
|
137
137
|
- `cursor/...` is the Cursor provider registered by this extension
|
|
138
138
|
- `@1m`, `@272k`, and `@300k` are context-window variants
|
|
139
139
|
- `:medium`, `:high`, and `:xhigh` are pi thinking-level suffixes for models where the Cursor SDK exposes a pi-controllable thinking parameter
|
|
140
|
-
- latest-style Cursor aliases returned by `Cursor.models.list()` are registered too, using the same context suffixes when the target model has context variants
|
|
140
|
+
- unambiguous latest-style Cursor aliases returned by `Cursor.models.list()` are registered too, using the same context suffixes when the target model has context variants; aliases shared by multiple base models or colliding with a base model ID are skipped because their SDK resolution and displayed metadata can diverge
|
|
141
141
|
|
|
142
142
|
Examples with pi thinking controls:
|
|
143
143
|
|
|
@@ -203,7 +203,7 @@ If no key is available from `/login`, `CURSOR_API_KEY`, or `--api-key`, model di
|
|
|
203
203
|
|
|
204
204
|
- `composer-2`
|
|
205
205
|
- `gpt-5.5@1m`, `gpt-5.5@272k`
|
|
206
|
-
- `claude-sonnet-4-6@1m`, `claude-sonnet-4-6@
|
|
206
|
+
- `claude-sonnet-4-6@1m`, `claude-sonnet-4-6@200k`
|
|
207
207
|
- `claude-opus-4-7@1m`, `claude-opus-4-7@300k`
|
|
208
208
|
|
|
209
209
|
Fallback models are a conservative startup model list. Actual Cursor runs still need a key from `/login`, `CURSOR_API_KEY`, or `--api-key`. If you add auth after startup, run `/reload` or restart pi to refresh the full live Cursor model catalog.
|
|
@@ -13,6 +13,7 @@ Current implementation notes:
|
|
|
13
13
|
- Cursor `fast` is extension state, not model identity.
|
|
14
14
|
- Cursor fast status uses `ctx.ui.setStatus()`; the default pi footer remains intact.
|
|
15
15
|
- Installed `@cursor/sdk` user messages accept images, and Cursor models are treated as image-capable; registered input metadata is `text` plus `image`.
|
|
16
|
+
- Product decision pending: image payload forwarding currently sends images only from the latest user message. If the latest user turn is plain text after an earlier image turn, the transcript keeps an `[image omitted from transcript]` placeholder but no image bytes are sent to Cursor. Changing this to carry images forward across turns requires a deliberate product decision about token cost, privacy, stale visual context, and expected multimodal follow-up behavior.
|
|
16
17
|
- `@cursor/sdk` is a package dependency of this extension; users should not need a global SDK install.
|
|
17
18
|
- Cursor auth uses pi-native API-key resolution for provider `cursor`: CLI `--api-key`, stored `~/.pi/agent/auth.json` API key from `/login`, then `CURSOR_API_KEY`. The extension config file stores only non-secret Cursor-only state such as fast defaults.
|
|
18
19
|
- Local agents do not pass `settingSources` by default because the current Cursor SDK writes setting/rule loading INFO logs directly to terminal output, which corrupts pi's TUI.
|
|
@@ -21,7 +22,7 @@ Current implementation notes:
|
|
|
21
22
|
- Cursor SDK usage events report cumulative internal agent/tool/cache work, not the replayable pi prompt context. The extension reports approximate prompt/output usage for pi context display and compaction decisions instead of copying raw Cursor SDK usage. When native replay splits one Cursor SDK run into multiple pi turns, prompt input is counted once for the run; later synthetic replay turns report `input: 0` and only their own output estimate.
|
|
22
23
|
- For models without a catalog `context` parameter, context windows are not hardcoded. The extension ships a bundled SDK-derived default/non-Max cache generated from `createAgentPlatform().checkpointStore.loadLatest(agentId).tokenDetails.maxTokens`. Successful runs can update a local override cache, but model discovery does not probe models at startup.
|
|
23
24
|
- Max Mode context windows are distinct from default/non-Max context windows. `@cursor/sdk` 1.0.13 documentation says the SDK may enable Max Mode automatically when a selected model requires it, but the public local-agent `ModelSelection` path still does not expose a manual Max Mode selector. Do not advertise Max Mode context windows unless the SDK catalog exposes an exact parameter/variant or the SDK public API adds a Max Mode selector that the extension actually sends.
|
|
24
|
-
- `@cursor/sdk` 1.0.13 adds latest-style `ModelListItem.aliases`. The extension registers
|
|
25
|
+
- `@cursor/sdk` 1.0.13 adds latest-style `ModelListItem.aliases`. The extension registers only unambiguous aliases as pi model IDs (with the same context suffixes when applicable) and sends the alias back in `ModelSelection.id`, while sharing Cursor-only state such as fast defaults with the underlying catalog `id`. Aliases shared by multiple base models, such as generic family aliases, are skipped because the pi row metadata would otherwise imply one base model while Cursor may resolve the alias to another.
|
|
25
26
|
|
|
26
27
|
## Goal
|
|
27
28
|
|
|
@@ -137,8 +138,9 @@ Register a `cursor` provider with `pi.registerProvider()`.
|
|
|
137
138
|
|
|
138
139
|
Rules:
|
|
139
140
|
|
|
140
|
-
- Register one pi model for each Cursor base model and SDK alias when there is no Cursor `context` parameter.
|
|
141
|
-
- Register one pi model per Cursor `context` value for each Cursor base model and SDK alias when the model exposes a `context` parameter.
|
|
141
|
+
- Register one pi model for each Cursor base model and each unambiguous SDK alias when there is no Cursor `context` parameter.
|
|
142
|
+
- Register one pi model per Cursor `context` value for each Cursor base model and each unambiguous SDK alias when the model exposes a `context` parameter.
|
|
143
|
+
- Skip SDK aliases that collide with another base model ID or are shared by multiple base models; those aliases can resolve differently from the pi row metadata.
|
|
142
144
|
- Do not encode `reasoning`, `effort`, `thinking`, or `fast` into pi model IDs.
|
|
143
145
|
- Prefer stable, readable `@<context>` suffixes that do not conflict with pi's final `:<thinking>` suffix parser.
|
|
144
146
|
- Sort Cursor models by base ID, then context value in Cursor SDK order before calling `pi.registerProvider()`. Registration order matters for `/model` display and model cycling; `--list-models` sorts output separately.
|
|
@@ -486,42 +488,22 @@ Fast flag example:
|
|
|
486
488
|
pi --model cursor/gpt-5.5@1m --cursor-fast -p "Say ok only"
|
|
487
489
|
```
|
|
488
490
|
|
|
489
|
-
##
|
|
491
|
+
## Discovered Model Capability Examples
|
|
490
492
|
|
|
491
|
-
|
|
493
|
+
These examples document the capability shapes the extension handles, not an exhaustive live catalog. The exact Cursor catalog changes over time; use `pi -e . --list-models cursor` or `Cursor.models.list()` for the current model surface. When the SDK reports aliases, only unambiguous aliases are registered; shared generic aliases are skipped.
|
|
492
494
|
|
|
493
|
-
|
|
|
495
|
+
| Example model shape | Cursor controls | Pi representation |
|
|
494
496
|
|---|---|---|
|
|
495
|
-
| `default` | none | plain model |
|
|
496
|
-
| `composer-2
|
|
497
|
-
|
|
|
498
|
-
|
|
|
499
|
-
|
|
|
500
|
-
|
|
|
501
|
-
|
|
|
502
|
-
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
| `gpt-5.2-codex` | reasoning, fast | plain model + native thinking + fast state |
|
|
506
|
-
| `gpt-5.1-codex-max` | reasoning, fast | plain model + native thinking + fast state |
|
|
507
|
-
| `gpt-5.1-codex-mini` | reasoning | plain model + native thinking |
|
|
508
|
-
| `gpt-5.1` | reasoning | plain model + native thinking |
|
|
509
|
-
| `claude-opus-4-7` | thinking, context, effort | context variants + native thinking |
|
|
510
|
-
| `claude-opus-4-6` | thinking, context, effort, fast | context variants + native thinking + fast state |
|
|
511
|
-
| `claude-opus-4-5` | thinking | plain model + native thinking |
|
|
512
|
-
| `claude-sonnet-4-6` | thinking, context, effort | context variants + native thinking |
|
|
513
|
-
| `claude-sonnet-4-5` | thinking, context | context-qualified model + native thinking |
|
|
514
|
-
| `claude-sonnet-4` | thinking, context | context-qualified model + native thinking |
|
|
515
|
-
| `claude-haiku-4-5` | thinking | plain model + native thinking |
|
|
516
|
-
| `grok-4.3` | context | context variants |
|
|
517
|
-
| `grok-4-20` | thinking | plain model + native thinking |
|
|
518
|
-
| `gemini-3.1-pro` | none | plain model |
|
|
519
|
-
| `gemini-3-flash` | none | plain model |
|
|
520
|
-
| `gemini-2.5-flash` | none | plain model |
|
|
521
|
-
| `gpt-5-mini` | none | plain model |
|
|
522
|
-
| `kimi-k2.5` | none | plain model |
|
|
523
|
-
|
|
524
|
-
If Cursor later adds `fast`, `context`, `reasoning`, or `effort` to a model, the extension picks it up dynamically.
|
|
497
|
+
| plain model, such as `default` or models with no exposed controls | none | plain model |
|
|
498
|
+
| `composer-2`-style model | fast | plain model + fast extension state |
|
|
499
|
+
| GPT-style reasoning model with context variants | context, reasoning, fast when exposed | context variants + native thinking + optional fast state |
|
|
500
|
+
| Claude-style thinking model with context variants | thinking, context, effort when exposed | context variants + native thinking + optional fast state |
|
|
501
|
+
| Claude-style thinking model without context variants | thinking and/or effort | plain model + native thinking |
|
|
502
|
+
| context-only model | context | context variants |
|
|
503
|
+
| unique latest alias for any shape | aliases | same pi rows as the base model shape, using the alias as `ModelSelection.id` |
|
|
504
|
+
| shared generic alias across multiple base models | aliases | skipped to avoid misleading pi rows |
|
|
505
|
+
|
|
506
|
+
If Cursor later adds `fast`, `context`, `reasoning`, `effort`, or aliases to a model, the extension picks up unambiguous capability changes dynamically.
|
|
525
507
|
|
|
526
508
|
## Detailed Examples
|
|
527
509
|
|
package/package.json
CHANGED
|
@@ -4,6 +4,7 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
|
4
4
|
import { BUNDLED_CONTEXT_WINDOWS } from "./bundled-context-windows.js";
|
|
5
5
|
|
|
6
6
|
const CONTEXT_WINDOW_CACHE_FILE = "cursor-sdk-context-windows.json";
|
|
7
|
+
let userContextWindowOverrideLoadCount = 0;
|
|
7
8
|
|
|
8
9
|
interface ContextWindowCacheFile {
|
|
9
10
|
contextWindows?: Record<string, number>;
|
|
@@ -18,6 +19,7 @@ function isPositiveInteger(value: unknown): value is number {
|
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
function loadUserContextWindowOverrides(): Map<string, number> {
|
|
22
|
+
userContextWindowOverrideLoadCount += 1;
|
|
21
23
|
const path = getCachePath();
|
|
22
24
|
const overrides = new Map<string, number>();
|
|
23
25
|
if (!existsSync(path)) return overrides;
|
|
@@ -80,4 +82,8 @@ export function saveCachedContextWindow(modelId: string, contextWindow: number):
|
|
|
80
82
|
|
|
81
83
|
export const __testUtils = {
|
|
82
84
|
getCachePath,
|
|
85
|
+
getUserContextWindowOverrideLoadCount: () => userContextWindowOverrideLoadCount,
|
|
86
|
+
resetUserContextWindowOverrideLoadCount: () => {
|
|
87
|
+
userContextWindowOverrideLoadCount = 0;
|
|
88
|
+
},
|
|
83
89
|
};
|
|
@@ -56,9 +56,14 @@ export function canRenderCursorToolNatively(toolName: string): boolean {
|
|
|
56
56
|
return isNativeCursorToolName(toolName) && registeredNativeToolNames.has(toolName);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
export function recordCursorNativeToolDisplay(item: CursorNativeToolDisplayItem):
|
|
60
|
-
if (!canRenderCursorToolNatively(item.toolName)) return;
|
|
59
|
+
export function recordCursorNativeToolDisplay(item: CursorNativeToolDisplayItem): boolean {
|
|
60
|
+
if (!canRenderCursorToolNatively(item.toolName)) return false;
|
|
61
61
|
nativeToolResults.set(item.id, item);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function deleteCursorNativeToolDisplay(id: string): void {
|
|
66
|
+
nativeToolResults.delete(id);
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
function consumeCursorNativeToolDisplay(id: string): CursorNativeToolDisplayItem | undefined {
|
|
@@ -68,6 +73,7 @@ function consumeCursorNativeToolDisplay(id: string): CursorNativeToolDisplayItem
|
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
export const __testUtils = {
|
|
76
|
+
nativeToolResultCount: () => nativeToolResults.size,
|
|
71
77
|
reset(): void {
|
|
72
78
|
registeredNativeToolNames.clear();
|
|
73
79
|
nativeToolResults.clear();
|
package/src/cursor-provider.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { buildCursorPiToolDisplay, formatCursorToolTranscript, mergeCursorToolCa
|
|
|
17
17
|
import {
|
|
18
18
|
canRenderCursorToolNatively,
|
|
19
19
|
isCursorNativeToolDisplayRuntimeEnabled,
|
|
20
|
+
deleteCursorNativeToolDisplay,
|
|
20
21
|
recordCursorNativeToolDisplay,
|
|
21
22
|
type CursorNativeToolDisplayItem,
|
|
22
23
|
} from "./cursor-native-tool-display.js";
|
|
@@ -58,6 +59,7 @@ const AUTH_CURSOR_SDK_ERROR_MESSAGE =
|
|
|
58
59
|
const APPROX_CHARS_PER_TOKEN = 4;
|
|
59
60
|
const IMAGE_TOKEN_ESTIMATE = 1200;
|
|
60
61
|
const CURSOR_ACTIVITY_TRACE_MAX_CHARS = 50000;
|
|
62
|
+
const DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS = 5 * 60 * 1000;
|
|
61
63
|
const CURSOR_NATIVE_REPLAY_TOOL_ID_PATTERN = /^(cursor-replay-\d+-\d+)-tool-\d+$/;
|
|
62
64
|
|
|
63
65
|
type CursorNativeQueuedEvent =
|
|
@@ -73,10 +75,13 @@ interface CursorNativeLiveRun {
|
|
|
73
75
|
promptInputTokensReported: boolean;
|
|
74
76
|
pendingEvents: CursorNativeQueuedEvent[];
|
|
75
77
|
textDeltas: string[];
|
|
78
|
+
recordedToolDisplayIds: string[];
|
|
76
79
|
finalText?: string;
|
|
77
80
|
done: boolean;
|
|
78
81
|
cancelled: boolean;
|
|
82
|
+
disposed: boolean;
|
|
79
83
|
errorMessage?: string;
|
|
84
|
+
idleDisposeTimer?: ReturnType<typeof setTimeout>;
|
|
80
85
|
waiters: Set<() => void>;
|
|
81
86
|
}
|
|
82
87
|
|
|
@@ -88,6 +93,7 @@ interface CursorNativeTurnState {
|
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
let cursorNativeReplayCounter = 0;
|
|
96
|
+
let cursorNativeReplayIdleDisposeMs = DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS;
|
|
91
97
|
const pendingCursorNativeRuns = new Map<string, CursorNativeLiveRun>();
|
|
92
98
|
|
|
93
99
|
function escapeRegExp(value: string): string {
|
|
@@ -264,6 +270,21 @@ function queueCursorNativeEvent(run: CursorNativeLiveRun, event: CursorNativeQue
|
|
|
264
270
|
notifyCursorNativeRun(run);
|
|
265
271
|
}
|
|
266
272
|
|
|
273
|
+
function clearCursorNativeRunIdleDispose(run: CursorNativeLiveRun): void {
|
|
274
|
+
if (!run.idleDisposeTimer) return;
|
|
275
|
+
clearTimeout(run.idleDisposeTimer);
|
|
276
|
+
run.idleDisposeTimer = undefined;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function scheduleCursorNativeRunIdleDispose(run: CursorNativeLiveRun): void {
|
|
280
|
+
if (run.disposed) return;
|
|
281
|
+
clearCursorNativeRunIdleDispose(run);
|
|
282
|
+
run.idleDisposeTimer = setTimeout(() => {
|
|
283
|
+
void disposeCursorNativeRun(run);
|
|
284
|
+
}, cursorNativeReplayIdleDisposeMs);
|
|
285
|
+
run.idleDisposeTimer.unref?.();
|
|
286
|
+
}
|
|
287
|
+
|
|
267
288
|
function isCursorNativeRunReady(run: CursorNativeLiveRun): boolean {
|
|
268
289
|
return run.pendingEvents.length > 0 || run.done || run.cancelled || run.errorMessage !== undefined;
|
|
269
290
|
}
|
|
@@ -310,12 +331,13 @@ function closeCursorNativeThinkingBlock(turn: CursorNativeTurnState): void {
|
|
|
310
331
|
|
|
311
332
|
function closeCursorNativeTextBlock(turn: CursorNativeTurnState): string {
|
|
312
333
|
if (turn.textContentIndex < 0) return "";
|
|
313
|
-
const
|
|
334
|
+
const contentIndex = turn.textContentIndex;
|
|
335
|
+
const block = turn.partial.content[contentIndex];
|
|
314
336
|
turn.textContentIndex = -1;
|
|
315
337
|
if (block.type !== "text") return "";
|
|
316
338
|
turn.stream.push({
|
|
317
339
|
type: "text_end",
|
|
318
|
-
contentIndex
|
|
340
|
+
contentIndex,
|
|
319
341
|
content: block.text,
|
|
320
342
|
partial: turn.partial,
|
|
321
343
|
});
|
|
@@ -402,15 +424,24 @@ function emitCursorNativeToolUseTurn(
|
|
|
402
424
|
stream.push({ type: "toolcall_delta", contentIndex, delta: JSON.stringify(tool.args), partial });
|
|
403
425
|
const block = partial.content[contentIndex];
|
|
404
426
|
if (block.type === "toolCall") stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial });
|
|
405
|
-
recordCursorNativeToolDisplay({ ...tool, terminate: shouldTerminate })
|
|
427
|
+
if (recordCursorNativeToolDisplay({ ...tool, terminate: shouldTerminate })) {
|
|
428
|
+
run.recordedToolDisplayIds.push(tool.id);
|
|
429
|
+
}
|
|
406
430
|
}
|
|
407
431
|
setApproximateUsage(partial, takeCursorNativePromptInputTokens(run), outputText);
|
|
408
432
|
partial.stopReason = "toolUse";
|
|
409
433
|
stream.push({ type: "done", reason: "toolUse", message: partial });
|
|
434
|
+
scheduleCursorNativeRunIdleDispose(run);
|
|
410
435
|
}
|
|
411
436
|
|
|
412
437
|
async function disposeCursorNativeRun(run: CursorNativeLiveRun): Promise<void> {
|
|
438
|
+
if (run.disposed) return;
|
|
439
|
+
run.disposed = true;
|
|
413
440
|
pendingCursorNativeRuns.delete(run.id);
|
|
441
|
+
clearCursorNativeRunIdleDispose(run);
|
|
442
|
+
for (const toolDisplayId of run.recordedToolDisplayIds) deleteCursorNativeToolDisplay(toolDisplayId);
|
|
443
|
+
run.recordedToolDisplayIds = [];
|
|
444
|
+
run.waiters.clear();
|
|
414
445
|
try {
|
|
415
446
|
await run.agent[Symbol.asyncDispose]();
|
|
416
447
|
} catch {
|
|
@@ -484,7 +515,13 @@ async function replayPendingCursorNativeRun(
|
|
|
484
515
|
if (!replayId) return false;
|
|
485
516
|
const run = pendingCursorNativeRuns.get(replayId);
|
|
486
517
|
if (!run) return false;
|
|
487
|
-
|
|
518
|
+
clearCursorNativeRunIdleDispose(run);
|
|
519
|
+
try {
|
|
520
|
+
await emitCursorNativeRunNextTurn(stream, partial, run, signal);
|
|
521
|
+
} catch (error) {
|
|
522
|
+
if (error instanceof CursorAbortError) await disposeCursorNativeRun(run);
|
|
523
|
+
throw error;
|
|
524
|
+
}
|
|
488
525
|
return true;
|
|
489
526
|
}
|
|
490
527
|
|
|
@@ -498,6 +535,7 @@ export function streamCursor(
|
|
|
498
535
|
(async () => {
|
|
499
536
|
const partial = makeInitialMessage(model);
|
|
500
537
|
let agent: SDKAgent | null = null;
|
|
538
|
+
let activeNativeRun: CursorNativeLiveRun | undefined;
|
|
501
539
|
let resolvedApiKey: string | undefined;
|
|
502
540
|
let abortSignal: AbortSignal | undefined;
|
|
503
541
|
let abortListener: (() => void) | undefined;
|
|
@@ -551,14 +589,21 @@ export function streamCursor(
|
|
|
551
589
|
promptInputTokensReported: false,
|
|
552
590
|
pendingEvents: [],
|
|
553
591
|
textDeltas,
|
|
592
|
+
recordedToolDisplayIds: [],
|
|
554
593
|
done: false,
|
|
555
594
|
cancelled: false,
|
|
595
|
+
disposed: false,
|
|
556
596
|
waiters: new Set(),
|
|
557
597
|
}
|
|
558
598
|
: undefined;
|
|
559
|
-
if (liveRun)
|
|
599
|
+
if (liveRun) {
|
|
600
|
+
pendingCursorNativeRuns.set(liveRun.id, liveRun);
|
|
601
|
+
activeNativeRun = liveRun;
|
|
602
|
+
}
|
|
560
603
|
const startedToolCalls = new Map<string, unknown>();
|
|
561
|
-
const
|
|
604
|
+
const completedToolIdentities = new Set<string>();
|
|
605
|
+
const completedStartedToolFingerprints = new Set<string>();
|
|
606
|
+
const completedFallbackToolFingerprints = new Set<string>();
|
|
562
607
|
|
|
563
608
|
const appendLiveTextDelta = (text: string): void => {
|
|
564
609
|
if (textContentIndex < 0) {
|
|
@@ -652,12 +697,25 @@ export function streamCursor(
|
|
|
652
697
|
}
|
|
653
698
|
};
|
|
654
699
|
|
|
655
|
-
const handleCompletedToolCall = (
|
|
700
|
+
const handleCompletedToolCall = (
|
|
701
|
+
toolCall: unknown,
|
|
702
|
+
options: { identity?: string; source?: "started" | "fallback" } = {},
|
|
703
|
+
): void => {
|
|
656
704
|
const transcript = scrubSensitiveText(formatCursorToolTranscript(toolCall, { cwd }), resolvedApiKey);
|
|
657
705
|
const display = buildCursorPiToolDisplay(toolCall, { cwd });
|
|
658
706
|
const fingerprint = getToolFingerprint({ toolName: display.toolName, args: display.args, result: display.result });
|
|
659
|
-
if (
|
|
660
|
-
|
|
707
|
+
if (options.identity && completedToolIdentities.has(options.identity)) return;
|
|
708
|
+
if (options.source === "started") {
|
|
709
|
+
if (completedFallbackToolFingerprints.has(fingerprint)) return;
|
|
710
|
+
} else if (completedStartedToolFingerprints.has(fingerprint) || completedFallbackToolFingerprints.has(fingerprint)) {
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (options.identity) completedToolIdentities.add(options.identity);
|
|
714
|
+
if (options.source === "started") {
|
|
715
|
+
completedStartedToolFingerprints.add(fingerprint);
|
|
716
|
+
} else {
|
|
717
|
+
completedFallbackToolFingerprints.add(fingerprint);
|
|
718
|
+
}
|
|
661
719
|
|
|
662
720
|
if (useNativeToolReplay && canRenderCursorToolNatively(display.toolName) && liveRun) {
|
|
663
721
|
nativeToolReplayStarted = true;
|
|
@@ -704,7 +762,11 @@ export function streamCursor(
|
|
|
704
762
|
} else if (update.type === "tool-call-completed") {
|
|
705
763
|
const mergedToolCall = mergeCursorToolCalls(startedToolCalls.get(update.callId), update.toolCall);
|
|
706
764
|
startedToolCalls.delete(update.callId);
|
|
707
|
-
|
|
765
|
+
const identity = typeof update.callId === "string" ? `cursor-tool:${update.callId}` : undefined;
|
|
766
|
+
handleCompletedToolCall(mergedToolCall, {
|
|
767
|
+
identity,
|
|
768
|
+
source: identity ? "started" : "fallback",
|
|
769
|
+
});
|
|
708
770
|
} else if (update.type === "summary") {
|
|
709
771
|
const summary = `Cursor summary: ${truncateSingleLine(update.summary)}\n`;
|
|
710
772
|
if (liveRun && nativeToolReplayStarted) {
|
|
@@ -723,7 +785,12 @@ export function streamCursor(
|
|
|
723
785
|
const step = getObjectField(args.step, "message") ? args.step : undefined;
|
|
724
786
|
if (getObjectField(args.step, "type") !== "toolCall") return;
|
|
725
787
|
const toolCall = getObjectField(step, "message");
|
|
726
|
-
|
|
788
|
+
const stepId = getObjectField(args.step, "id") ?? getObjectField(toolCall, "id") ?? getObjectField(toolCall, "callId");
|
|
789
|
+
if (toolCall) {
|
|
790
|
+
handleCompletedToolCall(toolCall, {
|
|
791
|
+
identity: typeof stepId === "string" ? `cursor-tool:${stepId}` : undefined,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
727
794
|
};
|
|
728
795
|
|
|
729
796
|
// Handle abort signal
|
|
@@ -750,21 +817,31 @@ export function streamCursor(
|
|
|
750
817
|
void run
|
|
751
818
|
.wait()
|
|
752
819
|
.then(async (result) => {
|
|
820
|
+
if (liveRun.disposed) return;
|
|
753
821
|
await cacheSdkContextWindow(liveRun.agent.agentId, model.id);
|
|
822
|
+
if (liveRun.disposed) return;
|
|
754
823
|
liveRun.cancelled = result.status === "cancelled";
|
|
755
824
|
liveRun.finalText = hasUsableText(result.result) ? result.result : liveRun.textDeltas.join("");
|
|
756
825
|
liveRun.done = true;
|
|
757
826
|
notifyCursorNativeRun(liveRun);
|
|
827
|
+
scheduleCursorNativeRunIdleDispose(liveRun);
|
|
758
828
|
})
|
|
759
829
|
.catch((error: unknown) => {
|
|
830
|
+
if (liveRun.disposed) return;
|
|
760
831
|
liveRun.errorMessage = sanitizeError(error, resolvedApiKey ?? options?.apiKey);
|
|
761
832
|
notifyCursorNativeRun(liveRun);
|
|
833
|
+
scheduleCursorNativeRunIdleDispose(liveRun);
|
|
762
834
|
});
|
|
763
835
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
836
|
+
try {
|
|
837
|
+
await waitForCursorNativeRunProgress(liveRun, options?.signal);
|
|
838
|
+
await settleCursorNativeToolBatch(liveRun);
|
|
839
|
+
closeTraceBlock();
|
|
840
|
+
await emitCursorNativeRunNextTurn(stream, partial, liveRun, options?.signal);
|
|
841
|
+
} catch (error) {
|
|
842
|
+
if (error instanceof CursorAbortError) await disposeCursorNativeRun(liveRun);
|
|
843
|
+
throw error;
|
|
844
|
+
}
|
|
768
845
|
agent = null;
|
|
769
846
|
return;
|
|
770
847
|
}
|
|
@@ -794,6 +871,8 @@ export function streamCursor(
|
|
|
794
871
|
stream.push({ type: "error", reason: "error", error: partial });
|
|
795
872
|
}
|
|
796
873
|
} finally {
|
|
874
|
+
if (activeNativeRun?.disposed) agent = null;
|
|
875
|
+
|
|
797
876
|
if (abortSignal && abortListener) {
|
|
798
877
|
abortSignal.removeEventListener("abort", abortListener);
|
|
799
878
|
}
|
|
@@ -813,3 +892,14 @@ export function streamCursor(
|
|
|
813
892
|
|
|
814
893
|
return stream;
|
|
815
894
|
}
|
|
895
|
+
|
|
896
|
+
export const __testUtils = {
|
|
897
|
+
DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS,
|
|
898
|
+
pendingCursorNativeRunCount: () => pendingCursorNativeRuns.size,
|
|
899
|
+
setCursorNativeReplayIdleDisposeMs: (value: number) => {
|
|
900
|
+
cursorNativeReplayIdleDisposeMs = value;
|
|
901
|
+
},
|
|
902
|
+
resetCursorNativeReplayIdleDisposeMs: () => {
|
|
903
|
+
cursorNativeReplayIdleDisposeMs = DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS;
|
|
904
|
+
},
|
|
905
|
+
};
|
package/src/cursor-state.ts
CHANGED
|
@@ -105,16 +105,25 @@ function restoreMapValue(map: Map<string, boolean>, key: string, previous: boole
|
|
|
105
105
|
function persistFastPreference(pi: ExtensionAPI, baseModelId: string, fast: boolean): void {
|
|
106
106
|
const previousSession = sessionFastPreferences.get(baseModelId);
|
|
107
107
|
const previousGlobal = globalFastPreferences.get(baseModelId);
|
|
108
|
+
let savedGlobal = false;
|
|
108
109
|
sessionFastPreferences.set(baseModelId, fast);
|
|
109
110
|
globalFastPreferences.set(baseModelId, fast);
|
|
110
111
|
try {
|
|
111
112
|
saveGlobalFastPreferences();
|
|
113
|
+
savedGlobal = true;
|
|
114
|
+
pi.appendEntry<CursorFastEntryData>(FAST_ENTRY_TYPE, { baseModelId, fast });
|
|
112
115
|
} catch (error) {
|
|
113
116
|
restoreMapValue(sessionFastPreferences, baseModelId, previousSession);
|
|
114
117
|
restoreMapValue(globalFastPreferences, baseModelId, previousGlobal);
|
|
118
|
+
if (savedGlobal) {
|
|
119
|
+
try {
|
|
120
|
+
saveGlobalFastPreferences();
|
|
121
|
+
} catch {
|
|
122
|
+
// Preserve the original append failure reported to the user.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
115
125
|
throw error;
|
|
116
126
|
}
|
|
117
|
-
pi.appendEntry<CursorFastEntryData>(FAST_ENTRY_TYPE, { baseModelId, fast });
|
|
118
127
|
}
|
|
119
128
|
|
|
120
129
|
export function getEffectiveFastForModelId(modelId: string): boolean | undefined {
|
|
@@ -281,15 +281,18 @@ function renderTreeNode(node: unknown, depth = 0, lines: string[] = []): string[
|
|
|
281
281
|
return lines;
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
-
function
|
|
285
|
-
const path = formatPathArg(args, options) ?? ".";
|
|
286
|
-
if (result.status === "error") return joinSections(`ls ${path}`, formatError(result.error));
|
|
287
|
-
|
|
284
|
+
function getLsBody(result: NormalizedResult, options: TranscriptOptions): string {
|
|
288
285
|
const value = asRecord(result.value);
|
|
289
286
|
const root = value?.directoryTreeRoot ?? result.value;
|
|
290
287
|
const treeLines = renderTreeNode(root);
|
|
291
288
|
const body = treeLines.length > 0 ? treeLines.join("\n") : stringifyUnknown(result.value);
|
|
292
|
-
return
|
|
289
|
+
return limitText(body, options);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function formatLs(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
|
|
293
|
+
const path = formatPathArg(args, options) ?? ".";
|
|
294
|
+
if (result.status === "error") return joinSections(`ls ${path}`, formatError(result.error));
|
|
295
|
+
return joinSections(`ls ${path}`, getLsBody(result, options));
|
|
293
296
|
}
|
|
294
297
|
|
|
295
298
|
function formatGlob(args: Record<string, unknown>, result: NormalizedResult, options: TranscriptOptions): string {
|
|
@@ -528,7 +531,7 @@ export function buildCursorPiToolDisplay(toolCall: unknown, options: TranscriptO
|
|
|
528
531
|
return {
|
|
529
532
|
toolName: "ls",
|
|
530
533
|
args,
|
|
531
|
-
result: textToolResult(result.status === "error" ? formatError(result.error) :
|
|
534
|
+
result: textToolResult(result.status === "error" ? formatError(result.error) : getLsBody(result, options).trim()),
|
|
532
535
|
isError: result.status === "error",
|
|
533
536
|
};
|
|
534
537
|
}
|
package/src/model-discovery.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
} from "@cursor/sdk";
|
|
8
8
|
import { AuthStorage, type ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import type { ModelThinkingLevel, ThinkingLevelMap } from "@earendil-works/pi-ai";
|
|
10
|
-
import {
|
|
10
|
+
import { loadContextWindowCache } from "./context-window-cache.js";
|
|
11
11
|
|
|
12
12
|
const CURSOR_PROVIDER_ID = "cursor";
|
|
13
13
|
const CURSOR_API_KEY_ENV_VAR = "CURSOR_API_KEY";
|
|
@@ -88,7 +88,7 @@ const FALLBACK_MODEL_ITEMS: ModelListItem[] = [
|
|
|
88
88
|
{
|
|
89
89
|
id: "context",
|
|
90
90
|
displayName: "Context",
|
|
91
|
-
values: [{ value: "1m" }, { value: "
|
|
91
|
+
values: [{ value: "1m" }, { value: "200k" }],
|
|
92
92
|
},
|
|
93
93
|
{
|
|
94
94
|
id: "effort",
|
|
@@ -165,6 +165,7 @@ export type CursorModelFallbackReason = "missing-api-key" | "discovery-failed" |
|
|
|
165
165
|
export interface CursorModelFallbackIssue {
|
|
166
166
|
reason: CursorModelFallbackReason;
|
|
167
167
|
message: string;
|
|
168
|
+
errorMessage?: string;
|
|
168
169
|
}
|
|
169
170
|
|
|
170
171
|
export interface DiscoverModelsOptions {
|
|
@@ -351,9 +352,14 @@ function getModelName(item: ModelListItem, context?: string, alias?: string): st
|
|
|
351
352
|
return context ? `${baseName} @ ${context}` : baseName;
|
|
352
353
|
}
|
|
353
354
|
|
|
354
|
-
function getContextWindow(piModelId: string, context?: string, baseModelId?: string): number {
|
|
355
|
-
|
|
356
|
-
|
|
355
|
+
function getContextWindow(contextWindowCache: Map<string, number>, piModelId: string, context?: string, baseModelId?: string): number {
|
|
356
|
+
return (
|
|
357
|
+
contextWindowCache.get(piModelId) ??
|
|
358
|
+
(context ? parseContextWindow(context) : undefined) ??
|
|
359
|
+
(baseModelId ? contextWindowCache.get(baseModelId) : undefined) ??
|
|
360
|
+
contextWindowCache.get("default") ??
|
|
361
|
+
FALLBACK_CONTEXT_WINDOW
|
|
362
|
+
);
|
|
357
363
|
}
|
|
358
364
|
|
|
359
365
|
function toMetadata(
|
|
@@ -362,6 +368,7 @@ function toMetadata(
|
|
|
362
368
|
selectionModelId: string,
|
|
363
369
|
defaultParams: ModelParameterValue[],
|
|
364
370
|
context: string | undefined,
|
|
371
|
+
contextWindowCache: Map<string, number>,
|
|
365
372
|
): CursorModelMetadata {
|
|
366
373
|
const thinkingLevelMap = getThinkingLevelMap(item);
|
|
367
374
|
const fastValue = getParamValue(defaultParams, "fast")?.toLowerCase();
|
|
@@ -372,7 +379,7 @@ function toMetadata(
|
|
|
372
379
|
displayName: item.displayName || item.id,
|
|
373
380
|
defaultParams: cloneParams(defaultParams),
|
|
374
381
|
...(context ? { context } : {}),
|
|
375
|
-
contextWindow: getContextWindow(piModelId, context, item.id),
|
|
382
|
+
contextWindow: getContextWindow(contextWindowCache, piModelId, context, item.id),
|
|
376
383
|
supportsFast: getParameter(item, "fast") !== undefined,
|
|
377
384
|
defaultFast: fastValue === "true",
|
|
378
385
|
supportsReasoning: thinkingLevelMap !== undefined,
|
|
@@ -404,11 +411,25 @@ function getContextValues(item: ModelListItem): string[] {
|
|
|
404
411
|
return getParameter(item, "context")?.values.map((value) => value.value) ?? [];
|
|
405
412
|
}
|
|
406
413
|
|
|
407
|
-
function
|
|
414
|
+
function getAmbiguousAliases(items: ModelListItem[]): Set<string> {
|
|
415
|
+
const aliasOwners = new Map<string, Set<string>>();
|
|
416
|
+
for (const item of items) {
|
|
417
|
+
for (const rawAlias of item.aliases ?? []) {
|
|
418
|
+
const alias = rawAlias.trim();
|
|
419
|
+
if (!alias || alias === item.id) continue;
|
|
420
|
+
const owners = aliasOwners.get(alias) ?? new Set<string>();
|
|
421
|
+
owners.add(item.id);
|
|
422
|
+
aliasOwners.set(alias, owners);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return new Set([...aliasOwners.entries()].filter(([, owners]) => owners.size > 1).map(([alias]) => alias));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function getModelIds(item: ModelListItem, reservedBaseModelIds: Set<string>, ambiguousAliases: Set<string>): string[] {
|
|
408
429
|
const ids = [item.id];
|
|
409
430
|
for (const rawAlias of item.aliases ?? []) {
|
|
410
431
|
const alias = rawAlias.trim();
|
|
411
|
-
if (!alias || alias === item.id || ids.includes(alias) || reservedBaseModelIds.has(alias)) continue;
|
|
432
|
+
if (!alias || alias === item.id || ids.includes(alias) || reservedBaseModelIds.has(alias) || ambiguousAliases.has(alias)) continue;
|
|
412
433
|
ids.push(alias);
|
|
413
434
|
}
|
|
414
435
|
return ids;
|
|
@@ -418,20 +439,22 @@ function toModelConfigs(
|
|
|
418
439
|
item: ModelListItem,
|
|
419
440
|
usedPiModelIds: Set<string>,
|
|
420
441
|
reservedBaseModelIds: Set<string>,
|
|
442
|
+
ambiguousAliases: Set<string>,
|
|
443
|
+
contextWindowCache: Map<string, number>,
|
|
421
444
|
): ProviderModelConfig[] {
|
|
422
445
|
const defaultParams = getDefaultParams(item);
|
|
423
446
|
const contextValues = getContextValues(item);
|
|
424
447
|
const contexts = contextValues.length > 0 ? contextValues : [undefined];
|
|
425
448
|
const configs: ProviderModelConfig[] = [];
|
|
426
449
|
|
|
427
|
-
for (const selectionModelId of getModelIds(item, reservedBaseModelIds)) {
|
|
450
|
+
for (const selectionModelId of getModelIds(item, reservedBaseModelIds, ambiguousAliases)) {
|
|
428
451
|
const alias = selectionModelId === item.id ? undefined : selectionModelId;
|
|
429
452
|
for (const context of contexts) {
|
|
430
453
|
const params = context ? replaceParam(defaultParams, "context", context) : defaultParams;
|
|
431
454
|
const piModelId = encodePiModelId(selectionModelId, context);
|
|
432
455
|
if (usedPiModelIds.has(piModelId)) continue;
|
|
433
456
|
usedPiModelIds.add(piModelId);
|
|
434
|
-
const metadata = toMetadata(item, piModelId, selectionModelId, params, context);
|
|
457
|
+
const metadata = toMetadata(item, piModelId, selectionModelId, params, context, contextWindowCache);
|
|
435
458
|
metadataByPiModelId.set(piModelId, metadata);
|
|
436
459
|
configs.push(toModelConfig(metadata, getModelName(item, context, alias)));
|
|
437
460
|
}
|
|
@@ -448,7 +471,9 @@ function registerModelItems(items: ModelListItem[]): ProviderModelConfig[] {
|
|
|
448
471
|
metadataByPiModelId.clear();
|
|
449
472
|
const usedPiModelIds = new Set<string>();
|
|
450
473
|
const reservedBaseModelIds = new Set(items.map((item) => item.id));
|
|
451
|
-
|
|
474
|
+
const ambiguousAliases = getAmbiguousAliases(items);
|
|
475
|
+
const contextWindowCache = loadContextWindowCache();
|
|
476
|
+
return sortModelsByBaseId(items).flatMap((item) => toModelConfigs(item, usedPiModelIds, reservedBaseModelIds, ambiguousAliases, contextWindowCache));
|
|
452
477
|
}
|
|
453
478
|
|
|
454
479
|
export function getCursorModelMetadata(modelId: string): CursorModelMetadata | undefined {
|
|
@@ -532,6 +557,24 @@ export function buildCursorModelSelection(
|
|
|
532
557
|
return params.length > 0 ? { id: metadata.selectionModelId, params } : { id: metadata.selectionModelId };
|
|
533
558
|
}
|
|
534
559
|
|
|
560
|
+
function scrubDiscoveryErrorText(text: string, apiKey: string): string {
|
|
561
|
+
let scrubbed = text.replace(new RegExp(apiKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), "[redacted]");
|
|
562
|
+
return scrubbed
|
|
563
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]")
|
|
564
|
+
.replace(/((?:^|[\s,{])cookie["']?\s*[:=]\s*["']?)[^\n]+/gi, "$1[redacted]")
|
|
565
|
+
.replace(
|
|
566
|
+
/((?:authorization|api[_-]?key|apiKey|token|session(?:[_-]?id)?)["']?\s*[:=]\s*["']?)[^"'\s,;}]+/gi,
|
|
567
|
+
"$1[redacted]",
|
|
568
|
+
)
|
|
569
|
+
.trim();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function sanitizeDiscoveryError(error: unknown, apiKey: string): string | undefined {
|
|
573
|
+
const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
574
|
+
const scrubbed = scrubDiscoveryErrorText(message, apiKey);
|
|
575
|
+
return scrubbed || undefined;
|
|
576
|
+
}
|
|
577
|
+
|
|
535
578
|
function useFallbackModels(options: DiscoverModelsOptions, issue: CursorModelFallbackIssue): ProviderModelConfig[] {
|
|
536
579
|
options.onFallback?.(issue);
|
|
537
580
|
return registerModelItems(FALLBACK_MODEL_ITEMS);
|
|
@@ -555,10 +598,12 @@ export async function discoverModels(options: DiscoverModelsOptions = {}): Promi
|
|
|
555
598
|
reason: "empty-model-list",
|
|
556
599
|
message: `Cursor model discovery returned no models. Using fallback Cursor models; verify ${AUTH_SETUP_HINT}. ${CATALOG_REFRESH_HINT}`,
|
|
557
600
|
});
|
|
558
|
-
} catch {
|
|
601
|
+
} catch (error) {
|
|
602
|
+
const errorMessage = sanitizeDiscoveryError(error, apiKey);
|
|
559
603
|
return useFallbackModels(options, {
|
|
560
604
|
reason: "discovery-failed",
|
|
561
|
-
message: `Cursor model discovery failed. Using fallback Cursor models; verify ${AUTH_SETUP_HINT}. ${CATALOG_REFRESH_HINT}`,
|
|
605
|
+
message: `Cursor model discovery failed${errorMessage ? `: ${errorMessage}` : ""}. Using fallback Cursor models; verify ${AUTH_SETUP_HINT}. ${CATALOG_REFRESH_HINT}`,
|
|
606
|
+
...(errorMessage ? { errorMessage } : {}),
|
|
562
607
|
});
|
|
563
608
|
}
|
|
564
609
|
}
|