@tduyng/pi-cursor 0.0.1 → 0.0.3
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/README.md +71 -41
- package/package.json +2 -2
- package/src/catalog.ts +69 -0
- package/src/index.ts +35 -5
- package/src/models.ts +198 -0
- package/src/resume.ts +81 -0
- package/src/session.ts +136 -27
- package/src/state.ts +43 -0
- package/src/transcript.ts +4 -6
- package/src/turn.ts +67 -14
package/README.md
CHANGED
|
@@ -1,25 +1,21 @@
|
|
|
1
1
|
# pi-cursor
|
|
2
2
|
|
|
3
|
-
Cursor models in
|
|
3
|
+
Cursor models in pi, through the official Cursor SDK. Pi keeps the prompt, the tools, the approvals, the compaction, and the session tree.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## What it costs
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
No tools, no prompt block, no schema. It is a model provider, so it adds nothing to the context and nothing to the cold start.
|
|
8
|
+
|
|
9
|
+
What it saves on the wire is the replay. A normal turn sends only the new user message, a tool round sends nothing, and a restarted pi process resumes the same Cursor agent instead of replaying the conversation.
|
|
8
10
|
|
|
9
11
|
## Requirements
|
|
10
12
|
|
|
11
13
|
- Pi 1.4 or newer.
|
|
12
14
|
- Node 24 or newer.
|
|
13
15
|
- A Cursor account.
|
|
14
|
-
-
|
|
16
|
+
- `@cursor/sdk` 1.0.40, installed by the workspace.
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
```sh
|
|
19
|
-
pi install /path/to/extensions/pi-cursor
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
In this repository the workspace already registers the extension in the root `package.json`.
|
|
18
|
+
No Cursor CLI is installed or called. Login is the SDK browser flow, which mints a stored Cursor key under `~/.cursor/sdk/auth.json`.
|
|
23
19
|
|
|
24
20
|
## Login
|
|
25
21
|
|
|
@@ -27,11 +23,11 @@ In this repository the workspace already registers the extension in the root `pa
|
|
|
27
23
|
/cursor login
|
|
28
24
|
```
|
|
29
25
|
|
|
30
|
-
The SDK opens the Cursor login page and mints a key when the
|
|
26
|
+
The SDK opens the Cursor login page and mints a key when the flow finishes. If the browser does not open, the command prints the URL. `/cursor status` shows the account and `/cursor logout` removes the stored key.
|
|
31
27
|
|
|
32
|
-
`CURSOR_API_KEY` overrides the stored login. The key is read by the SDK
|
|
28
|
+
`CURSOR_API_KEY` overrides the stored login. The key is read by the SDK and not copied into pi's `auth.json`.
|
|
33
29
|
|
|
34
|
-
Cursor bills SDK traffic under its own terms
|
|
30
|
+
Cursor bills SDK traffic under its own terms, and this extension cannot tell you whether a run is plan-included. Check the Cursor dashboard for the account's SDK usage.
|
|
35
31
|
|
|
36
32
|
## Use
|
|
37
33
|
|
|
@@ -39,50 +35,84 @@ Cursor bills SDK traffic under its own terms. This extension cannot tell you whe
|
|
|
39
35
|
/model cursor/gpt-5.5
|
|
40
36
|
```
|
|
41
37
|
|
|
42
|
-
The
|
|
38
|
+
The provider id is `cursor` and model ids are Cursor's own. The list comes from `Cursor.models.list()`, cached after the last successful read in `~/.pi/agent/pi-cursor-models.json` at mode 0600 and keyed by the credential. Startup registers the cache with no network call, session start refreshes it, and a small built-in list is the fallback.
|
|
39
|
+
|
|
40
|
+
A model that offers several context windows is registered once per window: the plain id uses the smallest one, and `cursor/gpt-5.5@1m` asks for 1M. Cursor bills the larger windows as Max Mode, so the small default is the cheap one. `/cursor models` prints them.
|
|
41
|
+
|
|
42
|
+
`/cursor fast on` adds Cursor's `fast` parameter to every model that exposes it. It is a billing choice, so it stays off until you ask. `/cursor status` shows the current setting.
|
|
43
43
|
|
|
44
44
|
## How it works
|
|
45
45
|
|
|
46
|
-
| Pi
|
|
47
|
-
|
|
|
48
|
-
| Pi system prompt
|
|
49
|
-
| Pi tools
|
|
50
|
-
| Cursor built-ins
|
|
51
|
-
| Cursor rules, skills, MCP | `local.settingSources: []`
|
|
52
|
-
| Pi tool execution
|
|
46
|
+
| Pi | Cursor SDK |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| Pi system prompt | `AgentOptions.systemPrompt`, replacing the Cursor harness prompt |
|
|
49
|
+
| Pi tools | `LocalAgentOptions.customTools`, one in-process callback per tool |
|
|
50
|
+
| Cursor built-ins | `AgentOptions.tools: ['mcp']`, so only pi's custom tools exist |
|
|
51
|
+
| Cursor rules, skills, MCP | `local.settingSources: []` |
|
|
52
|
+
| Pi tool execution | The callback parks, pi runs the tool, the next provider call resolves it |
|
|
53
|
+
|
|
54
|
+
A tool call does not end the Cursor run. The callback waits, pi executes the named tool through its normal pipeline, and the next `streamSimple` call resolves the callback with the result. The same run continues, so the model sees the result in the same conversation.
|
|
55
|
+
|
|
56
|
+
A message you steer in while a tool runs is delivered with `Run.steer`, so the model reads it before its next step. Images attached to such a message become a one-line note, because steering carries text only.
|
|
57
|
+
|
|
58
|
+
One agent runs per pi session. The agent is replaced only when the prompt, tool set, or working directory changes, or when the pi history diverges, and that replacement replays the conversation once as a single message.
|
|
59
|
+
|
|
60
|
+
A restarted pi process resumes the same agent instead of replaying. At the end of every clean turn the extension records the agent id with the working folder, prompt and tool hashes, the pi message count, and its own hash of that prefix, in `~/.pi/agent/pi-cursor-agents.json`. The next process calls `Agent.resume` when all of those still match and the pending turn is a single new user message. A stale id, a diverged fork, or a turn killed mid tool round falls back to the replay. The ledger keeps the last 20 sessions, and `/cursor clear`, `/cursor login`, and `/cursor logout` delete it, so those commands really do start from a fresh agent.
|
|
61
|
+
|
|
62
|
+
There is no HTTP MCP server, no Hono server, and no tool-name rewriting: the SDK invokes the callback directly.
|
|
63
|
+
|
|
64
|
+
## What the wire costs
|
|
53
65
|
|
|
54
|
-
|
|
66
|
+
Measured on the account this extension was built on, with gpt-5.5 and auto-smart, thinking off:
|
|
55
67
|
|
|
56
|
-
|
|
68
|
+
| Call | Input tokens | Wall time |
|
|
69
|
+
| --- | --- | --- |
|
|
70
|
+
| First call of an agent | 11.9k fresh plus 2.6k cached | 4.8 to 5.5s |
|
|
71
|
+
| Plain follow-up turn | 12.9k fresh plus 12.7k cached | 2.2s |
|
|
72
|
+
| Call after a tool round | 36.9k fresh plus 36.0k cached | 4.4 to 13s |
|
|
57
73
|
|
|
58
|
-
|
|
74
|
+
The tool-round number is Cursor's, not the tool result. The SDK re-prefills its own context around the custom tool callback, so one tool call costs about three plain calls. A turn that ends in a tool call is the slow turn, and there is no pi-side switch for it. The same 32k fresh appears with an empty working directory, so it is not workspace scanning. Fewer, wider tool calls per turn is the lever that exists.
|
|
59
75
|
|
|
60
|
-
|
|
76
|
+
Thinking levels map to Cursor model parameters: `thinking` and `effort` for Claude models, `reasoning` for GPT, `effort` for Grok, from the live catalog.
|
|
61
77
|
|
|
62
|
-
|
|
78
|
+
## Limits
|
|
63
79
|
|
|
64
|
-
|
|
80
|
+
- Cursor owns the agent loop and its context management, so pi's compaction does not reach inside a live agent.
|
|
65
81
|
|
|
66
|
-
|
|
82
|
+
- `AgentOptions.systemPrompt` is server-gated. A rejected prompt comes back as a failed run about 20 seconds in, not from `Agent.create`, so the extension retries that turn with the prompt prepended to the first message.
|
|
67
83
|
|
|
68
|
-
-
|
|
69
|
-
- `AgentOptions.systemPrompt` is server-gated. If Cursor rejects it, the extension recreates the agent without it and prepends Pi's prompt to the first message. That fallback works but spends more tokens because Cursor's harness prompt remains.
|
|
70
|
-
- Usage tokens come from Cursor's `turn-ended` events. Pi shows a zero money cost because Cursor bills outside Pi.
|
|
71
|
-
- Images work in new messages. Replayed history carries images without their original message labels.
|
|
72
|
-
- A model id that Cursor no longer serves fails at `send`, not at model selection.
|
|
73
|
-
- A long Pi tool run is held open by Cursor local runtime. The SDK does not document a timeout for custom tools.
|
|
74
|
-
- The Cursor SDK is a 25 MB dependency with optional platform packages.
|
|
84
|
+
- The rejection is recorded as `noSystemPrompt` in `~/.pi/agent/pi-cursor.json`, and every later agent skips the doomed attempt. Clear it with `/cursor login` or by editing the file, and `/cursor status` shows which path is in use. The fallback works but spends more tokens, because the Cursor harness prompt remains.
|
|
75
85
|
|
|
76
|
-
|
|
86
|
+
- A failed run reaches pi as an error message, so an empty answer means a broken transport rather than a silent backend rejection.
|
|
87
|
+
|
|
88
|
+
- Usage tokens come from Cursor's `turn-ended` events plus the run's own total, and pi records each message's share, so the numbers add across a tool round. A message that ends in a tool call shows zero, because that step has not ended server side, and the next message carries both. Pi shows zero money cost because Cursor bills outside pi.
|
|
89
|
+
|
|
90
|
+
- Images ride with the message that carries them. A replay replaces earlier images with an `[image: mime]` placeholder and keeps only the pending message's images, so reattach one to ask about it again after a restart.
|
|
91
|
+
|
|
92
|
+
- A model id Cursor no longer serves fails at `send`, not at selection.
|
|
93
|
+
|
|
94
|
+
- A long pi tool run is held open by the Cursor local runtime, and the SDK documents no timeout for custom tools.
|
|
95
|
+
|
|
96
|
+
- The SDK is a 25 MB dependency with optional platform packages.
|
|
97
|
+
|
|
98
|
+
## Configure
|
|
99
|
+
|
|
100
|
+
`CURSOR_API_KEY` overrides the stored login.
|
|
101
|
+
|
|
102
|
+
`~/.pi/agent/pi-cursor.json` holds the two preferences: `fast`, from `/cursor fast`, and `noSystemPrompt`, once Cursor has refused pi's prompt for this account.
|
|
103
|
+
|
|
104
|
+
## Install
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
pi install npm:@tduyng/pi-cursor
|
|
108
|
+
```
|
|
77
109
|
|
|
78
|
-
|
|
79
|
-
| ---------------- | ----------------------------------------------- |
|
|
80
|
-
| `CURSOR_API_KEY` | Cursor API key. Overrides the stored SDK login. |
|
|
110
|
+
Local checkout: `pi install /path/to/dotfiles/pi/agent/extensions/pi-cursor`
|
|
81
111
|
|
|
82
112
|
## Tests
|
|
83
113
|
|
|
84
114
|
```sh
|
|
85
|
-
|
|
115
|
+
bun test extensions/pi-cursor
|
|
86
116
|
bun run typecheck
|
|
87
117
|
bun run lint:anti-slop
|
|
88
118
|
```
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tduyng/pi-cursor",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "Cursor models in Pi through the official Cursor SDK: one warm agent per session, Pi's own prompt and tools, resumed instead of replayed.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
7
7
|
"pi-package",
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { getAgentDir } from '@earendil-works/pi-coding-agent'
|
|
5
|
+
import { Validator, type Infer } from 'ata-validator'
|
|
6
|
+
import { t } from 'ata-validator/t'
|
|
7
|
+
|
|
8
|
+
import type { ModelInfo } from './models.js'
|
|
9
|
+
|
|
10
|
+
const ParameterValue = t.object({ value: t.string(), displayName: t.optional(t.string()) })
|
|
11
|
+
const Parameter = t.object({
|
|
12
|
+
id: t.string(),
|
|
13
|
+
displayName: t.optional(t.string()),
|
|
14
|
+
values: t.array(ParameterValue)
|
|
15
|
+
})
|
|
16
|
+
const Variant = t.object({
|
|
17
|
+
params: t.array(t.object({ id: t.string(), value: t.string() })),
|
|
18
|
+
displayName: t.string(),
|
|
19
|
+
isDefault: t.optional(t.boolean())
|
|
20
|
+
})
|
|
21
|
+
const CachedModel = t.object({
|
|
22
|
+
id: t.string(),
|
|
23
|
+
displayName: t.string(),
|
|
24
|
+
parameters: t.optional(t.array(Parameter)),
|
|
25
|
+
variants: t.optional(t.array(Variant))
|
|
26
|
+
})
|
|
27
|
+
const CachedCatalog = t.object({ key: t.string(), models: t.array(CachedModel) })
|
|
28
|
+
|
|
29
|
+
const isCatalog = new Validator(CachedCatalog).isValidObject
|
|
30
|
+
|
|
31
|
+
export type KnownCatalog = Infer<typeof CachedCatalog>
|
|
32
|
+
|
|
33
|
+
function cachePath(): string {
|
|
34
|
+
return join(getAgentDir(), 'pi-cursor-models.json')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseCatalog(text: string, key: string): ModelInfo[] | undefined {
|
|
38
|
+
try {
|
|
39
|
+
const parsed: unknown = JSON.parse(text)
|
|
40
|
+
if (!isCatalog(parsed) || parsed.key !== key) return undefined
|
|
41
|
+
return parsed.models
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readCatalog(key: string): ModelInfo[] | undefined {
|
|
48
|
+
try {
|
|
49
|
+
return parseCatalog(readFileSync(cachePath(), 'utf8'), key)
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function writeCatalog(key: string, models: ModelInfo[]): void {
|
|
56
|
+
const path = cachePath()
|
|
57
|
+
const temp = `${path}.${process.pid}.tmp`
|
|
58
|
+
const catalog: KnownCatalog = {
|
|
59
|
+
key,
|
|
60
|
+
models: models.map((model) => ({
|
|
61
|
+
id: model.id,
|
|
62
|
+
displayName: model.displayName,
|
|
63
|
+
parameters: model.parameters,
|
|
64
|
+
variants: model.variants
|
|
65
|
+
}))
|
|
66
|
+
}
|
|
67
|
+
writeFileSync(temp, JSON.stringify(catalog), { mode: 0o600 })
|
|
68
|
+
renameSync(temp, path)
|
|
69
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,10 @@ import type { SdkLoginOptions } from '@cursor/sdk'
|
|
|
2
2
|
import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'
|
|
3
3
|
|
|
4
4
|
import { fallbackModels, rememberModels, toPiModels, type PiModelEntry } from './models.js'
|
|
5
|
+
import { readCatalog, writeCatalog } from './catalog.js'
|
|
5
6
|
import { cursorApiKey, cursorSdk } from './sdk.js'
|
|
7
|
+
import { dropAllRecords } from './resume.js'
|
|
8
|
+
import { noSystemPrompt, patchState } from './state.js'
|
|
6
9
|
import { allSessions, dropSessions, sweep } from './session.js'
|
|
7
10
|
import { createRuntime, streamCursor } from './turn.js'
|
|
8
11
|
|
|
@@ -12,7 +15,10 @@ const IDLE_MS = 20 * 60_000
|
|
|
12
15
|
|
|
13
16
|
export default function piCursor(pi: ExtensionAPI): void {
|
|
14
17
|
const runtime = createRuntime()
|
|
15
|
-
|
|
18
|
+
const catalogKey = () => cursorApiKey() ?? 'stored-login'
|
|
19
|
+
const cached = readCatalog(catalogKey())
|
|
20
|
+
let models: PiModelEntry[] = cached && cached.length > 0 ? toPiModels(cached) : fallbackModels()
|
|
21
|
+
if (cached && cached.length > 0) rememberModels(cached)
|
|
16
22
|
let refreshing: Promise<PiModelEntry[]> | undefined
|
|
17
23
|
let refreshed = false
|
|
18
24
|
let sweeper: ReturnType<typeof setInterval> | undefined
|
|
@@ -36,8 +42,9 @@ export default function piCursor(pi: ExtensionAPI): void {
|
|
|
36
42
|
const { Cursor } = await cursorSdk()
|
|
37
43
|
const items = await Cursor.models.list({ apiKey: cursorApiKey() })
|
|
38
44
|
rememberModels(items)
|
|
45
|
+
writeCatalog(catalogKey(), items)
|
|
39
46
|
const next = toPiModels(items)
|
|
40
|
-
if (next.length > 0)
|
|
47
|
+
if (next.length > 0) models = next
|
|
41
48
|
return models
|
|
42
49
|
} catch {
|
|
43
50
|
return models
|
|
@@ -82,7 +89,9 @@ export default function piCursor(pi: ExtensionAPI): void {
|
|
|
82
89
|
}
|
|
83
90
|
if (ctx.signal) options.signal = ctx.signal
|
|
84
91
|
const result = await Cursor.auth.login(options)
|
|
85
|
-
|
|
92
|
+
patchState({ noSystemPrompt: false })
|
|
93
|
+
dropAllRecords()
|
|
94
|
+
register(await refresh())
|
|
86
95
|
await show(
|
|
87
96
|
`Cursor: logged in${result.email ? ` as ${result.email}` : ''}. ${models.length} model(s) available.`
|
|
88
97
|
)
|
|
@@ -97,6 +106,7 @@ export default function piCursor(pi: ExtensionAPI): void {
|
|
|
97
106
|
{ value: 'logout', label: 'logout', description: 'forget the SDK credential' },
|
|
98
107
|
{ value: 'models', label: 'models', description: 'every Cursor model the provider exposes' },
|
|
99
108
|
{ value: 'clear', label: 'clear', description: 'close every warm Cursor agent' },
|
|
109
|
+
{ value: 'fast', label: 'fast', description: 'toggle cursor fast mode: fast on|off' },
|
|
100
110
|
{ value: 'help', label: 'help', description: 'usage' }
|
|
101
111
|
].filter((item) => item.value.startsWith(prefix.trim())),
|
|
102
112
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
@@ -108,6 +118,7 @@ export default function piCursor(pi: ExtensionAPI): void {
|
|
|
108
118
|
if (sub === 'logout') {
|
|
109
119
|
const { Cursor } = await cursorSdk()
|
|
110
120
|
await Cursor.auth.logout()
|
|
121
|
+
dropAllRecords()
|
|
111
122
|
rememberModels([])
|
|
112
123
|
register(fallbackModels())
|
|
113
124
|
await show('Cursor: logged out. The SDK credential is gone.')
|
|
@@ -115,9 +126,21 @@ export default function piCursor(pi: ExtensionAPI): void {
|
|
|
115
126
|
}
|
|
116
127
|
if (sub === 'clear') {
|
|
117
128
|
dropSessions(runtime.sessions)
|
|
129
|
+
dropAllRecords()
|
|
118
130
|
await show('Cursor: closed every warm agent.')
|
|
119
131
|
return
|
|
120
132
|
}
|
|
133
|
+
if (sub === 'fast') {
|
|
134
|
+
const value = args.trim().split(/\s+/).filter(Boolean)[1]
|
|
135
|
+
if (value !== 'on' && value !== 'off') {
|
|
136
|
+
await show(`Cursor fast mode is ${runtime.fast ? 'on' : 'off'}. Use /cursor fast on|off.`)
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
runtime.fast = value === 'on'
|
|
140
|
+
patchState({ fast: runtime.fast })
|
|
141
|
+
await show(`Cursor fast mode ${runtime.fast ? 'on' : 'off'} for models that expose it.`)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
121
144
|
if (sub === 'models') {
|
|
122
145
|
await show(
|
|
123
146
|
[
|
|
@@ -130,12 +153,13 @@ export default function piCursor(pi: ExtensionAPI): void {
|
|
|
130
153
|
if (sub === 'help') {
|
|
131
154
|
await show(
|
|
132
155
|
[
|
|
133
|
-
'Usage: /cursor [status|login|logout|models|clear|help]',
|
|
156
|
+
'Usage: /cursor [status|login|logout|models|clear|fast|help]',
|
|
134
157
|
'',
|
|
135
158
|
'Login uses the official Cursor SDK browser flow and mints a stored API key.',
|
|
136
159
|
'No Cursor CLI is installed or called.',
|
|
137
160
|
'Pick a model with /model, for example cursor/gpt-5.5.',
|
|
138
|
-
'CURSOR_API_KEY overrides the stored SDK login.'
|
|
161
|
+
'CURSOR_API_KEY overrides the stored SDK login.',
|
|
162
|
+
'Models default to their smallest context window. Pick /model cursor/gpt-5.5@1m for the 1M one.',
|
|
139
163
|
].join('\n')
|
|
140
164
|
)
|
|
141
165
|
return
|
|
@@ -157,6 +181,12 @@ export default function piCursor(pi: ExtensionAPI): void {
|
|
|
157
181
|
`credential: ${cursorApiKey() ? 'CURSOR_API_KEY' : 'stored SDK login'}`,
|
|
158
182
|
`models: ${models.length}`,
|
|
159
183
|
`live agents: ${sessions.length}, parked tool calls: ${parked}`,
|
|
184
|
+
`system prompt: ${
|
|
185
|
+
noSystemPrompt()
|
|
186
|
+
? 'not enabled for this account, Pi prompt rides in the first message'
|
|
187
|
+
: 'sent as AgentOptions.systemPrompt'
|
|
188
|
+
}`,
|
|
189
|
+
`fast mode: ${runtime.fast ? 'on' : 'off'}`,
|
|
160
190
|
'prompt: Pi system prompt and Pi tools only; Cursor built-in tools disabled'
|
|
161
191
|
].join('\n')
|
|
162
192
|
)
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { ModelListItem, ModelParameterDefinition, ModelParameterValue } from '@cursor/sdk'
|
|
2
|
+
import type { ModelThinkingLevel, ThinkingLevelMap } from '@earendil-works/pi-ai'
|
|
3
|
+
|
|
4
|
+
export type PiModelEntry = {
|
|
5
|
+
id: string
|
|
6
|
+
name: string
|
|
7
|
+
reasoning: boolean
|
|
8
|
+
thinkingLevelMap?: ThinkingLevelMap
|
|
9
|
+
input: ('text' | 'image')[]
|
|
10
|
+
cost: { input: number; output: number; cacheRead: number; cacheWrite: number }
|
|
11
|
+
contextWindow: number
|
|
12
|
+
maxTokens: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ModelSelection = { id: string; params?: ModelParameterValue[] }
|
|
16
|
+
|
|
17
|
+
type ThinkingParams = { boolean?: ModelParameterDefinition; levels?: ModelParameterDefinition }
|
|
18
|
+
|
|
19
|
+
const LEVELS: ModelThinkingLevel[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
|
|
20
|
+
export type ModelInfo = Pick<ModelListItem, 'id' | 'displayName' | 'parameters' | 'variants'>
|
|
21
|
+
|
|
22
|
+
const catalog = new Map<string, ModelInfo>()
|
|
23
|
+
|
|
24
|
+
const FALLBACK_MODELS: PiModelEntry[] = [
|
|
25
|
+
entry('auto-smart', 'Auto'),
|
|
26
|
+
entry('claude-opus-4-8', 'Claude Opus 4.8', 300_000),
|
|
27
|
+
entry('claude-sonnet-5-5', 'Claude Sonnet 5.5'),
|
|
28
|
+
entry('gpt-5.5', 'GPT-5.5', 272_000),
|
|
29
|
+
entry('grok-4.6', 'Grok 4.6', 256_000),
|
|
30
|
+
entry('composer-2.5', 'Composer 2.5'),
|
|
31
|
+
entry('gemini-3.7-flash', 'Gemini 3.7 Flash')
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
function entry(id: string, name: string, contextWindow = 200_000): PiModelEntry {
|
|
35
|
+
return {
|
|
36
|
+
id,
|
|
37
|
+
name,
|
|
38
|
+
reasoning: false,
|
|
39
|
+
input: ['text', 'image'],
|
|
40
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
41
|
+
contextWindow,
|
|
42
|
+
maxTokens: 32_768
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function rememberModels(items: ModelInfo[]): void {
|
|
47
|
+
catalog.clear()
|
|
48
|
+
for (const item of items) catalog.set(item.id, item)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function fallbackModels(): PiModelEntry[] {
|
|
52
|
+
return FALLBACK_MODELS.map((model) => ({ ...model }))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function toPiModels(items: ModelInfo[]): PiModelEntry[] {
|
|
56
|
+
const entries: PiModelEntry[] = []
|
|
57
|
+
for (const item of items) {
|
|
58
|
+
const contexts = contextValues(item)
|
|
59
|
+
entries.push(toPiModel(item, undefined, contexts[0]))
|
|
60
|
+
for (const context of contexts.slice(1)) entries.push(toPiModel(item, context, context))
|
|
61
|
+
}
|
|
62
|
+
return entries
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type ModelIdParts = { base: string; context?: string }
|
|
66
|
+
|
|
67
|
+
export function parseModelId(id: string): ModelIdParts {
|
|
68
|
+
const match = /^(.+)@(\d+[km])$/.exec(id)
|
|
69
|
+
if (!match?.[1] || !match[2]) return { base: id }
|
|
70
|
+
return { base: match[1], context: match[2] }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function contextValues(item: ModelInfo): string[] {
|
|
74
|
+
const values = item.parameters?.find((candidate) => candidate.id === 'context')?.values.map((value) => value.value)
|
|
75
|
+
if (!values) return []
|
|
76
|
+
return values.toSorted((left, right) => contextSize(left) - contextSize(right))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function contextSize(context: string): number {
|
|
80
|
+
const match = /^(\d+)([km])$/.exec(context)
|
|
81
|
+
if (!match?.[1] || !match[2]) return Number.MAX_SAFE_INTEGER
|
|
82
|
+
const size = Number(match[1])
|
|
83
|
+
return match[2] === 'm' ? size * 1_000_000 : size * 1_000
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function contextWindowOf(id: string): number {
|
|
87
|
+
const { base, context } = parseModelId(id)
|
|
88
|
+
if (context) return contextSize(context)
|
|
89
|
+
return contextWindowFor(base)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function toPiModel(item: ModelInfo, suffix: string | undefined, window: string | undefined): PiModelEntry {
|
|
93
|
+
const params = thinkingParams(item)
|
|
94
|
+
const id = suffix ? `${item.id}@${suffix}` : item.id
|
|
95
|
+
const displayName = item.displayName || item.id
|
|
96
|
+
const name = suffix ? `${displayName} ${suffix.toUpperCase()}` : displayName
|
|
97
|
+
const model = entry(id, name, window ? contextSize(window) : contextWindowFor(item.id))
|
|
98
|
+
if (params) {
|
|
99
|
+
model.reasoning = true
|
|
100
|
+
model.thinkingLevelMap = thinkingLevelMap(params)
|
|
101
|
+
}
|
|
102
|
+
return model
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function thinkingParams(item: ModelInfo): ThinkingParams | undefined {
|
|
106
|
+
const boolean = item.parameters?.find(
|
|
107
|
+
(candidate) =>
|
|
108
|
+
candidate.id === 'thinking' &&
|
|
109
|
+
candidate.values.length > 0 &&
|
|
110
|
+
candidate.values.every((value) => value.value === 'true' || value.value === 'false')
|
|
111
|
+
)
|
|
112
|
+
const levels =
|
|
113
|
+
item.parameters?.find((candidate) => candidate.id === 'effort' && candidate.values.length > 0) ??
|
|
114
|
+
item.parameters?.find((candidate) => candidate.id === 'reasoning' && candidate.values.length > 0)
|
|
115
|
+
if (!boolean && !levels) return undefined
|
|
116
|
+
return { boolean, levels }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function thinkingLevelMap(params: ThinkingParams): ThinkingLevelMap | undefined {
|
|
120
|
+
const map: ThinkingLevelMap = {}
|
|
121
|
+
let any = false
|
|
122
|
+
for (const level of LEVELS) {
|
|
123
|
+
let value: string | undefined
|
|
124
|
+
if (level === 'off') value = params.boolean ? 'false' : mapLevel(params.levels, level)
|
|
125
|
+
else if (params.levels) value = mapLevel(params.levels, level)
|
|
126
|
+
else value = params.boolean ? 'true' : undefined
|
|
127
|
+
map[level] = value ?? null
|
|
128
|
+
if (value) any = true
|
|
129
|
+
}
|
|
130
|
+
return any ? map : undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function mapLevel(param: ModelParameterDefinition | undefined, level: ModelThinkingLevel): string | undefined {
|
|
134
|
+
const values = param?.values.map((value) => value.value) ?? []
|
|
135
|
+
if (level === 'off') return values.find((value) => value === 'none' || value === 'false' || value === 'off')
|
|
136
|
+
if (level === 'minimal') {
|
|
137
|
+
return values.find((value) => value === 'minimal') ?? values.find((value) => value === 'low')
|
|
138
|
+
}
|
|
139
|
+
if (level === 'low') return values.find((value) => value === 'low')
|
|
140
|
+
if (level === 'medium') return values.find((value) => value === 'medium')
|
|
141
|
+
if (level === 'high') return values.find((value) => value === 'high')
|
|
142
|
+
if (level === 'xhigh') {
|
|
143
|
+
return values.find((value) => value === 'xhigh') ?? values.find((value) => value === 'extra-high')
|
|
144
|
+
}
|
|
145
|
+
return (
|
|
146
|
+
values.find((value) => value === 'max') ??
|
|
147
|
+
values.find((value) => value === 'xhigh') ??
|
|
148
|
+
values.find((value) => value === 'extra-high')
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function selectionFor(id: string, level: ModelThinkingLevel | undefined, fast = false): ModelSelection {
|
|
153
|
+
const { base, context } = parseModelId(id)
|
|
154
|
+
const item = catalog.get(base)
|
|
155
|
+
if (!item) return { id: base }
|
|
156
|
+
const variant = item.variants?.find((candidate) => candidate.isDefault) ?? item.variants?.[0]
|
|
157
|
+
const ordered = variant?.params.map((param) => param.id) ?? []
|
|
158
|
+
const values = new Map<string, string>()
|
|
159
|
+
for (const param of variant?.params ?? []) values.set(param.id, param.value)
|
|
160
|
+
const chosenContext = context ?? contextValues(item)[0]
|
|
161
|
+
if (chosenContext) {
|
|
162
|
+
values.set('context', chosenContext)
|
|
163
|
+
if (!ordered.includes('context')) ordered.push('context')
|
|
164
|
+
}
|
|
165
|
+
if (fast && item.parameters?.some((candidate) => candidate.id === 'fast')) {
|
|
166
|
+
values.set('fast', 'true')
|
|
167
|
+
if (!ordered.includes('fast')) ordered.push('fast')
|
|
168
|
+
}
|
|
169
|
+
const params = thinkingParams(item)
|
|
170
|
+
if (params && level) {
|
|
171
|
+
if (params.boolean) {
|
|
172
|
+
values.set(params.boolean.id, level === 'off' ? 'false' : 'true')
|
|
173
|
+
if (!ordered.includes(params.boolean.id)) ordered.push(params.boolean.id)
|
|
174
|
+
}
|
|
175
|
+
if (params.levels) {
|
|
176
|
+
const value = mapLevel(params.levels, level)
|
|
177
|
+
if (value) values.set(params.levels.id, value)
|
|
178
|
+
if (!ordered.includes(params.levels.id)) ordered.push(params.levels.id)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const result: ModelParameterValue[] = []
|
|
182
|
+
for (const key of ordered) {
|
|
183
|
+
const value = values.get(key)
|
|
184
|
+
if (value !== undefined) result.push({ id: key, value })
|
|
185
|
+
}
|
|
186
|
+
return result.length > 0 ? { id: base, params: result } : { id: base }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function contextWindowFor(id: string): number {
|
|
190
|
+
if (id.startsWith('gpt-5')) return 272_000
|
|
191
|
+
if (id.startsWith('grok')) return 256_000
|
|
192
|
+
return 200_000
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function modelName(id: string): string {
|
|
196
|
+
const { base } = parseModelId(id)
|
|
197
|
+
return catalog.get(base)?.displayName ?? FALLBACK_MODELS.find((model) => model.id === base)?.name ?? id
|
|
198
|
+
}
|
package/src/resume.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { getAgentDir } from '@earendil-works/pi-coding-agent'
|
|
5
|
+
import { Validator, type Infer } from 'ata-validator'
|
|
6
|
+
import { t } from 'ata-validator/t'
|
|
7
|
+
|
|
8
|
+
import type { SessionKey } from './session.js'
|
|
9
|
+
import type { Transcript, TurnMessage } from './transcript.js'
|
|
10
|
+
|
|
11
|
+
const ResumeRecord = t.object({
|
|
12
|
+
sessionId: t.string(),
|
|
13
|
+
agentId: t.string(),
|
|
14
|
+
cwd: t.string(),
|
|
15
|
+
toolsHash: t.string(),
|
|
16
|
+
count: t.number(),
|
|
17
|
+
chain: t.string(),
|
|
18
|
+
at: t.number()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const ResumeLedger = t.object({ records: t.array(ResumeRecord) })
|
|
22
|
+
|
|
23
|
+
const isLedger = new Validator(ResumeLedger).isValidObject
|
|
24
|
+
|
|
25
|
+
export type AgentRecord = Infer<typeof ResumeRecord>
|
|
26
|
+
|
|
27
|
+
const KEEP = 20
|
|
28
|
+
|
|
29
|
+
function ledgerPath(): string {
|
|
30
|
+
return join(getAgentDir(), 'pi-cursor-agents.json')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readLedger(): AgentRecord[] {
|
|
34
|
+
try {
|
|
35
|
+
const parsed: unknown = JSON.parse(readFileSync(ledgerPath(), 'utf8'))
|
|
36
|
+
return isLedger(parsed) ? parsed.records : []
|
|
37
|
+
} catch {
|
|
38
|
+
return []
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function dropAllRecords(): void {
|
|
43
|
+
rmSync(ledgerPath(), { force: true })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readRecord(sessionId: string): AgentRecord | undefined {
|
|
47
|
+
return readLedger().findLast((record) => record.sessionId === sessionId)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function writeRecord(record: AgentRecord): void {
|
|
51
|
+
const records = readLedger().filter((candidate) => candidate.sessionId !== record.sessionId)
|
|
52
|
+
records.push(record)
|
|
53
|
+
const path = ledgerPath()
|
|
54
|
+
const temp = `${path}.${process.pid}.tmp`
|
|
55
|
+
const kept = records.toSorted((left, right) => left.at - right.at).slice(-KEEP)
|
|
56
|
+
writeFileSync(temp, JSON.stringify({ records: kept }), { mode: 0o600 })
|
|
57
|
+
renameSync(temp, path)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function dropRecord(sessionId: string): void {
|
|
61
|
+
const records = readLedger().filter((candidate) => candidate.sessionId !== sessionId)
|
|
62
|
+
const path = ledgerPath()
|
|
63
|
+
const temp = `${path}.${process.pid}.tmp`
|
|
64
|
+
writeFileSync(temp, JSON.stringify({ records }), { mode: 0o600 })
|
|
65
|
+
renameSync(temp, path)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function resumeTail(
|
|
69
|
+
record: AgentRecord,
|
|
70
|
+
transcript: Transcript,
|
|
71
|
+
key: SessionKey
|
|
72
|
+
): TurnMessage[] | undefined {
|
|
73
|
+
if (record.cwd !== key.cwd) return undefined
|
|
74
|
+
if (record.toolsHash !== key.toolsHash) return undefined
|
|
75
|
+
if (record.count >= transcript.chain.length) return undefined
|
|
76
|
+
if (transcript.chain[record.count] !== record.chain) return undefined
|
|
77
|
+
const tail = transcript.messages.slice(record.count)
|
|
78
|
+
if (tail.some((message) => message.role === 'assistant' || message.role === 'toolResult')) return undefined
|
|
79
|
+
if (!tail.some((message) => message.role === 'user')) return undefined
|
|
80
|
+
return tail
|
|
81
|
+
}
|
package/src/session.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
AgentOptions,
|
|
3
|
+
Run,
|
|
4
|
+
RunResult,
|
|
5
|
+
SDKAgent,
|
|
6
|
+
SDKCustomTool,
|
|
7
|
+
SDKCustomToolResult,
|
|
8
|
+
TokenUsage
|
|
9
|
+
} from '@cursor/sdk'
|
|
2
10
|
import type { AssistantMessage, Tool, ToolResultMessage } from '@earendil-works/pi-ai'
|
|
11
|
+
import type { AgentRecord } from './resume.js'
|
|
3
12
|
|
|
4
13
|
import { sha, type JsonObject } from './json.js'
|
|
14
|
+
import { noSystemPrompt, patchState } from './state.js'
|
|
5
15
|
import { selectionFor, type ModelSelection } from './models.js'
|
|
6
16
|
import { cursorApiKey, cursorSdk } from './sdk.js'
|
|
7
17
|
import {
|
|
@@ -44,6 +54,7 @@ export type CursorSession = {
|
|
|
44
54
|
chain: string
|
|
45
55
|
count: number
|
|
46
56
|
usage?: TokenUsage
|
|
57
|
+
reported?: TokenUsage
|
|
47
58
|
toolCounter: number
|
|
48
59
|
promptFallback?: boolean
|
|
49
60
|
promptSent?: boolean
|
|
@@ -129,6 +140,7 @@ export function createSession(
|
|
|
129
140
|
chain: transcript.chain[transcript.messages.length] ?? transcript.chain[0] ?? sha('prompt'),
|
|
130
141
|
count: transcript.messages.length,
|
|
131
142
|
toolCounter: 0,
|
|
143
|
+
promptFallback: noSystemPrompt(),
|
|
132
144
|
busy: true,
|
|
133
145
|
dead: false,
|
|
134
146
|
lastUsed: Date.now()
|
|
@@ -178,23 +190,25 @@ export function updateChain(session: CursorSession, transcript: Transcript, outp
|
|
|
178
190
|
session.lastUsed = Date.now()
|
|
179
191
|
}
|
|
180
192
|
|
|
193
|
+
function agentOptions(session: CursorSession, model: string, systemPrompt: string | undefined): AgentOptions {
|
|
194
|
+
const options: AgentOptions = {
|
|
195
|
+
apiKey: cursorApiKey(),
|
|
196
|
+
model: selectionFor(model, undefined),
|
|
197
|
+
tools: ['mcp'],
|
|
198
|
+
local: {
|
|
199
|
+
cwd: session.key.cwd,
|
|
200
|
+
settingSources: [],
|
|
201
|
+
customTools: customTools(session)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (systemPrompt !== undefined) options.systemPrompt = systemPrompt
|
|
205
|
+
return options
|
|
206
|
+
}
|
|
207
|
+
|
|
181
208
|
export async function ensureAgent(session: CursorSession, model: string): Promise<SDKAgent> {
|
|
182
209
|
if (session.agent) return session.agent
|
|
183
210
|
const { Agent } = await cursorSdk()
|
|
184
|
-
const create = (systemPrompt: string | undefined) =>
|
|
185
|
-
const options: AgentOptions = {
|
|
186
|
-
apiKey: cursorApiKey(),
|
|
187
|
-
model: selectionFor(model, undefined),
|
|
188
|
-
tools: ['mcp'],
|
|
189
|
-
local: {
|
|
190
|
-
cwd: session.key.cwd,
|
|
191
|
-
settingSources: [],
|
|
192
|
-
customTools: customTools(session)
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
if (systemPrompt !== undefined) options.systemPrompt = systemPrompt
|
|
196
|
-
return Agent.create(options)
|
|
197
|
-
}
|
|
211
|
+
const create = (systemPrompt: string | undefined) => Agent.create(agentOptions(session, model, systemPrompt))
|
|
198
212
|
try {
|
|
199
213
|
session.agent = await create(session.promptFallback ? undefined : session.systemPrompt)
|
|
200
214
|
return session.agent
|
|
@@ -203,6 +217,7 @@ export async function ensureAgent(session: CursorSession, model: string): Promis
|
|
|
203
217
|
if (!session.promptFallback && /system.?prompt/i.test(message)) {
|
|
204
218
|
session.promptFallback = true
|
|
205
219
|
session.agent = await create(undefined)
|
|
220
|
+
patchState({ noSystemPrompt: true })
|
|
206
221
|
return session.agent
|
|
207
222
|
}
|
|
208
223
|
if (/auth|api.?key|login|credential/i.test(message)) {
|
|
@@ -212,6 +227,32 @@ export async function ensureAgent(session: CursorSession, model: string): Promis
|
|
|
212
227
|
}
|
|
213
228
|
}
|
|
214
229
|
|
|
230
|
+
export async function resumeSession(
|
|
231
|
+
store: CursorSessionStore,
|
|
232
|
+
id: string,
|
|
233
|
+
key: SessionKey,
|
|
234
|
+
systemPrompt: string,
|
|
235
|
+
tools: Tool[],
|
|
236
|
+
transcript: Transcript,
|
|
237
|
+
model: string,
|
|
238
|
+
record: AgentRecord
|
|
239
|
+
): Promise<CursorSession | undefined> {
|
|
240
|
+
const session = createSession(store, id, key, systemPrompt, tools, transcript)
|
|
241
|
+
session.promptFallback = noSystemPrompt()
|
|
242
|
+
session.promptSent = true
|
|
243
|
+
session.count = record.count
|
|
244
|
+
session.chain = record.chain
|
|
245
|
+
try {
|
|
246
|
+
const { Agent } = await cursorSdk()
|
|
247
|
+
const prompt = session.promptFallback ? undefined : systemPrompt
|
|
248
|
+
session.agent = await Agent.resume(record.agentId, agentOptions(session, model, prompt))
|
|
249
|
+
return session
|
|
250
|
+
} catch {
|
|
251
|
+
dropSession(store, session)
|
|
252
|
+
return undefined
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
215
256
|
function customTools(session: CursorSession) {
|
|
216
257
|
const tools: Record<string, SDKCustomTool> = {}
|
|
217
258
|
for (const tool of session.tools) {
|
|
@@ -241,6 +282,7 @@ export async function startRun(session: CursorSession, input: CursorInput, selec
|
|
|
241
282
|
const payload = session.promptFallback && !session.promptSent ? withPreamble(input, session.systemPrompt) : input
|
|
242
283
|
if (session.promptFallback) session.promptSent = true
|
|
243
284
|
session.usage = undefined
|
|
285
|
+
session.reported = undefined
|
|
244
286
|
let run: Run
|
|
245
287
|
try {
|
|
246
288
|
run = await agent.send(toSdkMessage(payload), {
|
|
@@ -249,22 +291,14 @@ export async function startRun(session: CursorSession, input: CursorInput, selec
|
|
|
249
291
|
if (update.type === 'text-delta') session.queue.push({ kind: 'text', text: update.text })
|
|
250
292
|
else if (update.type === 'thinking-delta') session.queue.push({ kind: 'thinking', text: update.text })
|
|
251
293
|
else if (update.type === 'turn-ended' && update.usage) {
|
|
252
|
-
|
|
253
|
-
session.usage = {
|
|
254
|
-
...usage,
|
|
255
|
-
totalTokens:
|
|
256
|
-
usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
|
|
257
|
-
}
|
|
294
|
+
session.usage = addUsage(session.usage, normalizeUsage(update.usage))
|
|
258
295
|
}
|
|
259
296
|
}
|
|
260
297
|
})
|
|
261
298
|
} catch (error) {
|
|
262
299
|
const message = error instanceof Error ? error.message : String(error)
|
|
263
300
|
if (!session.promptFallback && /system.?prompt/i.test(message)) {
|
|
264
|
-
session
|
|
265
|
-
session.agent?.close()
|
|
266
|
-
session.agent = undefined
|
|
267
|
-
await startRun(session, input, selection)
|
|
301
|
+
await restartWithoutPrompt(session, input, selection)
|
|
268
302
|
return
|
|
269
303
|
}
|
|
270
304
|
throw error
|
|
@@ -272,8 +306,17 @@ export async function startRun(session: CursorSession, input: CursorInput, selec
|
|
|
272
306
|
session.run = run
|
|
273
307
|
void run
|
|
274
308
|
.wait()
|
|
275
|
-
.then((result) => {
|
|
276
|
-
if (
|
|
309
|
+
.then(async (result) => {
|
|
310
|
+
if (result.usage) session.usage = largerUsage(session.usage, normalizeUsage(result.usage))
|
|
311
|
+
const outcome = outcomeOf(result, session.promptFallback === true)
|
|
312
|
+
if (outcome.kind === 'retry') {
|
|
313
|
+
await restartWithoutPrompt(session, input, selection)
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
if (outcome.kind === 'error') {
|
|
317
|
+
session.queue.push({ kind: 'error', message: outcome.message })
|
|
318
|
+
return
|
|
319
|
+
}
|
|
277
320
|
session.queue.push({ kind: 'done' })
|
|
278
321
|
})
|
|
279
322
|
.catch((error: Error) => {
|
|
@@ -281,6 +324,72 @@ export async function startRun(session: CursorSession, input: CursorInput, selec
|
|
|
281
324
|
})
|
|
282
325
|
}
|
|
283
326
|
|
|
327
|
+
type ReportedUsage = Omit<TokenUsage, 'totalTokens'>
|
|
328
|
+
|
|
329
|
+
function normalizeUsage(usage: ReportedUsage): TokenUsage {
|
|
330
|
+
return {
|
|
331
|
+
...usage,
|
|
332
|
+
totalTokens: usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function addUsage(current: TokenUsage | undefined, next: TokenUsage): TokenUsage {
|
|
337
|
+
if (!current) return { ...next }
|
|
338
|
+
const reasoning = current.reasoningTokens
|
|
339
|
+
const extra = next.reasoningTokens
|
|
340
|
+
const total: TokenUsage = {
|
|
341
|
+
inputTokens: current.inputTokens + next.inputTokens,
|
|
342
|
+
outputTokens: current.outputTokens + next.outputTokens,
|
|
343
|
+
cacheReadTokens: current.cacheReadTokens + next.cacheReadTokens,
|
|
344
|
+
cacheWriteTokens: current.cacheWriteTokens + next.cacheWriteTokens,
|
|
345
|
+
totalTokens: current.totalTokens + next.totalTokens
|
|
346
|
+
}
|
|
347
|
+
if (reasoning !== undefined || extra !== undefined) total.reasoningTokens = (reasoning ?? 0) + (extra ?? 0)
|
|
348
|
+
return total
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function largerUsage(current: TokenUsage | undefined, next: TokenUsage): TokenUsage {
|
|
352
|
+
if (!current || next.totalTokens > current.totalTokens) return { ...next }
|
|
353
|
+
return current
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function usageDelta(total: TokenUsage | undefined, reported: TokenUsage | undefined): TokenUsage | undefined {
|
|
357
|
+
if (!total) return undefined
|
|
358
|
+
if (!reported) return total
|
|
359
|
+
const delta: TokenUsage = {
|
|
360
|
+
inputTokens: Math.max(total.inputTokens - reported.inputTokens, 0),
|
|
361
|
+
outputTokens: Math.max(total.outputTokens - reported.outputTokens, 0),
|
|
362
|
+
cacheReadTokens: Math.max(total.cacheReadTokens - reported.cacheReadTokens, 0),
|
|
363
|
+
cacheWriteTokens: Math.max(total.cacheWriteTokens - reported.cacheWriteTokens, 0),
|
|
364
|
+
totalTokens: Math.max(total.totalTokens - reported.totalTokens, 0)
|
|
365
|
+
}
|
|
366
|
+
if (total.reasoningTokens !== undefined) {
|
|
367
|
+
delta.reasoningTokens = Math.max(total.reasoningTokens - (reported.reasoningTokens ?? 0), 0)
|
|
368
|
+
}
|
|
369
|
+
return delta
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export type RunOutcome = { kind: 'done' } | { kind: 'retry' } | { kind: 'error'; message: string }
|
|
373
|
+
|
|
374
|
+
export function outcomeOf(result: RunResult, promptFallback: boolean): RunOutcome {
|
|
375
|
+
if (result.status !== 'error') return { kind: 'done' }
|
|
376
|
+
const message = result.error?.message ?? 'the Cursor run failed'
|
|
377
|
+
if (!promptFallback && /system.?prompt/i.test(message)) return { kind: 'retry' }
|
|
378
|
+
return { kind: 'error', message }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function restartWithoutPrompt(
|
|
382
|
+
session: CursorSession,
|
|
383
|
+
input: CursorInput,
|
|
384
|
+
selection: ModelSelection
|
|
385
|
+
): Promise<void> {
|
|
386
|
+
session.promptFallback = true
|
|
387
|
+
session.agent?.close()
|
|
388
|
+
session.agent = undefined
|
|
389
|
+
await startRun(session, input, selection)
|
|
390
|
+
patchState({ noSystemPrompt: true })
|
|
391
|
+
}
|
|
392
|
+
|
|
284
393
|
export function deliverToolResult(session: CursorSession, message: ToolResultMessage): void {
|
|
285
394
|
const result = toolResultCustom(message)
|
|
286
395
|
const resolve = session.parked.get(message.toolCallId)
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { getAgentDir } from '@earendil-works/pi-coding-agent'
|
|
5
|
+
import { Validator, type Infer } from 'ata-validator'
|
|
6
|
+
import { t } from 'ata-validator/t'
|
|
7
|
+
|
|
8
|
+
const CursorPreferences = t.object({
|
|
9
|
+
noSystemPrompt: t.optional(t.boolean()),
|
|
10
|
+
fast: t.optional(t.boolean())
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const isPreferences = new Validator(CursorPreferences).isValidObject
|
|
14
|
+
|
|
15
|
+
export type CursorState = Infer<typeof CursorPreferences>
|
|
16
|
+
|
|
17
|
+
function statePath(): string {
|
|
18
|
+
return join(getAgentDir(), 'pi-cursor.json')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readState(): CursorState {
|
|
22
|
+
try {
|
|
23
|
+
const parsed: unknown = JSON.parse(readFileSync(statePath(), 'utf8'))
|
|
24
|
+
return isPreferences(parsed) ? parsed : {}
|
|
25
|
+
} catch {
|
|
26
|
+
return {}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function patchState(patch: CursorState): void {
|
|
31
|
+
const path = statePath()
|
|
32
|
+
const temp = `${path}.${process.pid}.tmp`
|
|
33
|
+
writeFileSync(temp, JSON.stringify({ ...readState(), ...patch }), { mode: 0o600 })
|
|
34
|
+
renameSync(temp, path)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function noSystemPrompt(): boolean {
|
|
38
|
+
return readState().noSystemPrompt === true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function fastMode(): boolean {
|
|
42
|
+
return readState().fast === true
|
|
43
|
+
}
|
package/src/transcript.ts
CHANGED
|
@@ -181,15 +181,13 @@ export function replayInput(messages: TurnMessage[]): CursorInput {
|
|
|
181
181
|
if (rendered) parts.push(`ASSISTANT:\n${rendered}`)
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
const pending = messages.findLast((message) => message.role === 'user')
|
|
184
185
|
const images: CursorImage[] = []
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (block.type !== 'image') continue
|
|
189
|
-
images.push(image(block))
|
|
186
|
+
if (pending && Array.isArray(pending.content)) {
|
|
187
|
+
for (const block of pending.content) {
|
|
188
|
+
if (block.type === 'image') images.push(image(block))
|
|
190
189
|
}
|
|
191
190
|
}
|
|
192
|
-
if (images.length > 0) parts.push(`The replay includes ${images.length} image(s) from earlier user messages.`)
|
|
193
191
|
return { text: parts.join('\n\n'), images }
|
|
194
192
|
}
|
|
195
193
|
|
package/src/turn.ts
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
} from '@earendil-works/pi-ai'
|
|
12
12
|
|
|
13
13
|
import { sha } from './json.js'
|
|
14
|
+
import { fastMode } from './state.js'
|
|
15
|
+
import { dropRecord, readRecord, resumeTail, writeRecord } from './resume.js'
|
|
14
16
|
import { selectionFor } from './models.js'
|
|
15
17
|
import {
|
|
16
18
|
attachSession,
|
|
@@ -19,8 +21,10 @@ import {
|
|
|
19
21
|
deliverToolResult,
|
|
20
22
|
dropSession,
|
|
21
23
|
failMissingResults,
|
|
24
|
+
resumeSession,
|
|
22
25
|
startRun,
|
|
23
26
|
updateChain,
|
|
27
|
+
usageDelta,
|
|
24
28
|
type Attached,
|
|
25
29
|
type CursorSession,
|
|
26
30
|
type CursorEvent,
|
|
@@ -29,10 +33,10 @@ import {
|
|
|
29
33
|
} from './session.js'
|
|
30
34
|
import { readTranscript, replayInput, systemPreamble, userInput, type CursorContext } from './transcript.js'
|
|
31
35
|
|
|
32
|
-
export type CursorRuntime = { sessions: CursorSessionStore }
|
|
36
|
+
export type CursorRuntime = { sessions: CursorSessionStore; fast: boolean }
|
|
33
37
|
|
|
34
38
|
export function createRuntime(): CursorRuntime {
|
|
35
|
-
return { sessions: createSessionStore() }
|
|
39
|
+
return { sessions: createSessionStore(), fast: fastMode() }
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
const TOOL_GRACE_MS = 250
|
|
@@ -113,7 +117,7 @@ async function runTurn(
|
|
|
113
117
|
.join('\n\n')
|
|
114
118
|
const key: SessionKey = { systemPrompt, toolsHash: transcript.toolsHash, cwd }
|
|
115
119
|
const sessionId = options?.sessionId ?? sha(systemPrompt)
|
|
116
|
-
const selection = selectionFor(model.id, options?.reasoning)
|
|
120
|
+
const selection = selectionFor(model.id, options?.reasoning, runtime.fast)
|
|
117
121
|
let attached: Attached | undefined = attachSession(runtime.sessions, transcript, key)
|
|
118
122
|
if (attached?.session.dead) {
|
|
119
123
|
dropSession(runtime.sessions, attached.session)
|
|
@@ -140,6 +144,14 @@ async function runTurn(
|
|
|
140
144
|
}
|
|
141
145
|
failMissingResults(active, seen)
|
|
142
146
|
active.awaiting.clear()
|
|
147
|
+
const steering = userInput(rest)
|
|
148
|
+
if (steering.text.length > 0 && active.run?.steer) {
|
|
149
|
+
const note =
|
|
150
|
+
steering.images.length > 0
|
|
151
|
+
? `${steering.text}\n[${steering.images.length} image(s) could not be injected mid-turn]`
|
|
152
|
+
: steering.text
|
|
153
|
+
await active.run.steer(note)
|
|
154
|
+
}
|
|
143
155
|
} else {
|
|
144
156
|
const input = userInput(rest)
|
|
145
157
|
if (input.text.length === 0 && input.images.length === 0) {
|
|
@@ -151,9 +163,37 @@ async function runTurn(
|
|
|
151
163
|
const previous = runtime.sessions.sessions.get(sessionId)
|
|
152
164
|
if (previous?.busy) throw new Error('this Pi session already has a request in flight')
|
|
153
165
|
if (previous) dropSession(runtime.sessions, previous)
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
166
|
+
const record = options?.sessionId ? readRecord(options.sessionId) : undefined
|
|
167
|
+
const tail = record ? resumeTail(record, transcript, key) : undefined
|
|
168
|
+
const resumed =
|
|
169
|
+
record && tail
|
|
170
|
+
? await resumeSession(
|
|
171
|
+
runtime.sessions,
|
|
172
|
+
sessionId,
|
|
173
|
+
key,
|
|
174
|
+
systemPrompt,
|
|
175
|
+
transcript.tools,
|
|
176
|
+
transcript,
|
|
177
|
+
model.id,
|
|
178
|
+
record
|
|
179
|
+
)
|
|
180
|
+
: undefined
|
|
181
|
+
if (resumed && tail) {
|
|
182
|
+
session = resumed
|
|
183
|
+
await startRun(resumed, userInput(tail), selection)
|
|
184
|
+
} else {
|
|
185
|
+
if (record && options?.sessionId) dropRecord(options.sessionId)
|
|
186
|
+
const created = createSession(
|
|
187
|
+
runtime.sessions,
|
|
188
|
+
sessionId,
|
|
189
|
+
key,
|
|
190
|
+
systemPrompt,
|
|
191
|
+
transcript.tools,
|
|
192
|
+
transcript
|
|
193
|
+
)
|
|
194
|
+
session = created
|
|
195
|
+
await startRun(created, replayInput(transcript.messages), selection)
|
|
196
|
+
}
|
|
157
197
|
}
|
|
158
198
|
|
|
159
199
|
const active = session
|
|
@@ -180,6 +220,18 @@ async function runTurn(
|
|
|
180
220
|
active.abortCleanup?.()
|
|
181
221
|
active.abortCleanup = undefined
|
|
182
222
|
active.run = undefined
|
|
223
|
+
const agentId = active.agent?.agentId
|
|
224
|
+
if (options?.sessionId && agentId) {
|
|
225
|
+
writeRecord({
|
|
226
|
+
sessionId: options.sessionId,
|
|
227
|
+
agentId,
|
|
228
|
+
cwd: key.cwd,
|
|
229
|
+
toolsHash: key.toolsHash,
|
|
230
|
+
count: active.count,
|
|
231
|
+
chain: active.chain,
|
|
232
|
+
at: Date.now()
|
|
233
|
+
})
|
|
234
|
+
}
|
|
183
235
|
}
|
|
184
236
|
|
|
185
237
|
stream.push({ type: 'done', reason: step.reason, message: output })
|
|
@@ -196,15 +248,16 @@ async function runTurn(
|
|
|
196
248
|
}
|
|
197
249
|
|
|
198
250
|
function applyUsage(output: AssistantMessage, session: CursorSession): void {
|
|
199
|
-
const
|
|
200
|
-
if (!
|
|
201
|
-
|
|
202
|
-
output.usage.
|
|
203
|
-
output.usage.
|
|
204
|
-
output.usage.
|
|
251
|
+
const delta = usageDelta(session.usage, session.reported)
|
|
252
|
+
if (!delta) return
|
|
253
|
+
session.reported = session.usage ? { ...session.usage } : undefined
|
|
254
|
+
output.usage.input = delta.inputTokens
|
|
255
|
+
output.usage.output = delta.outputTokens
|
|
256
|
+
output.usage.cacheRead = delta.cacheReadTokens
|
|
257
|
+
output.usage.cacheWrite = delta.cacheWriteTokens
|
|
205
258
|
output.usage.totalTokens =
|
|
206
|
-
|
|
207
|
-
if (
|
|
259
|
+
delta.inputTokens + delta.outputTokens + delta.cacheReadTokens + delta.cacheWriteTokens
|
|
260
|
+
if (delta.reasoningTokens !== undefined) output.usage.reasoning = delta.reasoningTokens
|
|
208
261
|
output.usage.cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
|
|
209
262
|
}
|
|
210
263
|
|