@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 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 first model list is a small fallback so `/model` is usable before login. After login, or on the next session start, the extension reads `Cursor.models.list()` and registers every model the account can reach. `/cursor models` prints them.
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, and it does not run a second agent for tool rounds. 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.
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. 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.
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
- bunx vitest run extensions/pi-cursor
102
+ bun test extensions/pi-cursor
86
103
  bun run typecheck
87
104
  bun run lint:anti-slop
88
105
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tduyng/pi-cursor",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Use Cursor models in Pi through the official Cursor SDK, with Pi's own prompt and tools.",
5
5
  "keywords": [
6
6
  "pi",
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
- let models: PiModelEntry[] = fallbackModels()
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) register(next)
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
- await refresh()
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 { AgentOptions, Run, SDKAgent, SDKCustomTool, SDKCustomToolResult, TokenUsage } from '@cursor/sdk'
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
- const usage = update.usage
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.promptFallback = true
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 (!session.usage && result.usage) session.usage = result.usage
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
- for (const message of messages) {
186
- if (message.role !== 'user' || !Array.isArray(message.content)) continue
187
- for (const block of message.content) {
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 created = createSession(runtime.sessions, sessionId, key, systemPrompt, transcript.tools, transcript)
155
- session = created
156
- await startRun(created, replayInput(transcript.messages), selection)
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 usage = session.usage
200
- if (!usage) return
201
- output.usage.input = usage.inputTokens
202
- output.usage.output = usage.outputTokens
203
- output.usage.cacheRead = usage.cacheReadTokens
204
- output.usage.cacheWrite = usage.cacheWriteTokens
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
- usage.totalTokens || usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
207
- if (usage.reasoningTokens !== undefined) output.usage.reasoning = usage.reasoningTokens
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