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/src/stateless.ts CHANGED
@@ -1,195 +1,30 @@
1
- import * as fs from "node:fs"
2
- import * as os from "node:os"
3
- import * as path from "node:path"
4
- import type { ResolvedOptions } from "./options"
5
-
6
- const WELL_KNOWN_BASE_URLS: Record<string, string> = {
7
- openai: "https://api.openai.com/v1",
8
- deepseek: "https://api.deepseek.com",
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 resolveProviderApiKey(
56
- opts: ResolvedOptions,
57
- providerID: string | undefined,
58
- providerConfig: any,
59
- providerState: any,
60
- ): string | undefined {
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
- const providerID = modelRef?.providerID
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
- if (baseURL) {
126
- const url = baseURL.endsWith("/chat/completions")
127
- ? baseURL
128
- : `${baseURL.replace(/\/+$/, "")}/chat/completions`
129
-
130
- const rawModel = modelRef?.modelID || "default"
131
- const keepSlash = providerID === "openrouter" || providerID === "together"
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
- export function parseModel(
2
- spec: unknown,
3
- ): { providerID: string; modelID: string } | undefined {
4
- if (!spec) return undefined
5
- if (typeof spec === "object" && spec !== null) {
6
- const obj = spec as Record<string, unknown>
7
- const providerID =
8
- typeof obj.providerID === "string"
9
- ? obj.providerID
10
- : typeof obj.provider === "string"
11
- ? obj.provider
12
- : undefined
13
- const modelID =
14
- typeof obj.modelID === "string"
15
- ? obj.modelID
16
- : typeof obj.id === "string"
17
- ? obj.id
18
- : typeof obj.model === "string"
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
- if (typeof spec !== "string") return undefined
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
+ }