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.
@@ -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
- import { mkdir, open, readdir, rename, rm } from "node:fs/promises"
4
- import type { InstanceManifest } from "./types.js"
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
- if (process.env.DRIVE_REGISTRY_DIR) return process.env.DRIVE_REGISTRY_DIR
8
- return join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "opencode-drive", "instances")
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 async function registerInstance(manifest: InstanceManifest) {
16
- await mkdir(registryDirectory(), { recursive: true })
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 unregisterInstance(name: string, pid: number) {
38
- const file = manifestPath(name)
39
- const manifest = await readManifest(file)
40
- if (manifest?.pid !== pid) return
41
- await rm(file, { force: true })
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 transferInstance(manifest: InstanceManifest, pid: number) {
45
- const file = manifestPath(manifest.name)
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 resolveInstance(name?: string) {
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
- if (name) {
56
- const instance = instances.find((item) => item.name === name)
57
- if (!instance) throw new Error(`drive instance "${name}" was not found`)
58
- return instance
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 (instances.length === 1) return instances[0]!
61
- if (instances.length === 0) throw new Error("no drive instances are running")
62
- throw new Error(`multiple drive instances are running; pass --name (${instances.map((item) => item.name).join(", ")})`)
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 directory = registryDirectory()
68
- const files = (await readdir(directory)).filter((file) => file.endsWith(".json"))
69
- const entries = await Promise.all(files.map((file) => readManifest(join(directory, file))))
70
- const active = entries.filter((entry): entry is InstanceManifest => entry !== undefined && processAlive(entry.pid))
71
- const activeNames = new Set(active.map((entry) => entry.name))
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) => !activeNames.has(basename(file, ".json")))
75
- .map((file) => rm(join(directory, file), { force: true })),
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 readManifest(file: string): Promise<InstanceManifest | undefined> {
81
- if (!(await Bun.file(file).exists())) return undefined
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
- const value = await Bun.file(file).json()
84
- if (!isManifest(value)) return undefined
85
- return value
86
- } catch {
87
- await rm(file, { force: true })
88
- return undefined
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") return false
98
- if (!("mode" in value) || (value.mode !== "simulated" && value.mode !== "real")) return false
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") return false
102
- if (!("endpoints" in value) || typeof value.endpoints !== "object" || value.endpoints === null) return false
103
- return "ui" in value.endpoints && typeof value.endpoints.ui === "string" && "backend" in value.endpoints && typeof value.endpoints.backend === "string"
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 processAlive(pid: number) {
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 validateName(name: string) {
116
- if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)) {
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
+ }