@tduyng/pi-cursor 0.0.0-stage → 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/LICENSE +178 -0
- package/README.md +104 -2
- package/package.json +42 -4
- package/src/catalog.ts +69 -0
- package/src/index.ts +195 -0
- package/src/json.ts +74 -0
- package/src/models.ts +198 -0
- package/src/resume.ts +81 -0
- package/src/sdk.ts +14 -0
- package/src/session.ts +461 -0
- package/src/state.ts +43 -0
- package/src/transcript.ts +210 -0
- package/src/turn.ts +389 -0
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/sdk.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type CursorSdk = typeof import('@cursor/sdk')
|
|
2
|
+
|
|
3
|
+
let loading: Promise<CursorSdk> | undefined
|
|
4
|
+
|
|
5
|
+
export function cursorSdk(): Promise<CursorSdk> {
|
|
6
|
+
loading ??= import('@cursor/sdk')
|
|
7
|
+
return loading
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function cursorApiKey(): string | undefined {
|
|
11
|
+
const value = process.env['CURSOR_API_KEY']
|
|
12
|
+
const trimmed = value?.trim()
|
|
13
|
+
return trimmed && trimmed.length > 0 ? trimmed : undefined
|
|
14
|
+
}
|