opencode-ghost 0.6.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +108 -96
- package/package.json +7 -8
- package/src/composer.ts +68 -0
- package/src/lifecycle.ts +41 -0
- package/src/options.ts +30 -28
- package/src/placeholder.ts +46 -0
- package/src/stateless.ts +22 -187
- package/src/text.ts +47 -51
- package/src/transcript.ts +44 -0
- package/src/tui.tsx +217 -914
- package/src/builtins.ts +0 -28
- package/src/completion.ts +0 -78
package/src/stateless.ts
CHANGED
|
@@ -1,195 +1,30 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
groq: "https://api.groq.com/openai/v1",
|
|
10
|
-
mistral: "https://api.mistral.com/v1",
|
|
11
|
-
openrouter: "https://openrouter.ai/api/v1",
|
|
12
|
-
together: "https://api.together.xyz/v1",
|
|
13
|
-
ollama: "http://127.0.0.1:11434/v1",
|
|
14
|
-
perplexity: "https://api.perplexity.ai",
|
|
15
|
-
xai: "https://api.x.ai/v1",
|
|
16
|
-
fireworks: "https://api.fireworks.ai/inference/v1",
|
|
17
|
-
cerebras: "https://api.cerebras.ai/v1",
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const WELL_KNOWN_ENV_KEYS: Record<string, string[]> = {
|
|
21
|
-
openai: ["OPENAI_API_KEY"],
|
|
22
|
-
deepseek: ["DEEPSEEK_API_KEY"],
|
|
23
|
-
groq: ["GROQ_API_KEY"],
|
|
24
|
-
mistral: ["MISTRAL_API_KEY"],
|
|
25
|
-
openrouter: ["OPENROUTER_API_KEY"],
|
|
26
|
-
together: ["TOGETHER_API_KEY"],
|
|
27
|
-
perplexity: ["PERPLEXITY_API_KEY"],
|
|
28
|
-
xai: ["XAI_API_KEY"],
|
|
29
|
-
fireworks: ["FIREWORKS_API_KEY"],
|
|
30
|
-
cerebras: ["CEREBRAS_API_KEY"],
|
|
31
|
-
anthropic: ["ANTHROPIC_API_KEY"],
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function resolveApiKey(raw: string | undefined): string | undefined {
|
|
35
|
-
if (!raw) return undefined
|
|
36
|
-
const trimmed = raw.trim()
|
|
37
|
-
if (trimmed.startsWith("{file:") && trimmed.endsWith("}")) {
|
|
38
|
-
const filePath = trimmed.slice(6, -1).trim()
|
|
39
|
-
try {
|
|
40
|
-
const resolved = filePath.startsWith("~/")
|
|
41
|
-
? path.join(os.homedir(), filePath.slice(2))
|
|
42
|
-
: filePath
|
|
43
|
-
return fs.readFileSync(resolved, "utf8").trim()
|
|
44
|
-
} catch {
|
|
45
|
-
return undefined
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
if (trimmed.startsWith("{env:") && trimmed.endsWith("}")) {
|
|
49
|
-
const envVar = trimmed.slice(5, -1).trim()
|
|
50
|
-
return process.env[envVar]
|
|
1
|
+
import type { SuggestionModel } from "./text"
|
|
2
|
+
|
|
3
|
+
export type GenerateClient = {
|
|
4
|
+
generate: {
|
|
5
|
+
text(
|
|
6
|
+
input: { prompt: string; model: { id: string; providerID: string; variant?: string } },
|
|
7
|
+
requestOptions?: { signal?: AbortSignal },
|
|
8
|
+
): Promise<{ text?: string }>
|
|
51
9
|
}
|
|
52
|
-
return trimmed
|
|
53
10
|
}
|
|
54
11
|
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (opts.apiKey) return resolveApiKey(opts.apiKey)
|
|
62
|
-
|
|
63
|
-
const configKey = resolveApiKey(providerConfig?.options?.apiKey)
|
|
64
|
-
if (configKey) return configKey
|
|
65
|
-
|
|
66
|
-
const stateOptionKey = resolveApiKey(providerState?.options?.apiKey)
|
|
67
|
-
if (stateOptionKey) return stateOptionKey
|
|
68
|
-
|
|
69
|
-
const stateKey = resolveApiKey(providerState?.key)
|
|
70
|
-
if (stateKey) return stateKey
|
|
71
|
-
|
|
72
|
-
if (Array.isArray(providerState?.env)) {
|
|
73
|
-
for (const envName of providerState.env) {
|
|
74
|
-
if (typeof envName === "string" && process.env[envName]) {
|
|
75
|
-
return process.env[envName]
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (providerID && WELL_KNOWN_ENV_KEYS[providerID]) {
|
|
81
|
-
for (const envName of WELL_KNOWN_ENV_KEYS[providerID]) {
|
|
82
|
-
if (process.env[envName]) {
|
|
83
|
-
return process.env[envName]
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (process.env.LITELLM_API_KEY) return process.env.LITELLM_API_KEY
|
|
89
|
-
if (!providerID || providerID === "openai" || providerID === "custom") {
|
|
90
|
-
if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return undefined
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export async function fetchStatelessSuggestion(
|
|
97
|
-
transcript: string,
|
|
98
|
-
opts: ResolvedOptions,
|
|
99
|
-
modelRef: { providerID: string; modelID: string } | undefined,
|
|
100
|
-
config: any,
|
|
101
|
-
providers?: readonly any[],
|
|
12
|
+
export async function generateSuggestion(
|
|
13
|
+
client: GenerateClient,
|
|
14
|
+
prompt: string,
|
|
15
|
+
model: SuggestionModel | undefined,
|
|
16
|
+
signal: AbortSignal,
|
|
17
|
+
onError?: (error: unknown) => void,
|
|
102
18
|
): Promise<string | undefined> {
|
|
103
|
-
|
|
104
|
-
const providerConfig = providerID ? config?.provider?.[providerID] : undefined
|
|
105
|
-
const providerState = providerID && Array.isArray(providers)
|
|
106
|
-
? providers.find((p) => p?.id === providerID)
|
|
107
|
-
: undefined
|
|
108
|
-
|
|
109
|
-
const apiKey = resolveProviderApiKey(opts, providerID, providerConfig, providerState)
|
|
110
|
-
const isAnthropic = providerID === "anthropic"
|
|
111
|
-
|
|
112
|
-
const baseURL =
|
|
113
|
-
opts.endpoint ||
|
|
114
|
-
providerConfig?.options?.baseURL ||
|
|
115
|
-
providerState?.options?.baseURL ||
|
|
116
|
-
(!isAnthropic && providerID ? WELL_KNOWN_BASE_URLS[providerID] : undefined) ||
|
|
117
|
-
process.env.LITELLM_BASE_URL ||
|
|
118
|
-
process.env.OPENAI_BASE_URL
|
|
119
|
-
|
|
120
|
-
const controller = new AbortController()
|
|
121
|
-
const timeoutMs = 4000
|
|
122
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
123
|
-
|
|
19
|
+
if (signal.aborted || !model) return undefined
|
|
124
20
|
try {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const model = !keepSlash && rawModel.includes("/")
|
|
133
|
-
? rawModel.slice(rawModel.lastIndexOf("/") + 1)
|
|
134
|
-
: rawModel
|
|
135
|
-
|
|
136
|
-
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
|
137
|
-
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`
|
|
138
|
-
|
|
139
|
-
const res = await fetch(url, {
|
|
140
|
-
method: "POST",
|
|
141
|
-
headers,
|
|
142
|
-
signal: controller.signal,
|
|
143
|
-
body: JSON.stringify({
|
|
144
|
-
model,
|
|
145
|
-
messages: [
|
|
146
|
-
{ role: "system", content: opts.system },
|
|
147
|
-
{ role: "user", content: transcript },
|
|
148
|
-
],
|
|
149
|
-
max_tokens: 120,
|
|
150
|
-
temperature: 0.2,
|
|
151
|
-
}),
|
|
152
|
-
})
|
|
153
|
-
|
|
154
|
-
if (!res.ok) return undefined
|
|
155
|
-
const data = (await res.json()) as any
|
|
156
|
-
return data?.choices?.[0]?.message?.content
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (modelRef?.providerID === "anthropic") {
|
|
160
|
-
const antKey = apiKey || process.env.ANTHROPIC_API_KEY
|
|
161
|
-
if (!antKey) return undefined
|
|
162
|
-
const antBase = (
|
|
163
|
-
providerConfig?.options?.baseURL ||
|
|
164
|
-
providerState?.options?.baseURL ||
|
|
165
|
-
"https://api.anthropic.com"
|
|
166
|
-
).replace(/\/+$/, "")
|
|
167
|
-
const url = antBase.endsWith("/v1/messages") ? antBase : `${antBase}/v1/messages`
|
|
168
|
-
const res = await fetch(url, {
|
|
169
|
-
method: "POST",
|
|
170
|
-
headers: {
|
|
171
|
-
"Content-Type": "application/json",
|
|
172
|
-
"x-api-key": antKey,
|
|
173
|
-
"anthropic-version": "2023-06-01",
|
|
174
|
-
},
|
|
175
|
-
signal: controller.signal,
|
|
176
|
-
body: JSON.stringify({
|
|
177
|
-
model: modelRef.modelID,
|
|
178
|
-
max_tokens: 120,
|
|
179
|
-
system: opts.system,
|
|
180
|
-
messages: [{ role: "user", content: transcript }],
|
|
181
|
-
}),
|
|
182
|
-
})
|
|
183
|
-
|
|
184
|
-
if (!res.ok) return undefined
|
|
185
|
-
const data = (await res.json()) as any
|
|
186
|
-
return data?.content?.[0]?.text
|
|
187
|
-
}
|
|
188
|
-
} catch {
|
|
21
|
+
const result = await client.generate.text(
|
|
22
|
+
{ prompt, model: { id: model.modelID, providerID: model.providerID, ...(model.variant ? { variant: model.variant } : {}) } },
|
|
23
|
+
{ signal },
|
|
24
|
+
)
|
|
25
|
+
return result.text
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (!signal.aborted) onError?.(error)
|
|
189
28
|
return undefined
|
|
190
|
-
} finally {
|
|
191
|
-
clearTimeout(timer)
|
|
192
29
|
}
|
|
193
|
-
|
|
194
|
-
return undefined
|
|
195
30
|
}
|
package/src/text.ts
CHANGED
|
@@ -1,27 +1,51 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
? obj.model
|
|
20
|
-
: undefined
|
|
21
|
-
if (providerID && modelID) return { providerID, modelID }
|
|
22
|
-
return undefined
|
|
1
|
+
import type { LocationRef, ModelInfo, OpenCodeClient } from "@opencode/client"
|
|
2
|
+
|
|
3
|
+
export type SuggestionModel = { providerID: string; modelID: string; variant?: string }
|
|
4
|
+
|
|
5
|
+
export const NO_SMALL_MODEL = "No default small model available; set /suggest model provider/model"
|
|
6
|
+
|
|
7
|
+
export type SmallModelClient = {
|
|
8
|
+
agent: Pick<OpenCodeClient["agent"], "list">
|
|
9
|
+
model: Pick<OpenCodeClient["model"], "default" | "list">
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function selectSmallModel(models: readonly ModelInfo[], providerID: string): SuggestionModel | undefined {
|
|
13
|
+
const available = models.filter((model) => model.providerID === providerID && model.enabled &&
|
|
14
|
+
model.status === "active" && model.capabilities.input.some((item) => item.startsWith("text")) &&
|
|
15
|
+
model.capabilities.output.some((item) => item.startsWith("text")))
|
|
16
|
+
for (const family of ["gpt-luna", "gemini-flash-lite", "gemini-flash", "claude-haiku"]) {
|
|
17
|
+
const model = available.find((model) => model.family === family)
|
|
18
|
+
if (model) return { providerID: model.providerID, modelID: model.id }
|
|
23
19
|
}
|
|
24
|
-
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function resolveSmallModel(
|
|
24
|
+
client: SmallModelClient,
|
|
25
|
+
location: LocationRef,
|
|
26
|
+
signal: AbortSignal,
|
|
27
|
+
providerID?: string,
|
|
28
|
+
): Promise<SuggestionModel | undefined> {
|
|
29
|
+
if (signal.aborted) return undefined
|
|
30
|
+
const agents = await client.agent.list({ location }, { signal })
|
|
31
|
+
if (signal.aborted) return undefined
|
|
32
|
+
const configured = agents.data.find((agent) => agent.id === "title")?.model
|
|
33
|
+
if (configured) return { providerID: configured.providerID, modelID: configured.id, ...(configured.variant ? { variant: configured.variant } : {}) }
|
|
34
|
+
if (!providerID) {
|
|
35
|
+
const primary = await client.model.default({ location }, { signal })
|
|
36
|
+
if (signal.aborted) return undefined
|
|
37
|
+
providerID = primary.data?.providerID
|
|
38
|
+
}
|
|
39
|
+
if (!providerID) throw new Error(NO_SMALL_MODEL)
|
|
40
|
+
const catalog = await client.model.list({ location }, { signal })
|
|
41
|
+
if (signal.aborted) return undefined
|
|
42
|
+
const model = selectSmallModel(catalog.data, providerID)
|
|
43
|
+
if (!model) throw new Error(NO_SMALL_MODEL)
|
|
44
|
+
return model
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseModel(spec: string | undefined): { providerID: string; modelID: string } | undefined {
|
|
48
|
+
if (!spec || /\s/.test(spec)) return undefined
|
|
25
49
|
const index = spec.indexOf("/")
|
|
26
50
|
if (index <= 0 || index === spec.length - 1) return undefined
|
|
27
51
|
return { providerID: spec.slice(0, index), modelID: spec.slice(index + 1) }
|
|
@@ -53,34 +77,6 @@ export function normalize(raw: string, maxChars: number): string | undefined {
|
|
|
53
77
|
return cleaned.slice(0, maxChars - 1).trimEnd() + "…"
|
|
54
78
|
}
|
|
55
79
|
|
|
56
|
-
/**
|
|
57
|
-
* Number of visual rows `text` occupies when word-wrapped to `width` columns.
|
|
58
|
-
* Mirrors the greedy word wrap the prompt renderer uses, so a ghost can reserve
|
|
59
|
-
* exactly as many rows as it needs. Long words break at the column boundary.
|
|
60
|
-
*/
|
|
61
|
-
export function wrapCount(text: string, width: number): number {
|
|
62
|
-
if (width <= 0) return 1
|
|
63
|
-
let lines = 1
|
|
64
|
-
let col = 0
|
|
65
|
-
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
66
|
-
let length = word.length
|
|
67
|
-
if (col > 0) {
|
|
68
|
-
if (col + 1 + length <= width) {
|
|
69
|
-
col += 1 + length
|
|
70
|
-
continue
|
|
71
|
-
}
|
|
72
|
-
lines += 1
|
|
73
|
-
col = 0
|
|
74
|
-
}
|
|
75
|
-
while (length > width) {
|
|
76
|
-
lines += 1
|
|
77
|
-
length -= width
|
|
78
|
-
}
|
|
79
|
-
col = length
|
|
80
|
-
}
|
|
81
|
-
return lines
|
|
82
|
-
}
|
|
83
|
-
|
|
84
80
|
export function isEcho(suggestion: string, previous: string): boolean {
|
|
85
81
|
const strip = (value: string) =>
|
|
86
82
|
value
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { clip } from "./text"
|
|
2
|
+
|
|
3
|
+
const MAX_MESSAGE_CHARS = 800
|
|
4
|
+
const MAX_TRANSCRIPT_CHARS = 6000
|
|
5
|
+
|
|
6
|
+
export type TranscriptMessage =
|
|
7
|
+
| { readonly type: "user"; readonly text: string }
|
|
8
|
+
| { readonly type: "assistant"; readonly content: ReadonlyArray<{ readonly type: string; readonly text?: string }> }
|
|
9
|
+
| { readonly type: string }
|
|
10
|
+
|
|
11
|
+
function messageText(message: TranscriptMessage): { role: "User" | "Assistant"; text: string } | undefined {
|
|
12
|
+
if (message.type === "user" && "text" in message) return { role: "User", text: message.text }
|
|
13
|
+
if (message.type === "assistant" && "content" in message) {
|
|
14
|
+
const text = message.content
|
|
15
|
+
.filter((part) => part.type === "text")
|
|
16
|
+
.map((part) => part.text ?? "")
|
|
17
|
+
.join(" ")
|
|
18
|
+
return { role: "Assistant", text }
|
|
19
|
+
}
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildTranscript(messages: readonly TranscriptMessage[], recentMessages: number): string {
|
|
24
|
+
const lines: string[] = []
|
|
25
|
+
const entries = messages.map(messageText).filter((entry) => entry && entry.text.trim())
|
|
26
|
+
for (const entry of entries.slice(-recentMessages)) {
|
|
27
|
+
if (!entry) continue
|
|
28
|
+
lines.push(`${entry.role}: ${clip(entry.text, MAX_MESSAGE_CHARS)}`)
|
|
29
|
+
}
|
|
30
|
+
if (lines.length === 0) return ""
|
|
31
|
+
return `${lines.join("\n").slice(-MAX_TRANSCRIPT_CHARS)}\n\nTask: write the user's next message.`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function lastUserText(messages: readonly TranscriptMessage[]): string {
|
|
35
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
36
|
+
const message = messages[i]
|
|
37
|
+
if (message.type === "user") return (message as { text: string }).text
|
|
38
|
+
}
|
|
39
|
+
return ""
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function suggestionPrompt(system: string, transcript: string): string {
|
|
43
|
+
return `${system}\n\n${transcript}`
|
|
44
|
+
}
|