@tduyng/pi-cursor 0.0.1 → 0.0.2
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 +24 -7
- package/package.json +1 -1
- 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
|
@@ -39,7 +39,9 @@ Cursor bills SDK traffic under its own terms. This extension cannot tell you whe
|
|
|
39
39
|
/model cursor/gpt-5.5
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
The
|
|
42
|
+
The model list comes from `Cursor.models.list()`, cached after the last successful read in `~/.pi/agent/pi-cursor-models.json` (0600, keyed by the credential). Startup registers the cache with no network call, the session start refresh replaces it, and a small built-in list is the fallback when there is no cache yet. A model that offers several context windows is registered once per window: the plain id uses the smallest one, `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.
|
|
43
|
+
|
|
44
|
+
`/cursor fast on` adds Cursor's `fast` model parameter to every model that exposes it. It is a billing choice, so it stays off until you ask for it. `/cursor status` shows the current setting.
|
|
43
45
|
|
|
44
46
|
## How it works
|
|
45
47
|
|
|
@@ -51,24 +53,37 @@ The first model list is a small fallback so `/model` is usable before login. Aft
|
|
|
51
53
|
| Cursor rules, skills, MCP | `local.settingSources: []` |
|
|
52
54
|
| Pi tool execution | the custom tool callback parks, Pi runs the tool, the next provider call resolves it |
|
|
53
55
|
|
|
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 Cursor run continues, so the model sees the tool result in the same conversation.
|
|
56
|
+
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 Cursor run continues, so the model sees the tool result in the same conversation. A message you steer in while that tool is running 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.
|
|
55
57
|
|
|
56
58
|
One Cursor agent runs per Pi session. A normal turn sends only the new user message. A tool round resolves parked callbacks and sends nothing. The agent is only replaced when Pi's prompt, tool set or working directory changes, or when the Pi history diverges. That replacement replays the existing Pi conversation once as a single message.
|
|
57
59
|
|
|
60
|
+
A restarted Pi process resumes the same Cursor 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 the extension's 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 fork that diverged, or a turn that was killed mid tool round all fall back to the replay, which is the pre-resume behavior. The ledger keeps the last 20 sessions. `/cursor clear`, `/cursor login` and `/cursor logout` delete it, so those commands really do start from a fresh agent.
|
|
61
|
+
|
|
58
62
|
There is no HTTP MCP server, no Hono server and no tool-name rewriting. Cursor's SDK invokes the callback directly.
|
|
59
63
|
|
|
60
64
|
## Token and cache behavior
|
|
61
65
|
|
|
62
|
-
Cursor's backend owns its own conversation and cache. Pi does not replay a linear conversation on every turn,
|
|
66
|
+
Cursor's backend owns its own conversation and cache. Pi does not replay a linear conversation on every turn, it does not run a second agent for tool rounds, and a restart resumes the agent rather than replaying. The Pi system prompt is stable for the lifetime of an agent, and the tool list is stable too. A divergence is the only case that pays a replay.
|
|
63
67
|
|
|
64
68
|
Thinking levels map to Cursor model parameters. For Claude models the extension sets both `thinking` and `effort`. For GPT models it sets `reasoning`. For Grok it sets `effort`. The mapping comes from the live model catalog.
|
|
65
69
|
|
|
70
|
+
What the wire actually costs, measured on the account this extension was built on (gpt-5.5 and auto-smart, thinking off):
|
|
71
|
+
|
|
72
|
+
| Call | Input tokens | Wall time |
|
|
73
|
+
| ----------------------- | -------------------------- | --------- |
|
|
74
|
+
| first call of an agent | 11.9k fresh + 2.6k cached | 4.8-5.5 s |
|
|
75
|
+
| plain follow-up turn | 12.9k fresh + 12.7k cached | 2.2 s |
|
|
76
|
+
| call after a tool round | 36.9k fresh + 36.0k cached | 4.4-13 s |
|
|
77
|
+
|
|
78
|
+
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 when the working directory is empty, so it is not workspace scanning. Fewer, wider tool calls per turn is the lever that exists.
|
|
79
|
+
|
|
66
80
|
## Limitations
|
|
67
81
|
|
|
68
82
|
- Cursor owns the agent loop and its context management. Pi's compaction does not reach inside a live Cursor agent.
|
|
69
|
-
- `AgentOptions.systemPrompt` is server-gated.
|
|
70
|
-
-
|
|
71
|
-
-
|
|
83
|
+
- `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. The rejection is recorded as `noSystemPrompt` in `~/.pi/agent/pi-cursor.json` and every later agent skips the doomed attempt. Clear that flag with `/cursor login`, or edit the file. `/cursor status` shows which path is in use. The fallback works but spends more tokens because Cursor's harness prompt remains.
|
|
84
|
+
- A failed Cursor run reaches Pi as an error message, so an empty answer now means a broken transport, not a silent backend rejection.
|
|
85
|
+
- Usage tokens come from Cursor's `turn-ended` events, plus the run's own total when it ends. Pi records each message's share of the run, so the numbers add up across a tool round instead of keeping only the last step. The message that ends in a tool call shows zero because that step has not ended server-side yet, and the next message carries both steps. Pi shows a zero money cost because Cursor bills outside Pi.
|
|
86
|
+
- Images ride with the message that carries them. A replay replaces every earlier image with an `[image: mime]` placeholder and keeps only the pending message's images, so reattach one to ask about it again after a restart.
|
|
72
87
|
- A model id that Cursor no longer serves fails at `send`, not at model selection.
|
|
73
88
|
- A long Pi tool run is held open by Cursor local runtime. The SDK does not document a timeout for custom tools.
|
|
74
89
|
- The Cursor SDK is a 25 MB dependency with optional platform packages.
|
|
@@ -79,10 +94,12 @@ Thinking levels map to Cursor model parameters. For Claude models the extension
|
|
|
79
94
|
| ---------------- | ----------------------------------------------- |
|
|
80
95
|
| `CURSOR_API_KEY` | Cursor API key. Overrides the stored SDK login. |
|
|
81
96
|
|
|
97
|
+
`~/.pi/agent/pi-cursor.json` holds the two preferences: `fast` from `/cursor fast`, and `noSystemPrompt` once Cursor has refused the Pi prompt for this account.
|
|
98
|
+
|
|
82
99
|
## Tests
|
|
83
100
|
|
|
84
101
|
```sh
|
|
85
|
-
|
|
102
|
+
bun test extensions/pi-cursor
|
|
86
103
|
bun run typecheck
|
|
87
104
|
bun run lint:anti-slop
|
|
88
105
|
```
|
package/package.json
CHANGED
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
|
|