opencode-drive 0.1.2 → 0.1.4
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 +19 -80
- package/package.json +1 -1
- package/src/cli/commands.ts +104 -68
- package/src/cli/control.ts +143 -0
- package/src/cli/index.ts +149 -106
- package/src/cli/instance.ts +222 -168
- package/src/cli/list.ts +10 -0
- package/src/cli/logs.ts +9 -0
- package/src/cli/mock-backend.ts +37 -0
- package/src/cli/registry.ts +224 -71
- package/src/cli/response-generator.ts +334 -0
- package/src/cli/responses.ts +20 -0
- package/src/cli/restart.ts +2 -65
- package/src/cli/script.ts +77 -0
- package/src/cli/send.ts +22 -39
- package/src/cli/start.ts +202 -65
- package/src/cli/stop.ts +19 -26
- package/src/cli/types.ts +7 -30
- package/src/client/backend.ts +95 -33
- package/src/client/client.ts +86 -29
- package/src/client/protocol.ts +116 -29
- package/src/client/protocol.types.ts +17 -23
- package/src/experimental/driver.ts +29 -18
- package/src/experimental/flow-driver.ts +130 -43
- package/src/experimental/flows/properties.ts +19 -13
- package/src/experimental/hello-driver.ts +11 -4
- package/src/experimental/stale-running-driver.ts +45 -13
- package/src/index.ts +2 -0
- package/src/cli/describe.ts +0 -14
- package/src/cli/driver-runner.ts +0 -39
- package/src/cli/driver.ts +0 -28
- package/src/experimental/cli-campaign.ts +0 -179
package/src/cli/registry.ts
CHANGED
|
@@ -1,109 +1,265 @@
|
|
|
1
|
+
import { mkdir, open, readdir, rename, rm } from "node:fs/promises"
|
|
1
2
|
import { homedir } from "node:os"
|
|
2
3
|
import { basename, join } from "node:path"
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
|
|
5
|
+
export interface InstanceManifest {
|
|
6
|
+
readonly version: 1
|
|
7
|
+
readonly name: string
|
|
8
|
+
readonly pid: number
|
|
9
|
+
readonly startedAt: string
|
|
10
|
+
readonly cwd: string
|
|
11
|
+
readonly artifacts: string
|
|
12
|
+
readonly visible: boolean
|
|
13
|
+
readonly status: "starting" | "ready"
|
|
14
|
+
readonly endpoints: { readonly ui: string; readonly backend: string }
|
|
15
|
+
readonly control: string
|
|
16
|
+
}
|
|
5
17
|
|
|
6
18
|
export function registryDirectory() {
|
|
7
|
-
|
|
8
|
-
|
|
19
|
+
return (
|
|
20
|
+
process.env.DRIVE_REGISTRY_DIR ??
|
|
21
|
+
join(
|
|
22
|
+
process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"),
|
|
23
|
+
"opencode-drive",
|
|
24
|
+
"instances",
|
|
25
|
+
)
|
|
26
|
+
)
|
|
9
27
|
}
|
|
10
28
|
|
|
11
29
|
export function manifestPath(name: string) {
|
|
12
30
|
return join(registryDirectory(), `${validateName(name)}.json`)
|
|
13
31
|
}
|
|
14
32
|
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
const file = manifestPath(manifest.name)
|
|
18
|
-
const lock = `${file}.lock`
|
|
19
|
-
const existing = await readManifest(file)
|
|
20
|
-
if (existing && processAlive(existing.pid)) throw new Error(`drive instance "${manifest.name}" is already running`)
|
|
21
|
-
if (existing) await rm(file, { force: true })
|
|
22
|
-
const handle = await open(lock, "wx").catch(() => undefined)
|
|
23
|
-
if (!handle) throw new Error(`drive instance "${manifest.name}" is already starting`)
|
|
24
|
-
try {
|
|
25
|
-
const current = await readManifest(file)
|
|
26
|
-
if (current && processAlive(current.pid)) throw new Error(`drive instance "${manifest.name}" is already running`)
|
|
27
|
-
const temporary = `${file}.${process.pid}.tmp`
|
|
28
|
-
await Bun.write(temporary, `${JSON.stringify(manifest, undefined, 2)}\n`)
|
|
29
|
-
await rename(temporary, file)
|
|
30
|
-
return file
|
|
31
|
-
} finally {
|
|
32
|
-
await handle.close()
|
|
33
|
-
await rm(lock, { force: true })
|
|
34
|
-
}
|
|
33
|
+
export function controlPath(name: string) {
|
|
34
|
+
return join(registryDirectory(), `${validateName(name)}.sock`)
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export async function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
export async function register(manifest: InstanceManifest) {
|
|
38
|
+
await withLock(manifest.name, false, async () => {
|
|
39
|
+
const existing = await read(manifestPath(manifest.name))
|
|
40
|
+
if (existing && alive(existing.pid))
|
|
41
|
+
throw new Error(`drive instance "${manifest.name}" is already running`)
|
|
42
|
+
await Promise.all([
|
|
43
|
+
rm(manifestPath(manifest.name), { force: true }),
|
|
44
|
+
rm(controlPath(manifest.name), { force: true }),
|
|
45
|
+
])
|
|
46
|
+
await write(manifest)
|
|
47
|
+
})
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
export async function
|
|
45
|
-
|
|
46
|
-
const current = await readManifest(file)
|
|
47
|
-
if (current?.pid !== manifest.pid) throw new Error(`drive instance "${manifest.name}" changed ownership`)
|
|
48
|
-
const temporary = `${file}.${process.pid}.tmp`
|
|
49
|
-
await Bun.write(temporary, `${JSON.stringify({ ...manifest, pid }, undefined, 2)}\n`)
|
|
50
|
-
await rename(temporary, file)
|
|
50
|
+
export async function markReady(name: string, pid: number) {
|
|
51
|
+
await markStatus(name, pid, "ready")
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
export async function
|
|
54
|
+
export async function markStarting(name: string, pid: number) {
|
|
55
|
+
await markStatus(name, pid, "starting")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function markStatus(
|
|
59
|
+
name: string,
|
|
60
|
+
pid: number,
|
|
61
|
+
status: InstanceManifest["status"],
|
|
62
|
+
) {
|
|
63
|
+
await withLock(name, true, async () => {
|
|
64
|
+
const manifest = await read(manifestPath(name))
|
|
65
|
+
if (!manifest || manifest.pid !== pid)
|
|
66
|
+
throw new Error(`drive instance "${name}" changed ownership`)
|
|
67
|
+
await write({ ...manifest, status })
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function resolveInstance(
|
|
72
|
+
name?: string,
|
|
73
|
+
options: { readonly ready?: boolean } = {},
|
|
74
|
+
) {
|
|
54
75
|
const instances = await listInstances()
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
76
|
+
const manifest = name
|
|
77
|
+
? instances.find((item) => item.name === name)
|
|
78
|
+
: instances.length === 1
|
|
79
|
+
? instances[0]
|
|
80
|
+
: undefined
|
|
81
|
+
if (!manifest) {
|
|
82
|
+
if (!name && instances.length > 1)
|
|
83
|
+
throw new Error(
|
|
84
|
+
`multiple drive instances are running; pass --name (${instances.map((item) => item.name).join(", ")})`,
|
|
85
|
+
)
|
|
86
|
+
throw new Error(
|
|
87
|
+
name
|
|
88
|
+
? `drive instance "${name}" was not found`
|
|
89
|
+
: "no drive instances are running",
|
|
90
|
+
)
|
|
59
91
|
}
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
92
|
+
if (options.ready !== false && manifest.status !== "ready")
|
|
93
|
+
throw new Error(`drive instance "${manifest.name}" is still starting`)
|
|
94
|
+
return manifest
|
|
63
95
|
}
|
|
64
96
|
|
|
65
97
|
export async function listInstances() {
|
|
66
98
|
await mkdir(registryDirectory(), { recursive: true })
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
99
|
+
const files = await readdir(registryDirectory())
|
|
100
|
+
const manifests = await Promise.all(
|
|
101
|
+
files
|
|
102
|
+
.filter((file) => file.endsWith(".json"))
|
|
103
|
+
.map(async (file) => {
|
|
104
|
+
const name = basename(file, ".json")
|
|
105
|
+
if (!validName(name)) {
|
|
106
|
+
await rm(join(registryDirectory(), file), { force: true })
|
|
107
|
+
return undefined
|
|
108
|
+
}
|
|
109
|
+
const manifest = await read(join(registryDirectory(), file))
|
|
110
|
+
if (manifest?.name === name && alive(manifest.pid)) return manifest
|
|
111
|
+
await prune(name, manifest?.pid)
|
|
112
|
+
return undefined
|
|
113
|
+
}),
|
|
114
|
+
)
|
|
115
|
+
const active = manifests.filter(
|
|
116
|
+
(manifest): manifest is InstanceManifest => manifest !== undefined,
|
|
117
|
+
)
|
|
118
|
+
const names = new Set(active.map((manifest) => manifest.name))
|
|
72
119
|
await Promise.all(
|
|
73
120
|
files
|
|
74
|
-
.filter((file) =>
|
|
75
|
-
.
|
|
121
|
+
.filter((file) => file.endsWith(".sock"))
|
|
122
|
+
.flatMap((file) => {
|
|
123
|
+
const name = basename(file, ".sock")
|
|
124
|
+
if (!validName(name))
|
|
125
|
+
return [rm(join(registryDirectory(), file), { force: true })]
|
|
126
|
+
if (!names.has(name)) return [prune(name)]
|
|
127
|
+
return []
|
|
128
|
+
}),
|
|
76
129
|
)
|
|
77
130
|
return active.sort((a, b) => a.name.localeCompare(b.name))
|
|
78
131
|
}
|
|
79
132
|
|
|
80
|
-
async function
|
|
81
|
-
|
|
133
|
+
export async function unregister(name: string, pid: number) {
|
|
134
|
+
await withLock(name, true, async () => {
|
|
135
|
+
const manifest = await read(manifestPath(name))
|
|
136
|
+
if (manifest?.pid !== pid) return
|
|
137
|
+
await Promise.all([
|
|
138
|
+
rm(manifestPath(name), { force: true }),
|
|
139
|
+
rm(controlPath(name), { force: true }),
|
|
140
|
+
])
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function prune(name: string, pid?: number) {
|
|
145
|
+
await withLock(name, true, async () => {
|
|
146
|
+
const manifest = await read(manifestPath(name))
|
|
147
|
+
if (manifest && (manifest.pid !== pid || alive(manifest.pid))) return
|
|
148
|
+
await Promise.all([
|
|
149
|
+
rm(manifestPath(name), { force: true }),
|
|
150
|
+
rm(controlPath(name), { force: true }),
|
|
151
|
+
])
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function write(manifest: InstanceManifest) {
|
|
156
|
+
const file = manifestPath(manifest.name)
|
|
157
|
+
const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
82
158
|
try {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
159
|
+
await Bun.write(temporary, `${JSON.stringify(manifest, undefined, 2)}\n`)
|
|
160
|
+
await rename(temporary, file)
|
|
161
|
+
} finally {
|
|
162
|
+
await rm(temporary, { force: true })
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function read(file: string): Promise<InstanceManifest | undefined> {
|
|
167
|
+
const value: unknown = await Bun.file(file)
|
|
168
|
+
.json()
|
|
169
|
+
.catch(() => undefined)
|
|
170
|
+
if (isManifest(value)) return value
|
|
171
|
+
return undefined
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function withLock(
|
|
175
|
+
name: string,
|
|
176
|
+
wait: boolean,
|
|
177
|
+
task: () => Promise<void>,
|
|
178
|
+
) {
|
|
179
|
+
await mkdir(registryDirectory(), { recursive: true })
|
|
180
|
+
const lock = `${manifestPath(name)}.lock`
|
|
181
|
+
const deadline = Date.now() + 10_000
|
|
182
|
+
while (true) {
|
|
183
|
+
const handle = await open(lock, "wx").catch((error: unknown) => {
|
|
184
|
+
if (isNodeError(error) && error.code === "EEXIST") return undefined
|
|
185
|
+
throw error
|
|
186
|
+
})
|
|
187
|
+
if (handle) {
|
|
188
|
+
try {
|
|
189
|
+
await handle.writeFile(`${process.pid}\n`)
|
|
190
|
+
await task()
|
|
191
|
+
return
|
|
192
|
+
} finally {
|
|
193
|
+
await handle.close()
|
|
194
|
+
await rm(lock, { force: true })
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (await staleLock(lock)) {
|
|
198
|
+
await rm(lock, { force: true })
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
if (!wait)
|
|
202
|
+
throw new Error(`drive instance "${name}" is already starting`)
|
|
203
|
+
if (Date.now() >= deadline)
|
|
204
|
+
throw new Error(`timed out updating drive instance "${name}"`)
|
|
205
|
+
await Bun.sleep(10)
|
|
89
206
|
}
|
|
90
207
|
}
|
|
91
208
|
|
|
209
|
+
async function staleLock(file: string) {
|
|
210
|
+
const pid = Number.parseInt(
|
|
211
|
+
await Bun.file(file)
|
|
212
|
+
.text()
|
|
213
|
+
.catch(() => ""),
|
|
214
|
+
10,
|
|
215
|
+
)
|
|
216
|
+
return Number.isInteger(pid) && !alive(pid)
|
|
217
|
+
}
|
|
218
|
+
|
|
92
219
|
function isManifest(value: unknown): value is InstanceManifest {
|
|
93
220
|
if (typeof value !== "object" || value === null) return false
|
|
94
221
|
if (!("version" in value) || value.version !== 1) return false
|
|
95
222
|
if (!("name" in value) || typeof value.name !== "string") return false
|
|
96
223
|
if (!("pid" in value) || typeof value.pid !== "number") return false
|
|
97
|
-
if (!("startedAt" in value) || typeof value.startedAt !== "string")
|
|
98
|
-
|
|
99
|
-
if (!("headless" in value) || typeof value.headless !== "boolean") return false
|
|
224
|
+
if (!("startedAt" in value) || typeof value.startedAt !== "string")
|
|
225
|
+
return false
|
|
100
226
|
if (!("cwd" in value) || typeof value.cwd !== "string") return false
|
|
101
|
-
if (!("artifacts" in value) || typeof value.artifacts !== "string")
|
|
102
|
-
|
|
103
|
-
|
|
227
|
+
if (!("artifacts" in value) || typeof value.artifacts !== "string")
|
|
228
|
+
return false
|
|
229
|
+
if (!("visible" in value) || typeof value.visible !== "boolean") return false
|
|
230
|
+
if (
|
|
231
|
+
!("status" in value) ||
|
|
232
|
+
(value.status !== "starting" && value.status !== "ready")
|
|
233
|
+
)
|
|
234
|
+
return false
|
|
235
|
+
if (!("control" in value) || typeof value.control !== "string") return false
|
|
236
|
+
if (
|
|
237
|
+
!("endpoints" in value) ||
|
|
238
|
+
typeof value.endpoints !== "object" ||
|
|
239
|
+
value.endpoints === null
|
|
240
|
+
)
|
|
241
|
+
return false
|
|
242
|
+
return (
|
|
243
|
+
"ui" in value.endpoints &&
|
|
244
|
+
typeof value.endpoints.ui === "string" &&
|
|
245
|
+
"backend" in value.endpoints &&
|
|
246
|
+
typeof value.endpoints.backend === "string"
|
|
247
|
+
)
|
|
104
248
|
}
|
|
105
249
|
|
|
106
|
-
function
|
|
250
|
+
function validateName(name: string) {
|
|
251
|
+
if (!validName(name))
|
|
252
|
+
throw new Error(
|
|
253
|
+
"instance names must contain 1-64 letters, numbers, dots, underscores, or dashes",
|
|
254
|
+
)
|
|
255
|
+
return name
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function validName(name: string) {
|
|
259
|
+
return /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function alive(pid: number) {
|
|
107
263
|
try {
|
|
108
264
|
process.kill(pid, 0)
|
|
109
265
|
return true
|
|
@@ -112,9 +268,6 @@ function processAlive(pid: number) {
|
|
|
112
268
|
}
|
|
113
269
|
}
|
|
114
270
|
|
|
115
|
-
function
|
|
116
|
-
|
|
117
|
-
throw new Error("instance names must contain 1-64 letters, numbers, dots, underscores, or dashes")
|
|
118
|
-
}
|
|
119
|
-
return name
|
|
271
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
272
|
+
return error instanceof Error && "code" in error
|
|
120
273
|
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { Backend } from "../client/protocol.js"
|
|
2
|
+
|
|
3
|
+
export const responseTypes = ["text", "reasoning", "tool", "diff"] as const
|
|
4
|
+
export type ResponseType = (typeof responseTypes)[number]
|
|
5
|
+
|
|
6
|
+
export interface ResponseConfiguration {
|
|
7
|
+
readonly types: ReadonlyArray<ResponseType>
|
|
8
|
+
readonly tools: ReadonlyArray<string>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ResponseUpdate {
|
|
12
|
+
readonly types?: ReadonlyArray<string>
|
|
13
|
+
readonly tools?: ReadonlyArray<string>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type JsonValue =
|
|
17
|
+
| null
|
|
18
|
+
| boolean
|
|
19
|
+
| number
|
|
20
|
+
| string
|
|
21
|
+
| ReadonlyArray<JsonValue>
|
|
22
|
+
| { readonly [key: string]: JsonValue }
|
|
23
|
+
|
|
24
|
+
const textResponses = [
|
|
25
|
+
"I took a careful look at the problem and followed it through the parts of the system that actually shape the behavior. The result is simpler than it first appeared: one clear boundary, one owner, and fewer opportunities for state to drift. There is a quiet satisfaction in watching the pieces settle into place.",
|
|
26
|
+
"The important path is working now, and the surrounding behavior remains intact. I kept the change focused, made the failure case visible, and checked the point where control passes from one process to another. It is a small adjustment, but it lets a little more daylight into the design.",
|
|
27
|
+
"I traced the request from its first input to its final effect and found the useful seam in between. The implementation now says what it means without asking the reader to remember hidden state. Nothing dramatic happened, which is often the nicest possible ending for this kind of work.",
|
|
28
|
+
"The pieces fit together cleanly after the change. Inputs are handled where they arrive, ownership stays explicit, and cleanup follows the same path every time. The code feels calmer now, like a room after someone has opened a window and put the books back in order.",
|
|
29
|
+
"I checked the current behavior, made the narrow change, and followed it through the edge cases that mattered. The result is direct enough to explain and ordinary enough to trust. Somewhere in the background, the event loop continues its patient little orbit.",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
const reasoningResponses = [
|
|
33
|
+
"I should inspect the available context before choosing the smallest reliable path through this.",
|
|
34
|
+
"I need to preserve the working behavior while checking the boundary where ownership changes hands.",
|
|
35
|
+
"The request contains enough information to proceed, though the assumptions deserve one careful pass first.",
|
|
36
|
+
"I will separate the observed behavior from the implementation detail, then test the seam between them.",
|
|
37
|
+
"The safest approach is to validate the current state, make one deliberate change, and follow its effects.",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
export function createResponseSettings() {
|
|
41
|
+
let configuration: ResponseConfiguration = {
|
|
42
|
+
types: ["text", "reasoning", "diff", "tool"],
|
|
43
|
+
tools: ["write", "apply_patch"],
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
current: () => configuration,
|
|
47
|
+
update(input: ResponseUpdate) {
|
|
48
|
+
const updated = {
|
|
49
|
+
types: input.types ? parseTypes(input.types) : configuration.types,
|
|
50
|
+
tools: input.tools ? parseTools(input.tools) : configuration.tools,
|
|
51
|
+
}
|
|
52
|
+
if (
|
|
53
|
+
updated.types.includes("diff") &&
|
|
54
|
+
!updated.tools.includes("*") &&
|
|
55
|
+
!updated.tools.some((tool) => diffTools.has(tool))
|
|
56
|
+
)
|
|
57
|
+
throw new Error(
|
|
58
|
+
"diff responses require apply_patch, write, or edit in --tools",
|
|
59
|
+
)
|
|
60
|
+
configuration = updated
|
|
61
|
+
return configuration
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function generateResponse(
|
|
67
|
+
configuration: ResponseConfiguration,
|
|
68
|
+
request: Backend.OpenedExchange,
|
|
69
|
+
): {
|
|
70
|
+
readonly items: ReadonlyArray<Backend.Item>
|
|
71
|
+
readonly finish: Backend.FinishReason
|
|
72
|
+
} {
|
|
73
|
+
if (hasToolResult(request.body)) return textResponse()
|
|
74
|
+
const tools = offeredTools(request.body).filter(
|
|
75
|
+
(tool) => configuration.tools.includes("*") || configuration.tools.includes(tool.name),
|
|
76
|
+
)
|
|
77
|
+
const available = configuration.types.filter(
|
|
78
|
+
(type) =>
|
|
79
|
+
(type !== "tool" || tools.length > 0) &&
|
|
80
|
+
(type !== "diff" || tools.some((tool) => diffTools.has(tool.name))),
|
|
81
|
+
)
|
|
82
|
+
const type = pick(available)
|
|
83
|
+
if (type === "reasoning")
|
|
84
|
+
return {
|
|
85
|
+
items: [
|
|
86
|
+
{ type: "reasoningDelta", text: pick(reasoningResponses) },
|
|
87
|
+
{ type: "textDelta", text: pick(textResponses) },
|
|
88
|
+
],
|
|
89
|
+
finish: "stop",
|
|
90
|
+
}
|
|
91
|
+
if (type === "tool") return toolResponse(tools.slice(0, 3), false)
|
|
92
|
+
if (type === "diff")
|
|
93
|
+
return toolResponse(
|
|
94
|
+
[
|
|
95
|
+
["apply_patch", "write", "edit"]
|
|
96
|
+
.map((name) => tools.find((tool) => tool.name === name))
|
|
97
|
+
.find((tool) => tool !== undefined)!,
|
|
98
|
+
],
|
|
99
|
+
true,
|
|
100
|
+
)
|
|
101
|
+
if (type === "text") return textResponse()
|
|
102
|
+
return {
|
|
103
|
+
items: [
|
|
104
|
+
{
|
|
105
|
+
type: "textDelta",
|
|
106
|
+
text: "No configured response type matched the tools offered by this request.",
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
finish: "stop",
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function textResponse() {
|
|
114
|
+
return {
|
|
115
|
+
items: [{ type: "textDelta" as const, text: pick(textResponses) }],
|
|
116
|
+
finish: "stop" as const,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface ToolDefinition {
|
|
121
|
+
readonly name: string
|
|
122
|
+
readonly parameters: unknown
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const diffTools = new Set(["apply_patch", "edit", "write"])
|
|
126
|
+
let gardenExpanded = false
|
|
127
|
+
|
|
128
|
+
function toolResponse(tools: ReadonlyArray<ToolDefinition>, diff: boolean) {
|
|
129
|
+
return {
|
|
130
|
+
items: tools.map((tool, index) => ({
|
|
131
|
+
type: "toolCall" as const,
|
|
132
|
+
index,
|
|
133
|
+
id: `call_${crypto.randomUUID().replaceAll("-", "").slice(0, 16)}`,
|
|
134
|
+
name: tool.name,
|
|
135
|
+
input: toolInput(tool, diff),
|
|
136
|
+
})),
|
|
137
|
+
finish: "tool-calls" as const,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function offeredTools(body: unknown) {
|
|
142
|
+
if (!isRecord(body) || !Array.isArray(body.tools)) return []
|
|
143
|
+
return body.tools.flatMap((value): ToolDefinition[] => {
|
|
144
|
+
if (!isRecord(value)) return []
|
|
145
|
+
const definition = isRecord(value.function) ? value.function : value
|
|
146
|
+
if (typeof definition.name !== "string") return []
|
|
147
|
+
return [
|
|
148
|
+
{
|
|
149
|
+
name: definition.name,
|
|
150
|
+
parameters: definition.parameters ?? definition.inputSchema,
|
|
151
|
+
},
|
|
152
|
+
]
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function toolInput(tool: ToolDefinition, diff: boolean) {
|
|
157
|
+
const generated = schemaValue(tool.parameters, "input")
|
|
158
|
+
const input = isJsonRecord(generated) ? generated : {}
|
|
159
|
+
const known = knownInput(tool.name, diff)
|
|
160
|
+
if (!isRecord(tool.parameters) || !isRecord(tool.parameters.properties))
|
|
161
|
+
return { ...input, ...known }
|
|
162
|
+
const properties = tool.parameters.properties
|
|
163
|
+
return {
|
|
164
|
+
...input,
|
|
165
|
+
...Object.fromEntries(
|
|
166
|
+
Object.entries(known).filter(
|
|
167
|
+
([key, value]) =>
|
|
168
|
+
key in properties && acceptsValue(properties[key], value),
|
|
169
|
+
),
|
|
170
|
+
),
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function knownInput(name: string, diff: boolean): Record<string, JsonValue> {
|
|
175
|
+
const suffix = crypto.randomUUID().slice(0, 8)
|
|
176
|
+
if (name === "apply_patch")
|
|
177
|
+
return {
|
|
178
|
+
patchText: gardenPatch(),
|
|
179
|
+
}
|
|
180
|
+
if (name === "write")
|
|
181
|
+
return {
|
|
182
|
+
path: "src/garden.js",
|
|
183
|
+
filePath: "src/garden.js",
|
|
184
|
+
content:
|
|
185
|
+
'export function greet(name, punctuation = "!") {\n const visitor = name.trim() || "traveler"\n return `Hello, ${visitor}${punctuation}`\n}\n',
|
|
186
|
+
}
|
|
187
|
+
if (name === "edit")
|
|
188
|
+
return {
|
|
189
|
+
path: ".opencode/opencode.jsonc",
|
|
190
|
+
filePath: ".opencode/opencode.jsonc",
|
|
191
|
+
oldString: '"name": "Simulation"',
|
|
192
|
+
newString: `"name": "Simulation ${suffix}"`,
|
|
193
|
+
}
|
|
194
|
+
if (name === "read")
|
|
195
|
+
return { path: ".opencode/opencode.jsonc", filePath: ".opencode/opencode.jsonc", offset: 1, limit: 120 }
|
|
196
|
+
if (name === "glob") return { pattern: "**/*", path: ".", limit: 20 }
|
|
197
|
+
if (name === "grep")
|
|
198
|
+
return { pattern: "simulation", path: ".opencode", include: "*.jsonc", limit: 20 }
|
|
199
|
+
if (name === "shell" || name === "bash")
|
|
200
|
+
return { command: diff ? "git diff --stat" : "pwd", description: "Inspect the workspace" }
|
|
201
|
+
return {}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function gardenPatch() {
|
|
205
|
+
const patch = gardenExpanded
|
|
206
|
+
? '*** Begin Patch\n*** Update File: src/garden.js\n@@\n-export function greet(name, punctuation = "!") {\n- const visitor = name.trim() || "traveler"\n- return `Hello, ${visitor}${punctuation}`\n+export function greet(name) {\n+ return `Hello, ${name}.`\n }\n*** End Patch'
|
|
207
|
+
: '*** Begin Patch\n*** Update File: src/garden.js\n@@\n-export function greet(name) {\n- return `Hello, ${name}.`\n+export function greet(name, punctuation = "!") {\n+ const visitor = name.trim() || "traveler"\n+ return `Hello, ${visitor}${punctuation}`\n }\n*** End Patch'
|
|
208
|
+
gardenExpanded = !gardenExpanded
|
|
209
|
+
return patch
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function schemaValue(
|
|
213
|
+
schema: unknown,
|
|
214
|
+
key: string,
|
|
215
|
+
root: unknown = schema,
|
|
216
|
+
): JsonValue {
|
|
217
|
+
if (!isRecord(schema)) return {}
|
|
218
|
+
if (typeof schema.$ref === "string" && schema.$ref.startsWith("#/$defs/")) {
|
|
219
|
+
const name = schema.$ref.slice("#/$defs/".length)
|
|
220
|
+
if (isRecord(root) && isRecord(root.$defs))
|
|
221
|
+
return schemaValue(root.$defs[name], key, root)
|
|
222
|
+
}
|
|
223
|
+
if ("const" in schema && isJson(schema.const)) return schema.const
|
|
224
|
+
if (Array.isArray(schema.enum) && schema.enum.length > 0 && isJson(schema.enum[0]))
|
|
225
|
+
return schema.enum[0]
|
|
226
|
+
const alternative = Array.isArray(schema.anyOf)
|
|
227
|
+
? schema.anyOf[0]
|
|
228
|
+
: Array.isArray(schema.oneOf)
|
|
229
|
+
? schema.oneOf[0]
|
|
230
|
+
: undefined
|
|
231
|
+
if (alternative !== undefined) return schemaValue(alternative, key, root)
|
|
232
|
+
if (Array.isArray(schema.allOf) && schema.allOf.length > 0 && schema.type === undefined)
|
|
233
|
+
return schemaValue(schema.allOf[0], key, root)
|
|
234
|
+
if (schema.type === "object" || isRecord(schema.properties)) {
|
|
235
|
+
const properties = isRecord(schema.properties) ? schema.properties : {}
|
|
236
|
+
const required = Array.isArray(schema.required)
|
|
237
|
+
? schema.required.filter((value): value is string => typeof value === "string")
|
|
238
|
+
: []
|
|
239
|
+
return Object.fromEntries(
|
|
240
|
+
required.map((name) => [name, schemaValue(properties[name], name, root)]),
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
if (schema.type === "array") {
|
|
244
|
+
const count = typeof schema.minItems === "number" ? Math.max(1, schema.minItems) : 1
|
|
245
|
+
return Array.from({ length: count }, () => schemaValue(schema.items, key, root))
|
|
246
|
+
}
|
|
247
|
+
if (schema.type === "boolean") return false
|
|
248
|
+
if (schema.type === "integer" || schema.type === "number") {
|
|
249
|
+
if (typeof schema.minimum === "number") return schema.minimum
|
|
250
|
+
if (typeof schema.exclusiveMinimum === "number") return schema.exclusiveMinimum + 1
|
|
251
|
+
return 1
|
|
252
|
+
}
|
|
253
|
+
if (schema.type === "null") return null
|
|
254
|
+
if (key.toLowerCase().includes("path")) return "."
|
|
255
|
+
if (key.toLowerCase().includes("pattern")) return "TODO"
|
|
256
|
+
if (key.toLowerCase().includes("command")) return "pwd"
|
|
257
|
+
if (key.toLowerCase().includes("content")) return "Generated by the simulated model."
|
|
258
|
+
const value = "sample"
|
|
259
|
+
return typeof schema.minLength === "number"
|
|
260
|
+
? value.padEnd(schema.minLength, "x")
|
|
261
|
+
: value
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function hasToolResult(body: unknown) {
|
|
265
|
+
if (!isRecord(body) || !Array.isArray(body.messages)) return false
|
|
266
|
+
const message = body.messages.at(-1)
|
|
267
|
+
if (!isRecord(message)) return false
|
|
268
|
+
if (message.role === "tool") return true
|
|
269
|
+
if (!Array.isArray(message.content)) return false
|
|
270
|
+
return message.content.some(
|
|
271
|
+
(part) => isRecord(part) && (part.type === "tool-result" || part.type === "tool"),
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function acceptsValue(schema: unknown, value: JsonValue) {
|
|
276
|
+
if (!isRecord(schema)) return true
|
|
277
|
+
if (Array.isArray(schema.enum) && !schema.enum.some((item) => item === value))
|
|
278
|
+
return false
|
|
279
|
+
if (schema.type === "string") return typeof value === "string"
|
|
280
|
+
if (schema.type === "number" || schema.type === "integer")
|
|
281
|
+
return typeof value === "number"
|
|
282
|
+
if (schema.type === "boolean") return typeof value === "boolean"
|
|
283
|
+
if (schema.type === "array") return Array.isArray(value)
|
|
284
|
+
if (schema.type === "object") return isJsonRecord(value)
|
|
285
|
+
return true
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function parseTypes(values: ReadonlyArray<string>) {
|
|
289
|
+
const types = unique(values)
|
|
290
|
+
if (types.length === 0) throw new Error("responses requires at least one type")
|
|
291
|
+
const valid = types.filter(isResponseType)
|
|
292
|
+
const unknown = types.filter((value) => !isResponseType(value))
|
|
293
|
+
if (unknown.length > 0)
|
|
294
|
+
throw new Error(`unknown response types: ${unknown.join(", ")}`)
|
|
295
|
+
return valid
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function parseTools(values: ReadonlyArray<string>) {
|
|
299
|
+
const tools = unique(values)
|
|
300
|
+
if (tools.length === 0) throw new Error("responses requires at least one tool or *")
|
|
301
|
+
if (tools.some((tool) => tool !== "*" && !/^[a-zA-Z0-9_.:-]+$/.test(tool)))
|
|
302
|
+
throw new Error("tool names may contain only letters, numbers, dots, underscores, colons, or dashes")
|
|
303
|
+
return tools
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function unique(values: ReadonlyArray<string>) {
|
|
307
|
+
return [...new Set(values.map((value) => value.trim()).filter(Boolean))]
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function pick<T>(values: ReadonlyArray<T>) {
|
|
311
|
+
return values[Math.floor(Math.random() * values.length)]!
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
315
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function isJsonRecord(
|
|
319
|
+
value: JsonValue,
|
|
320
|
+
): value is { readonly [key: string]: JsonValue } {
|
|
321
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function isJson(value: unknown): value is JsonValue {
|
|
325
|
+
if (value === null) return true
|
|
326
|
+
if (["boolean", "number", "string"].includes(typeof value)) return true
|
|
327
|
+
if (Array.isArray(value)) return value.every(isJson)
|
|
328
|
+
if (!isRecord(value)) return false
|
|
329
|
+
return Object.values(value).every(isJson)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function isResponseType(value: string): value is ResponseType {
|
|
333
|
+
return responseTypes.some((type) => type === value)
|
|
334
|
+
}
|