pi-cursor-sdk 0.1.30 → 0.1.32
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 +21 -0
- package/README.md +13 -4
- package/docs/crabbox-platform-testing-lessons.md +1 -1
- package/docs/cursor-model-ux-spec.md +16 -12
- package/docs/platform-smoke.md +8 -7
- package/package.json +1 -1
- package/platform-smoke.config.mjs +2 -2
- package/scripts/platform-smoke/doctor.mjs +18 -9
- package/src/context-window-cache.ts +23 -7
- package/src/cursor-state.ts +34 -12
- package/src/model-discovery.ts +36 -12
- package/src/model-list-cache.ts +74 -10
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.1.32 - 2026-06-02
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Add a production typing-safety regression test that blocks broad TypeScript escape hatches such as `as unknown as`, `as any`, `as never`, explicit `any`, and production `@ts-ignore` / `@ts-expect-error` usage.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Replace repeated native replay render-test `as never` casts with typed test render fixture helpers.
|
|
14
|
+
- Use the maintained Homebrew Crabbox binary on `PATH` for platform smoke with a `0.24.0` minimum version, keeping `PLATFORM_SMOKE_CRABBOX` as an explicit override only.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Harden local Cursor cache/config JSON parsing so model-list, context-window, and fast-default files are validated from `unknown` before trusted values are used.
|
|
19
|
+
|
|
20
|
+
## 0.1.31 - 2026-06-01
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- Add Cursor `:fast` and `:slow` virtual model aliases for models with a Cursor SDK `fast` parameter so subagents and workflow-spawned agents can choose fast/slow independently of saved `/cursor-fast` defaults (#112).
|
|
25
|
+
|
|
5
26
|
## 0.1.30 - 2026-06-01
|
|
6
27
|
|
|
7
28
|
### Added
|
package/README.md
CHANGED
|
@@ -168,7 +168,7 @@ pi --model cursor/gpt-5.5@272k:xhigh
|
|
|
168
168
|
pi --model cursor/gpt-5.5@1m --thinking medium
|
|
169
169
|
```
|
|
170
170
|
|
|
171
|
-
Cursor
|
|
171
|
+
Cursor `context` becomes a pi-visible model variant because it changes pi's native `contextWindow`. For models that expose Cursor's boolean `fast` parameter, the extension also registers virtual `:fast` and `:slow` model aliases such as `cursor/composer-2-5:slow` and `cursor/gpt-5.5@1m:fast`. Those aliases are selection-only controls for subagents and workflow-spawned agents: they send the same Cursor SDK model ID plus an explicit `fast=true` or `fast=false` param, and they take precedence over saved `/cursor-fast` session/global defaults. Cursor SDK conversation mode remains extension state, not model identity. Alias model IDs use their selected SDK ID for Cursor-only state such as fast defaults, with read fallback for older defaults keyed by the underlying Cursor base model.
|
|
172
172
|
|
|
173
173
|
## Thinking support
|
|
174
174
|
|
|
@@ -190,7 +190,7 @@ Some Cursor SDK models do not expose a `reasoning`, `effort`, or `thinking` para
|
|
|
190
190
|
|
|
191
191
|
## Fast mode
|
|
192
192
|
|
|
193
|
-
Use `/cursor-fast` to persistently toggle fast mode for the selected Cursor model when the model supports Cursor's `fast` parameter.
|
|
193
|
+
Use `/cursor-fast` to persistently toggle fast mode for the selected unsuffixed Cursor model when the model supports Cursor's `fast` parameter.
|
|
194
194
|
|
|
195
195
|
Fast preferences are remembered per selected Cursor SDK model ID or alias and stored:
|
|
196
196
|
|
|
@@ -204,7 +204,16 @@ pi --model cursor/gpt-5.5@1m --cursor-fast -p "Say ok only"
|
|
|
204
204
|
pi --model cursor/composer-2-5 --cursor-no-fast -p "Say ok only"
|
|
205
205
|
```
|
|
206
206
|
|
|
207
|
-
|
|
207
|
+
For per-agent control, select the virtual model alias instead of mutating the shared saved default:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
pi --model cursor/composer-2-5:slow -p "Say ok only"
|
|
211
|
+
pi --model cursor/gpt-5.5@1m:fast -p "Say ok only"
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The `:fast` and `:slow` aliases are available only for Cursor models whose catalog exposes a `fast` parameter. They override saved `/cursor-fast` session/global defaults while leaving `--cursor-fast` and `--cursor-no-fast` as explicit process-level force flags. `/cursor-fast` does not persist a new default while a virtual fast/slow alias is selected; switch to the unsuffixed model first.
|
|
215
|
+
|
|
216
|
+
Composer 2 and Composer 2.5 can default to fast. Use `--cursor-no-fast` or a `:slow` virtual alias for a one-shot no-fast Composer run. In print mode (`-p`), `--cursor-no-fast` is silent and does not write `~/.pi/agent/cursor-sdk.json`.
|
|
208
217
|
|
|
209
218
|
In interactive mode, the footer only shows fast mode when fast is enabled and Cursor mode when it is non-default. Fast and plan mode share one Cursor status value, so they do not overwrite each other:
|
|
210
219
|
|
|
@@ -218,7 +227,7 @@ If you do not see `cursor fast`, fast mode is off. If you do not see `cursor pla
|
|
|
218
227
|
|
|
219
228
|
## Cursor SDK mode
|
|
220
229
|
|
|
221
|
-
Cursor SDK conversation mode is Cursor-only extension state. It is not a pi model variant, not pi thinking/reasoning, not
|
|
230
|
+
Cursor SDK conversation mode is Cursor-only extension state. It is not a pi model variant, not pi thinking/reasoning, not a `:fast`/`:slow` virtual fast alias, and not pi's separate read-only plan-mode extension.
|
|
222
231
|
|
|
223
232
|
Default mode is `agent`. Start a one-shot run in a specific mode:
|
|
224
233
|
|
|
@@ -154,7 +154,7 @@ PLATFORM_SMOKE_WINDOWS_USER=<windows-ssh-user>
|
|
|
154
154
|
PLATFORM_SMOKE_WINDOWS_WORK_ROOT=C:\crabbox\<project>
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
-
Pin Crabbox deliberately. Exact pins are best for release-critical harnesses whose parsing depends on CLI output. Minimum version checks are fine for simpler gates. Current local baseline is Crabbox `0.24.0
|
|
157
|
+
Pin Crabbox deliberately. Exact pins are best for release-critical harnesses whose parsing depends on CLI output. Minimum version checks are fine for simpler gates. Current local baseline is Crabbox `0.24.0` or newer from the Homebrew package.
|
|
158
158
|
|
|
159
159
|
## Target setup best practices
|
|
160
160
|
|
|
@@ -10,7 +10,7 @@ Current implementation notes:
|
|
|
10
10
|
|
|
11
11
|
- Cursor context variants use `base@context` pi model IDs.
|
|
12
12
|
- Cursor `reasoning`, `effort`, and boolean `thinking` parameters are driven by pi native thinking when the Cursor SDK exposes those controls.
|
|
13
|
-
- Cursor `fast` is extension state
|
|
13
|
+
- Cursor `fast` is extension state by default; models that expose `fast` also get selection-only `:fast` / `:slow` virtual aliases for per-agent overrides.
|
|
14
14
|
- Cursor SDK `mode` (`agent` or `plan`) is extension session state, not model identity, pi thinking, Cursor `fast`, or pi's separate plan-mode extension.
|
|
15
15
|
- Cursor status uses one coordinated `ctx.ui.setStatus("cursor", ...)` value for fast and non-default plan mode; the default pi footer remains intact.
|
|
16
16
|
- Installed `@cursor/sdk` user messages accept images, and Cursor models are treated as image-capable; registered input metadata is `text` plus `image`.
|
|
@@ -136,7 +136,7 @@ Use native pi abstractions wherever possible:
|
|
|
136
136
|
| Cursor `reasoning` | pi native thinking via `thinkingLevelMap` |
|
|
137
137
|
| Cursor `effort` | pi native thinking via `thinkingLevelMap` |
|
|
138
138
|
| Cursor `thinking=false` | pi native `off` |
|
|
139
|
-
| Cursor `fast` | extension state
|
|
139
|
+
| Cursor `fast` | extension state plus `:fast` / `:slow` virtual aliases for per-agent overrides |
|
|
140
140
|
| Cursor SDK `mode` | extension session state; `agent` by default, `plan` via SDK-native mode |
|
|
141
141
|
| Footer | default pi footer plus optional extension status |
|
|
142
142
|
|
|
@@ -156,7 +156,7 @@ Rules:
|
|
|
156
156
|
- Register one pi model for each Cursor base model and each unambiguous SDK alias when there is no Cursor `context` parameter.
|
|
157
157
|
- 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.
|
|
158
158
|
- 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.
|
|
159
|
-
- Do not encode `reasoning`, `effort`, `thinking`,
|
|
159
|
+
- Do not encode `reasoning`, `effort`, `thinking`, or Cursor SDK `mode` into pi model IDs. For models with a Cursor `fast` parameter, also register selection-only `:fast` and `:slow` virtual model aliases that do not change pi-native metadata.
|
|
160
160
|
- Prefer stable, readable `@<context>` suffixes that do not conflict with pi's final `:<thinking>` suffix parser.
|
|
161
161
|
- 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.
|
|
162
162
|
|
|
@@ -168,6 +168,9 @@ cursor/gpt-5.5@272k
|
|
|
168
168
|
cursor/claude-opus-4-8@1m
|
|
169
169
|
cursor/claude-opus-4-8@300k
|
|
170
170
|
cursor/composer-2-5
|
|
171
|
+
cursor/composer-2-5:fast
|
|
172
|
+
cursor/composer-2-5:slow
|
|
173
|
+
cursor/gpt-5.5@1m:fast
|
|
171
174
|
```
|
|
172
175
|
|
|
173
176
|
Avoid colon-based context IDs in the first implementation unless this spec is intentionally changed:
|
|
@@ -190,7 +193,7 @@ Reason:
|
|
|
190
193
|
|
|
191
194
|
- `@1m` keeps context visually separate from pi's native `:medium` thinking suffix.
|
|
192
195
|
- Context variants make `contextWindow` accurate in `--list-models`, the native footer, context overflow checks, and compaction logic.
|
|
193
|
-
-
|
|
196
|
+
- `:fast` / `:slow` are virtual aliases, not separate Cursor SDK base models: they keep the same context/thinking metadata and only force the outgoing Cursor `fast` param. They exist so subagents and workflow-spawned agents can choose fast/slow without mutating shared `/cursor-fast` defaults.
|
|
194
197
|
|
|
195
198
|
### Metadata Per Registered Model
|
|
196
199
|
|
|
@@ -362,17 +365,18 @@ fast=false <-> fast=true
|
|
|
362
365
|
|
|
363
366
|
Rules:
|
|
364
367
|
|
|
365
|
-
-
|
|
366
|
-
-
|
|
367
|
-
-
|
|
368
|
-
-
|
|
368
|
+
- Unsuffixed models use extension state from `/cursor-fast`, per-session entries, and global defaults.
|
|
369
|
+
- `:fast` / `:slow` virtual model aliases force fast on/off for that selected agent and override saved defaults without writing state.
|
|
370
|
+
- Toggle unsuffixed models with `/cursor-fast`; do not persist a new default while a virtual fast alias is selected.
|
|
371
|
+
- Store per-session and global per-base-model preferences for unsuffixed models.
|
|
372
|
+
- When calling `Agent.create()` or `agent.send()`, include the selected `fast` value in Cursor model params.
|
|
369
373
|
- Show `fast` through `ctx.ui.setStatus()` when enabled.
|
|
370
|
-
-
|
|
374
|
+
- Keep `--cursor-fast` and `--cursor-no-fast` as explicit process-level force flags.
|
|
371
375
|
|
|
372
376
|
Reason:
|
|
373
377
|
|
|
374
378
|
- `fast` does not affect pi `contextWindow`, thinking levels, or input support.
|
|
375
|
-
-
|
|
379
|
+
- The virtual aliases trade small `--list-models` noise for per-agent selection that works with subagents and dynamic workflows, where mutating a shared global fast default is the wrong abstraction.
|
|
376
380
|
|
|
377
381
|
Status example:
|
|
378
382
|
|
|
@@ -521,7 +525,7 @@ Reason:
|
|
|
521
525
|
- pi supports one final `:<thinking>` suffix.
|
|
522
526
|
- Cursor-only parameters are not generic pi CLI parameters.
|
|
523
527
|
- Context is already represented by the registered pi model ID.
|
|
524
|
-
- `fast` is controlled by saved extension defaults or the
|
|
528
|
+
- `fast` is controlled by saved extension defaults, `:fast` / `:slow` virtual model aliases, or the `--cursor-fast` / `--cursor-no-fast` extension flags.
|
|
525
529
|
- Cursor SDK `mode` is controlled by `/cursor-mode` session state or the first-pass `--cursor-mode` extension flag; it is never encoded in `--model`.
|
|
526
530
|
|
|
527
531
|
For print mode:
|
|
@@ -529,7 +533,7 @@ For print mode:
|
|
|
529
533
|
- no keybindings,
|
|
530
534
|
- use selected context model variant,
|
|
531
535
|
- use `--thinking` or `:medium` for reasoning/effort,
|
|
532
|
-
- use saved global `fast` defaults unless
|
|
536
|
+
- use saved global `fast` defaults unless a virtual `:fast` / `:slow` model alias or force flag is present,
|
|
533
537
|
- use Cursor SDK `agent` mode unless `/cursor-mode` session state or `--cursor-mode` overrides it.
|
|
534
538
|
|
|
535
539
|
Fast flag example:
|
package/docs/platform-smoke.md
CHANGED
|
@@ -52,12 +52,12 @@ The runner uses one supported Crabbox build.
|
|
|
52
52
|
Current baseline:
|
|
53
53
|
|
|
54
54
|
```text
|
|
55
|
-
install: brew install crabbox
|
|
56
|
-
version: 0.24.0
|
|
57
|
-
binary:
|
|
55
|
+
install: brew install openclaw/tap/crabbox
|
|
56
|
+
version: 0.24.0 or newer
|
|
57
|
+
binary: Homebrew `crabbox` on PATH (`/opt/homebrew/bin/crabbox` on Apple Silicon Homebrew installs)
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
Use the Homebrew Crabbox binary on PATH for normal release gates. `PLATFORM_SMOKE_CRABBOX=/path/to/crabbox` is only an explicit override for testing a non-default binary. `smoke:platform:doctor` verifies the configured binary and fails when it is older than the configured minimum version.
|
|
61
61
|
|
|
62
62
|
Required Crabbox providers:
|
|
63
63
|
|
|
@@ -188,8 +188,8 @@ export default {
|
|
|
188
188
|
"cursor-abort-cleanup",
|
|
189
189
|
],
|
|
190
190
|
requiredCrabbox: {
|
|
191
|
-
install: "
|
|
192
|
-
|
|
191
|
+
install: "Homebrew package or PLATFORM_SMOKE_CRABBOX override",
|
|
192
|
+
minVersion: "0.24.0",
|
|
193
193
|
},
|
|
194
194
|
ubuntuContainerImage: "cimg/node:24.16",
|
|
195
195
|
nodeValidationMajor: 24,
|
|
@@ -203,6 +203,7 @@ export default {
|
|
|
203
203
|
The doctor fails if any required value is missing.
|
|
204
204
|
|
|
205
205
|
```bash
|
|
206
|
+
# Optional override; by default the gate uses Homebrew `crabbox` from PATH.
|
|
206
207
|
PLATFORM_SMOKE_CRABBOX=/opt/homebrew/bin/crabbox
|
|
207
208
|
|
|
208
209
|
PLATFORM_SMOKE_MAC_HOST=localhost
|
|
@@ -313,7 +314,7 @@ tar --version
|
|
|
313
314
|
Doctor checks:
|
|
314
315
|
|
|
315
316
|
1. Required env vars exist.
|
|
316
|
-
2. `PLATFORM_SMOKE_CRABBOX`
|
|
317
|
+
2. Homebrew `crabbox` is available on PATH, or `PLATFORM_SMOKE_CRABBOX` points at an executable override.
|
|
317
318
|
3. Crabbox build matches the configured baseline.
|
|
318
319
|
4. Crabbox provider registry includes `local-container`, `ssh`, and `parallels`.
|
|
319
320
|
5. `crabbox doctor --provider local-container --json` passes.
|
package/package.json
CHANGED
|
@@ -13,8 +13,8 @@ export default {
|
|
|
13
13
|
"cursor-abort-cleanup",
|
|
14
14
|
],
|
|
15
15
|
requiredCrabbox: {
|
|
16
|
-
install: "
|
|
17
|
-
|
|
16
|
+
install: "Homebrew package or PLATFORM_SMOKE_CRABBOX override",
|
|
17
|
+
minVersion: "0.24.0",
|
|
18
18
|
},
|
|
19
19
|
ubuntuContainerImage: "cimg/node:24.16",
|
|
20
20
|
nodeValidationMajor: 24,
|
|
@@ -45,6 +45,10 @@ function shell(cmd, opts = {}) {
|
|
|
45
45
|
catch { return null; }
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function commandPath(command) {
|
|
49
|
+
return shell(`command -v ${command}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
48
52
|
function parseLeaseId(output) {
|
|
49
53
|
return output.match(/\bleased\s+(\S+)/)?.[1]
|
|
50
54
|
?? output.match(/\blease=(\S+)/)?.[1]
|
|
@@ -126,7 +130,6 @@ function runChecks(config) {
|
|
|
126
130
|
// ── Phase 1: environment variables ──
|
|
127
131
|
console.log("\n── Environment variables ──");
|
|
128
132
|
const requiredVars = [
|
|
129
|
-
"PLATFORM_SMOKE_CRABBOX",
|
|
130
133
|
"CURSOR_API_KEY",
|
|
131
134
|
"PLATFORM_SMOKE_WINDOWS_VM",
|
|
132
135
|
"PLATFORM_SMOKE_WINDOWS_SNAPSHOT",
|
|
@@ -134,6 +137,7 @@ function runChecks(config) {
|
|
|
134
137
|
"PLATFORM_SMOKE_WINDOWS_NATIVE_WORK_ROOT",
|
|
135
138
|
];
|
|
136
139
|
const optionalVars = [
|
|
140
|
+
"PLATFORM_SMOKE_CRABBOX",
|
|
137
141
|
"PLATFORM_SMOKE_MAC_HOST",
|
|
138
142
|
"PLATFORM_SMOKE_MAC_USER",
|
|
139
143
|
"PLATFORM_SMOKE_MAC_WORK_ROOT",
|
|
@@ -151,12 +155,17 @@ function runChecks(config) {
|
|
|
151
155
|
|
|
152
156
|
// ── Phase 2: Crabbox binary ──
|
|
153
157
|
console.log("\n── Crabbox binary ──");
|
|
154
|
-
const cbox = env("PLATFORM_SMOKE_CRABBOX");
|
|
155
|
-
|
|
156
|
-
|
|
158
|
+
const cbox = env("PLATFORM_SMOKE_CRABBOX") || "crabbox";
|
|
159
|
+
const cboxPath = env("PLATFORM_SMOKE_CRABBOX") || commandPath("crabbox");
|
|
160
|
+
if (!cboxPath) {
|
|
161
|
+
fail(`crabbox not found on PATH; install with ${config.requiredCrabbox?.install ?? "Homebrew"} or set PLATFORM_SMOKE_CRABBOX`);
|
|
157
162
|
} else {
|
|
158
|
-
|
|
159
|
-
|
|
163
|
+
if (env("PLATFORM_SMOKE_CRABBOX")) {
|
|
164
|
+
try { accessSync(cboxPath, constants.X_OK); ok(`binary: ${cboxPath} (env override)`); }
|
|
165
|
+
catch { fail(`${cboxPath} not executable`); }
|
|
166
|
+
} else {
|
|
167
|
+
ok(`binary: ${cboxPath} (PATH)`);
|
|
168
|
+
}
|
|
160
169
|
const ver = silent(cbox, ["--version"]);
|
|
161
170
|
const actualVersion = ver?.split("\n")[0]?.trim();
|
|
162
171
|
if (actualVersion) ok(`version: ${actualVersion}`);
|
|
@@ -174,9 +183,9 @@ function runChecks(config) {
|
|
|
174
183
|
}
|
|
175
184
|
const requiredCommit = config.requiredCrabbox?.commit;
|
|
176
185
|
if (!requiredVersion && !minimumVersion && requiredCommit) {
|
|
177
|
-
const gitRoot = findGitRoot(dirname(
|
|
186
|
+
const gitRoot = findGitRoot(dirname(cboxPath));
|
|
178
187
|
const actualCommit = gitRoot ? silent("git", ["-C", gitRoot, "rev-parse", "HEAD"]) : null;
|
|
179
|
-
if (!actualCommit) fail(`could not verify Crabbox source commit for ${
|
|
188
|
+
if (!actualCommit) fail(`could not verify Crabbox source commit for ${cboxPath}`);
|
|
180
189
|
else if (actualCommit !== requiredCommit) fail(`Crabbox commit mismatch: expected ${requiredCommit}, got ${actualCommit}`);
|
|
181
190
|
else ok(`commit: ${actualCommit}`);
|
|
182
191
|
}
|
|
@@ -184,7 +193,7 @@ function runChecks(config) {
|
|
|
184
193
|
|
|
185
194
|
// ── Phase 3: Crabbox providers ──
|
|
186
195
|
console.log("\n── Crabbox providers ──");
|
|
187
|
-
if (
|
|
196
|
+
if (cboxPath) {
|
|
188
197
|
const providerList = silent(cbox, ["providers"]);
|
|
189
198
|
if (providerList) {
|
|
190
199
|
for (const provider of ["ssh", "local-container", "parallels"]) {
|
|
@@ -18,15 +18,31 @@ function isPositiveInteger(value: unknown): value is number {
|
|
|
18
18
|
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
22
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseContextWindowCacheFile(value: unknown): ContextWindowCacheFile | undefined {
|
|
26
|
+
if (!isRecord(value)) return undefined;
|
|
27
|
+
const { contextWindows } = value;
|
|
28
|
+
if (contextWindows === undefined) return {};
|
|
29
|
+
if (!isRecord(contextWindows)) return undefined;
|
|
30
|
+
return {
|
|
31
|
+
contextWindows: Object.fromEntries(
|
|
32
|
+
Object.entries(contextWindows).filter((entry): entry is [string, number] => isPositiveInteger(entry[1])),
|
|
33
|
+
),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
function loadUserContextWindowOverrides(): Map<string, number> {
|
|
22
38
|
userContextWindowOverrideLoadCount += 1;
|
|
23
39
|
const path = getCachePath();
|
|
24
40
|
const overrides = new Map<string, number>();
|
|
25
41
|
if (!existsSync(path)) return overrides;
|
|
26
42
|
try {
|
|
27
|
-
const parsed = JSON.parse(readFileSync(path, "utf-8"))
|
|
28
|
-
for (const [modelId, contextWindow] of Object.entries(parsed
|
|
29
|
-
|
|
43
|
+
const parsed = parseContextWindowCacheFile(JSON.parse(readFileSync(path, "utf-8")));
|
|
44
|
+
for (const [modelId, contextWindow] of Object.entries(parsed?.contextWindows ?? {})) {
|
|
45
|
+
overrides.set(modelId, contextWindow);
|
|
30
46
|
}
|
|
31
47
|
} catch {
|
|
32
48
|
return overrides;
|
|
@@ -52,10 +68,10 @@ export function getCachedContextWindow(modelId: string): number | undefined {
|
|
|
52
68
|
}
|
|
53
69
|
|
|
54
70
|
export function getCheckpointContextWindow(checkpoint: unknown): number | undefined {
|
|
55
|
-
if (checkpoint
|
|
56
|
-
const tokenDetails =
|
|
57
|
-
if (tokenDetails
|
|
58
|
-
const maxTokens =
|
|
71
|
+
if (!isRecord(checkpoint)) return undefined;
|
|
72
|
+
const { tokenDetails } = checkpoint;
|
|
73
|
+
if (!isRecord(tokenDetails)) return undefined;
|
|
74
|
+
const { maxTokens } = tokenDetails;
|
|
59
75
|
if (!isPositiveInteger(maxTokens)) return undefined;
|
|
60
76
|
return maxTokens;
|
|
61
77
|
}
|
package/src/cursor-state.ts
CHANGED
|
@@ -69,10 +69,13 @@ export function parseCursorAgentMode(raw: unknown): AgentModeOption | undefined
|
|
|
69
69
|
return isCursorAgentMode(mode) ? mode : undefined;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
73
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
72
76
|
function isCursorFastEntryData(value: unknown): value is CursorFastEntryData {
|
|
73
|
-
if (!value
|
|
74
|
-
|
|
75
|
-
return (typeof data.modelId === "string" || typeof data.baseModelId === "string") && typeof data.fast === "boolean";
|
|
77
|
+
if (!isRecord(value)) return false;
|
|
78
|
+
return (typeof value.modelId === "string" || typeof value.baseModelId === "string") && typeof value.fast === "boolean";
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
function getCursorFastEntryModelId(data: CursorFastEntryData): string {
|
|
@@ -80,9 +83,19 @@ function getCursorFastEntryModelId(data: CursorFastEntryData): string {
|
|
|
80
83
|
}
|
|
81
84
|
|
|
82
85
|
function isCursorModeEntryData(value: unknown): value is CursorModeEntryData {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
return isRecord(value) && isCursorAgentMode(value.mode);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseCursorGlobalConfig(value: unknown): CursorGlobalConfig | undefined {
|
|
90
|
+
if (!isRecord(value)) return undefined;
|
|
91
|
+
const { fastDefaults } = value;
|
|
92
|
+
if (fastDefaults === undefined) return {};
|
|
93
|
+
if (!isRecord(fastDefaults)) return undefined;
|
|
94
|
+
return {
|
|
95
|
+
fastDefaults: Object.fromEntries(
|
|
96
|
+
Object.entries(fastDefaults).filter((entry): entry is [string, boolean] => typeof entry[1] === "boolean"),
|
|
97
|
+
),
|
|
98
|
+
};
|
|
86
99
|
}
|
|
87
100
|
|
|
88
101
|
function getConfigPath(): string {
|
|
@@ -93,12 +106,8 @@ function loadGlobalFastPreferences(): Map<string, boolean> {
|
|
|
93
106
|
const path = getConfigPath();
|
|
94
107
|
if (!existsSync(path)) return new Map();
|
|
95
108
|
try {
|
|
96
|
-
const parsed = JSON.parse(readFileSync(path, "utf-8"))
|
|
97
|
-
return new Map(
|
|
98
|
-
Object.entries(parsed.fastDefaults ?? {}).filter(
|
|
99
|
-
(entry): entry is [string, boolean] => typeof entry[1] === "boolean",
|
|
100
|
-
),
|
|
101
|
-
);
|
|
109
|
+
const parsed = parseCursorGlobalConfig(JSON.parse(readFileSync(path, "utf-8")));
|
|
110
|
+
return new Map(Object.entries(parsed?.fastDefaults ?? {}));
|
|
102
111
|
} catch {
|
|
103
112
|
return new Map();
|
|
104
113
|
}
|
|
@@ -138,6 +147,10 @@ function getFastPreferenceModelId(metadata: NonNullable<ReturnType<typeof getCur
|
|
|
138
147
|
return metadata.selectionModelId || metadata.baseModelId;
|
|
139
148
|
}
|
|
140
149
|
|
|
150
|
+
function getVirtualFastBaseModelId(modelId: string): string {
|
|
151
|
+
return modelId.replace(/:(?:fast|slow)$/, "");
|
|
152
|
+
}
|
|
153
|
+
|
|
141
154
|
function getStoredFastPreference(metadata: NonNullable<ReturnType<typeof getCursorModelMetadata>>): boolean | undefined {
|
|
142
155
|
const preferenceModelId = getFastPreferenceModelId(metadata);
|
|
143
156
|
return (
|
|
@@ -153,6 +166,7 @@ function getEffectiveFast(modelId: string): boolean | undefined {
|
|
|
153
166
|
if (!metadata?.supportsFast) return undefined;
|
|
154
167
|
if (cliForceNoFast) return false;
|
|
155
168
|
if (cliForceFast) return true;
|
|
169
|
+
if (metadata.fastOverride !== undefined) return metadata.fastOverride;
|
|
156
170
|
return getStoredFastPreference(metadata) ?? metadata.defaultFast;
|
|
157
171
|
}
|
|
158
172
|
|
|
@@ -344,6 +358,14 @@ export function registerCursorRuntimeControls(pi: CursorRuntimeControlsExtension
|
|
|
344
358
|
ctx.ui.notify("Cursor fast is forced by --cursor-fast", "info");
|
|
345
359
|
return;
|
|
346
360
|
}
|
|
361
|
+
if (metadata.fastOverride !== undefined) {
|
|
362
|
+
const state = metadata.fastOverride ? "enabled" : "disabled";
|
|
363
|
+
ctx.ui.notify(
|
|
364
|
+
`Cursor fast is fixed ${state} by selected model ${metadata.piModelId}; choose ${getVirtualFastBaseModelId(metadata.piModelId)} to use /cursor-fast preferences`,
|
|
365
|
+
"info",
|
|
366
|
+
);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
347
369
|
|
|
348
370
|
const preferenceModelId = getFastPreferenceModelId(metadata);
|
|
349
371
|
const current = getEffectiveFast(metadata.piModelId) ?? false;
|
package/src/model-discovery.ts
CHANGED
|
@@ -88,6 +88,7 @@ export interface CursorModelMetadata {
|
|
|
88
88
|
contextWindow: number;
|
|
89
89
|
supportsFast: boolean;
|
|
90
90
|
defaultFast: boolean;
|
|
91
|
+
fastOverride?: boolean;
|
|
91
92
|
supportsReasoning: boolean;
|
|
92
93
|
thinkingLevelMap?: ThinkingLevelMap;
|
|
93
94
|
parameterIds: {
|
|
@@ -205,19 +206,35 @@ function getParamValue(params: ModelParameterValue[], id: string): string | unde
|
|
|
205
206
|
return params.find((param) => param.id === id)?.value;
|
|
206
207
|
}
|
|
207
208
|
|
|
208
|
-
function encodePiModelId(modelId: string, context?: string): string {
|
|
209
|
-
|
|
209
|
+
function encodePiModelId(modelId: string, context?: string, fastOverride?: boolean): string {
|
|
210
|
+
const contextQualified = context ? `${modelId}@${context}` : modelId;
|
|
211
|
+
if (fastOverride === true) return `${contextQualified}:fast`;
|
|
212
|
+
if (fastOverride === false) return `${contextQualified}:slow`;
|
|
213
|
+
return contextQualified;
|
|
210
214
|
}
|
|
211
215
|
|
|
212
|
-
function getModelName(item: ModelListItem, context?: string, alias?: string): string {
|
|
216
|
+
function getModelName(item: ModelListItem, context?: string, alias?: string, fastOverride?: boolean): string {
|
|
213
217
|
const displayName = item.displayName || item.id;
|
|
214
|
-
const
|
|
218
|
+
const qualifiers: string[] = [];
|
|
219
|
+
if (alias) qualifiers.push(alias);
|
|
220
|
+
if (fastOverride === true) qualifiers.push("fast");
|
|
221
|
+
if (fastOverride === false) qualifiers.push("slow");
|
|
222
|
+
const baseName = qualifiers.length > 0 ? `${displayName} (${qualifiers.join(", ")})` : displayName;
|
|
215
223
|
return context ? `${baseName} @ ${context}` : baseName;
|
|
216
224
|
}
|
|
217
225
|
|
|
226
|
+
function getFastOverrideBasePiModelId(piModelId: string): string {
|
|
227
|
+
return piModelId.replace(/:(?:fast|slow)$/, "");
|
|
228
|
+
}
|
|
229
|
+
|
|
218
230
|
function getContextWindow(contextWindowCache: Map<string, number>, piModelId: string, context?: string, baseModelId?: string): number {
|
|
219
|
-
|
|
231
|
+
const fastOverrideBasePiModelId = getFastOverrideBasePiModelId(piModelId);
|
|
232
|
+
const contextWindowOverride =
|
|
220
233
|
contextWindowCache.get(piModelId) ??
|
|
234
|
+
(fastOverrideBasePiModelId !== piModelId ? contextWindowCache.get(fastOverrideBasePiModelId) : undefined);
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
contextWindowOverride ??
|
|
221
238
|
(context ? parseContextWindow(context) : undefined) ??
|
|
222
239
|
(baseModelId ? contextWindowCache.get(baseModelId) : undefined) ??
|
|
223
240
|
contextWindowCache.get("default") ??
|
|
@@ -232,6 +249,7 @@ function toMetadata(
|
|
|
232
249
|
defaultParams: ModelParameterValue[],
|
|
233
250
|
context: string | undefined,
|
|
234
251
|
contextWindowCache: Map<string, number>,
|
|
252
|
+
fastOverride?: boolean,
|
|
235
253
|
): CursorModelMetadata {
|
|
236
254
|
const thinkingLevelMap = getThinkingLevelMap(item);
|
|
237
255
|
const fastValue = getParamValue(defaultParams, "fast")?.toLowerCase();
|
|
@@ -245,6 +263,7 @@ function toMetadata(
|
|
|
245
263
|
contextWindow: getContextWindow(contextWindowCache, piModelId, context, item.id),
|
|
246
264
|
supportsFast: getParameter(item, "fast") !== undefined,
|
|
247
265
|
defaultFast: fastValue === "true",
|
|
266
|
+
...(fastOverride !== undefined ? { fastOverride } : {}),
|
|
248
267
|
supportsReasoning: thinkingLevelMap !== undefined,
|
|
249
268
|
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
|
250
269
|
parameterIds: {
|
|
@@ -310,16 +329,21 @@ function toModelConfigs(
|
|
|
310
329
|
const contexts = contextValues.length > 0 ? contextValues : [undefined];
|
|
311
330
|
const configs: ProviderModelConfig[] = [];
|
|
312
331
|
|
|
332
|
+
const fastOverrides = getParameter(item, "fast") === undefined ? [undefined] : [undefined, true, false];
|
|
333
|
+
|
|
313
334
|
for (const selectionModelId of getModelIds(item, reservedBaseModelIds, ambiguousAliases)) {
|
|
314
335
|
const alias = selectionModelId === item.id ? undefined : selectionModelId;
|
|
315
336
|
for (const context of contexts) {
|
|
316
|
-
const
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
337
|
+
const contextParams = context ? replaceParam(defaultParams, "context", context) : defaultParams;
|
|
338
|
+
for (const fastOverride of fastOverrides) {
|
|
339
|
+
const params = fastOverride === undefined ? contextParams : replaceParam(contextParams, "fast", fastOverride ? "true" : "false");
|
|
340
|
+
const piModelId = encodePiModelId(selectionModelId, context, fastOverride);
|
|
341
|
+
if (usedPiModelIds.has(piModelId)) continue;
|
|
342
|
+
usedPiModelIds.add(piModelId);
|
|
343
|
+
const metadata = toMetadata(item, piModelId, selectionModelId, params, context, contextWindowCache, fastOverride);
|
|
344
|
+
metadataByPiModelId.set(piModelId, metadata);
|
|
345
|
+
configs.push(toModelConfig(metadata, getModelName(item, context, alias, fastOverride)));
|
|
346
|
+
}
|
|
323
347
|
}
|
|
324
348
|
}
|
|
325
349
|
|
package/src/model-list-cache.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { parseEnvBoolean } from "./cursor-env-boolean.js";
|
|
|
8
8
|
const MODEL_LIST_CACHE_FILE = "cursor-sdk-model-list.json";
|
|
9
9
|
const MODEL_LIST_CACHE_VERSION = 1;
|
|
10
10
|
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
const MAX_CACHE_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
11
12
|
const DISABLE_ENV_VAR = "PI_CURSOR_SDK_DISABLE_MODEL_CACHE";
|
|
12
13
|
const TTL_ENV_VAR = "PI_CURSOR_SDK_MODEL_CACHE_TTL_MS";
|
|
13
14
|
|
|
@@ -45,20 +46,83 @@ export function fingerprintApiKey(apiKey: string): string {
|
|
|
45
46
|
return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
50
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isStringArray(value: unknown): value is string[] {
|
|
54
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isModelParameterValue(value: unknown): value is NonNullable<ModelListItem["variants"]>[number]["params"][number] {
|
|
58
|
+
return isRecord(value) && typeof value.id === "string" && typeof value.value === "string";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isModelParameterDefinitionValue(value: unknown): value is NonNullable<ModelListItem["parameters"]>[number]["values"][number] {
|
|
62
|
+
return isRecord(value) && typeof value.value === "string" && (value.displayName === undefined || typeof value.displayName === "string");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isModelParameterDefinition(value: unknown): value is NonNullable<ModelListItem["parameters"]>[number] {
|
|
66
|
+
if (!isRecord(value)) return false;
|
|
67
|
+
return (
|
|
68
|
+
typeof value.id === "string" &&
|
|
69
|
+
(value.displayName === undefined || typeof value.displayName === "string") &&
|
|
70
|
+
Array.isArray(value.values) &&
|
|
71
|
+
value.values.every(isModelParameterDefinitionValue)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isModelVariant(value: unknown): value is NonNullable<ModelListItem["variants"]>[number] {
|
|
76
|
+
if (!isRecord(value)) return false;
|
|
77
|
+
return (
|
|
78
|
+
Array.isArray(value.params) &&
|
|
79
|
+
value.params.every(isModelParameterValue) &&
|
|
80
|
+
typeof value.displayName === "string" &&
|
|
81
|
+
(value.description === undefined || typeof value.description === "string") &&
|
|
82
|
+
(value.isDefault === undefined || typeof value.isDefault === "boolean")
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isModelListItem(value: unknown): value is ModelListItem {
|
|
87
|
+
if (!isRecord(value)) return false;
|
|
88
|
+
return (
|
|
89
|
+
typeof value.id === "string" &&
|
|
90
|
+
typeof value.displayName === "string" &&
|
|
91
|
+
(value.description === undefined || typeof value.description === "string") &&
|
|
92
|
+
(value.aliases === undefined || isStringArray(value.aliases)) &&
|
|
93
|
+
(value.parameters === undefined || (Array.isArray(value.parameters) && value.parameters.every(isModelParameterDefinition))) &&
|
|
94
|
+
(value.variants === undefined || (Array.isArray(value.variants) && value.variants.every(isModelVariant)))
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isValidFetchedAt(value: unknown): value is number {
|
|
99
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= Date.now() + MAX_CACHE_CLOCK_SKEW_MS;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseModelListCacheFile(value: unknown): ModelListCacheFile | undefined {
|
|
103
|
+
if (!isRecord(value)) return undefined;
|
|
104
|
+
if (
|
|
105
|
+
value.version !== MODEL_LIST_CACHE_VERSION ||
|
|
106
|
+
!isValidFetchedAt(value.fetchedAt) ||
|
|
107
|
+
typeof value.keyFingerprint !== "string" ||
|
|
108
|
+
!Array.isArray(value.models) ||
|
|
109
|
+
!value.models.every(isModelListItem)
|
|
110
|
+
) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
version: value.version,
|
|
115
|
+
fetchedAt: value.fetchedAt,
|
|
116
|
+
keyFingerprint: value.keyFingerprint,
|
|
117
|
+
models: value.models,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
48
121
|
function readCacheFile(): ModelListCacheFile | undefined {
|
|
49
122
|
const path = getCachePath();
|
|
50
123
|
if (!existsSync(path)) return undefined;
|
|
51
124
|
try {
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
parsed.version !== MODEL_LIST_CACHE_VERSION ||
|
|
55
|
-
typeof parsed.fetchedAt !== "number" ||
|
|
56
|
-
typeof parsed.keyFingerprint !== "string" ||
|
|
57
|
-
!Array.isArray(parsed.models)
|
|
58
|
-
) {
|
|
59
|
-
return undefined;
|
|
60
|
-
}
|
|
61
|
-
return parsed;
|
|
125
|
+
return parseModelListCacheFile(JSON.parse(readFileSync(path, "utf-8")));
|
|
62
126
|
} catch {
|
|
63
127
|
return undefined;
|
|
64
128
|
}
|