opencode-drive 0.1.3 → 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 CHANGED
@@ -1,119 +1,39 @@
1
1
  # opencode-drive
2
2
 
3
- Drive real and simulated OpenCode instances through fixed local WebSocket ports.
3
+ This project gives your agents control over OpenCode:
4
+
5
+ * Run it during development and let your agents see and poke at the running instance
6
+ * Allow your agents to run it in headless mode and drive it to test things
4
7
 
5
8
  ## Skill
6
9
 
7
10
  ```sh
8
11
  npx skills add jlongster/opencode-drive --agent opencode --skill opencode-drive
9
12
  ```
13
+ ## OpenCode development
10
14
 
11
- # Usage
12
-
13
- **Start the default headless instance:**
14
-
15
- ```bash
16
- bunx opencode-drive start
17
- ```
18
-
19
- There is no "detached" version. If you want to drive an isolated app, use the scripted version below.
20
-
21
- Run a local OpenCode checkout:
22
-
23
- ```sh
24
- bunx opencode-drive start --dev ~/projects/opencode
25
- ```
26
-
27
- ## Send UI Commands
28
-
29
- `send` always connects to UI port `40900`:
30
-
31
- ```sh
32
- bunx opencode-drive send \
33
- --command.ui.type '{"text":"Explain this project"}' \
34
- --command.ui.enter
35
-
36
- bunx opencode-drive send --command.ui.state
37
- bunx opencode-drive send --command.ui.screenshot
38
- ```
39
-
40
- Run `bunx opencode-drive api` for the full UI command contract.
41
-
42
- ## Scripted Mode
43
-
44
- Scripted mode launches OpenCode, runs the script, and stops OpenCode when the script exits.
15
+ Run this:
45
16
 
46
17
  ```sh
47
- bunx opencode-drive start --script ./drive.ts
48
- ```
49
-
50
- Add `--visible` to watch the TUI while the script runs. Visible starts remain active after a script finishes. Restart the visible OpenCode child and rerun its script with `bunx opencode-drive restart`.
51
-
52
- Only one visible instance can be controlled this way. Headless runs exit when their script completes and do not support restart.
53
-
54
- The module must default-export a function receiving connected clients and the run's artifact directory:
55
-
56
- ```ts
57
- import { defineScript } from "opencode-drive"
58
-
59
- export default defineScript(async ({ ui, backend }) => {
60
- await backend.attach(async (request) => {
61
- await backend.chunk(request.id, [{ type: "textDelta", text: "Hello" }])
62
- await backend.finish(request.id)
63
- })
64
-
65
- await ui.typeText("Say hello")
66
- await ui.pressEnter()
67
- await ui.screenshot()
68
- })
18
+ OPENCODE_DRIVE=1 bun run dev
69
19
  ```
70
20
 
71
- # Use cases
72
-
73
- There are two different modes OpenCode can run in:
74
-
75
- * Simulated: core layers like networking are swapped out in the backend so you can control them
76
- * Driven: starts websocket servers internally to expose commands to drive the app
21
+ If you installed the skill file, OpenCode will be able to see and interact with the running instance.
77
22
 
78
- You can choose one or the other, or both! This allows the following use cases:
23
+ ## Using with agents
79
24
 
80
- ## You want to develop opencode
25
+ Install the skill file above and ask the agent to test various flows with the app. You also ask it to make a recording and it will generate a video file for you to watch anything it did.
81
26
 
82
- You are running a development version of OpenCode to work on it. You have another OpenCode instance making changes to the app.
27
+ ## UI development
83
28
 
84
- In this case, you don't care about simulation. But you still want OpenCode to see and drive your development version! This _closes the loop_ and gives direct feedback to AI.
29
+ If you are doing UI development in OpenCode, you might want to run it in a simulated mode. This allows `opencode-drive` to drive it and always put it into a state that you want to see.
85
30
 
86
- To do this, all you have to do is pass `OPENCODE_DRIVE=1` when running opencode:
31
+ Run it in visible mode:
87
32
 
88
33
  ```sh
89
- # In the opencode repo
90
- OPENCODE_DRIVE=1 bun run dev
91
- ```
92
-
93
- #### UI work in simulated mode
94
-
95
- If you are doing UI work, you may want to run it in simulated mode. You can do that with this:
96
-
34
+ opencode-drive start --visible --dev ~/projects/opencode
97
35
  ```
98
- bunx opencode-drive start --dev . --visible
99
- ```
100
-
101
- The nice thing about this is OpenCode can drive it into the states you are interested in. Additionally, its able to restart the UI itself. The server will still be running in the background so this only restarts the UI:
102
-
103
- ```sh
104
- bunx opencode-drive restart
105
- ```
106
-
107
- ## You want opencode to develop itself
108
-
109
- OpenCode can spawn it's own instances of OpenCode in headless mode. Use the skill in this repo to teach it about it.
110
-
111
- It will spawn it in both drive and simulated mode, allowing it explore the app. It will use the `screenshot` command to capture screenshots to see what's happening, providing a full feedback loop.
112
-
113
- ## You want to share reproducible steps
114
-
115
- Ask opencode to find a bug, and list the commands it used to get there. You can then share this list with someone else who can use `opencode-drive` in visible mode to visually inspect it and develop a fix. (Or just share the steps with your own local OpenCode to find a fix)
116
36
 
117
- ## You want to run it a billion times and assert properties
37
+ While developing, you can run `opencode-drive restart` to restart only the UI (the server will persist as a separate process). Do this with agents, and they will always restart and get the UI where you want it to be automatically.
118
38
 
119
- This isn't fully implemented right now, but in the future `opencode-drive` will provide a way to specify properties that you want to assert, and run the app a billion times in many different states to make sure those properties hold. This will work with a new CLI command that takes different flags.
39
+ View the [skills file](https://github.com/jlongster/opencode-drive/blob/main/skills/opencode-drive/SKILL.md) for more details about the CLI.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-drive",
4
- "version": "0.1.3",
4
+ "version": "0.1.4",
5
5
  "description": "Drive real and simulated OpenCode instances",
6
6
  "license": "MIT",
7
7
  "type": "module",
@@ -47,7 +47,10 @@ export function commandNames() {
47
47
  return Object.keys(commandInfo).sort()
48
48
  }
49
49
 
50
- export async function executeCommands(endpoint: string, commands: ReadonlyArray<DriveCommand>) {
50
+ export async function executeCommands(
51
+ endpoint: string,
52
+ commands: ReadonlyArray<DriveCommand>,
53
+ ) {
51
54
  const ui = await connectSimulation({ url: endpoint })
52
55
  const results: Array<{ readonly command: string; readonly result: unknown }> =
53
56
  []
@@ -1,21 +1,52 @@
1
1
  import { rm } from "node:fs/promises"
2
2
  import { connect, createServer } from "node:net"
3
+ import type {
4
+ ResponseConfiguration,
5
+ ResponseUpdate,
6
+ } from "./response-generator.js"
3
7
 
4
8
  export async function listenControl(
5
9
  path: string,
6
- handlers: { readonly restart: () => Promise<void>; readonly stop: () => Promise<void> },
10
+ handlers: {
11
+ readonly restart: () => Promise<void>
12
+ readonly stop: () => Promise<void>
13
+ readonly responses: (
14
+ input: ResponseUpdate,
15
+ ) => Promise<ResponseConfiguration>
16
+ },
7
17
  ) {
8
18
  const server = createServer((socket) => {
19
+ let buffer = ""
9
20
  socket.setEncoding("utf8")
10
- socket.once("data", (data) => {
11
- const handler = handlers[String(data).trim() as keyof typeof handlers]
12
- socket.destroy()
13
- if (!handler) return
14
- void handler().catch((error) => {
15
- console.error(`error: ${error instanceof Error ? error.message : String(error)}`)
16
- })
21
+ socket.on("data", (data) => {
22
+ buffer += data
23
+ if (buffer.length > 64 * 1024) {
24
+ socket.removeAllListeners("data")
25
+ socket.end("error: control request exceeds 64 KiB\n")
26
+ return
27
+ }
28
+ if (!buffer.includes("\n")) return
29
+ socket.removeAllListeners("data")
30
+ void handle(buffer.slice(0, buffer.indexOf("\n"))).then(
31
+ (result) =>
32
+ socket.end(
33
+ `success${result === undefined ? "" : ` ${JSON.stringify(result)}`}\n`,
34
+ ),
35
+ (error) =>
36
+ socket.end(
37
+ `error: ${error instanceof Error ? error.message : String(error)}\n`,
38
+ ),
39
+ )
17
40
  })
18
41
  })
42
+ const handle = async (input: string) => {
43
+ if (input === "restart") return handlers.restart()
44
+ if (input === "stop") return handlers.stop()
45
+ if (input === "responses") return handlers.responses({})
46
+ if (input.startsWith("responses "))
47
+ return handlers.responses(parseResponseUpdate(input.slice("responses ".length)))
48
+ throw new Error("unknown control command")
49
+ }
19
50
  await listen(server, path)
20
51
  return async () => {
21
52
  await new Promise<void>((resolve) => server.close(() => resolve()))
@@ -23,14 +54,83 @@ export async function listenControl(
23
54
  }
24
55
  }
25
56
 
26
- export function request(path: string, command: "restart" | "stop") {
27
- return new Promise<void>((resolve, reject) => {
57
+ export async function request(path: string, command: "restart" | "stop") {
58
+ const response = await send(path, command)
59
+ if (response !== "success") throw responseError(response)
60
+ }
61
+
62
+ export async function requestResponses(path: string, input: ResponseUpdate) {
63
+ const response = await send(
64
+ path,
65
+ Object.keys(input).length === 0
66
+ ? "responses"
67
+ : `responses ${JSON.stringify(input)}`,
68
+ )
69
+ if (!response.startsWith("success ")) throw responseError(response)
70
+ const value: unknown = JSON.parse(response.slice("success ".length))
71
+ if (!isResponseConfiguration(value))
72
+ throw new Error("instance returned an invalid response configuration")
73
+ return value
74
+ }
75
+
76
+ function send(path: string, command: string) {
77
+ return new Promise<string>((resolve, reject) => {
28
78
  const socket = connect(path)
29
- socket.on("connect", () => socket.end(`${command}\n`, resolve))
30
- socket.on("error", () => reject(new Error("instance control socket is unavailable")))
79
+ let response = ""
80
+ const timer = setTimeout(() => {
81
+ socket.destroy()
82
+ reject(new Error("instance control request timed out"))
83
+ }, 10_000)
84
+ socket.setEncoding("utf8")
85
+ socket.on("connect", () => socket.write(`${command}\n`))
86
+ socket.on("data", (data) => {
87
+ response += data
88
+ })
89
+ socket.on("end", () => {
90
+ clearTimeout(timer)
91
+ resolve(response.trim())
92
+ })
93
+ socket.on("error", () => {
94
+ clearTimeout(timer)
95
+ reject(new Error("instance control socket is unavailable"))
96
+ })
31
97
  })
32
98
  }
33
99
 
100
+ function parseResponseUpdate(input: string): ResponseUpdate {
101
+ const value: unknown = JSON.parse(input)
102
+ if (typeof value !== "object" || value === null || Array.isArray(value))
103
+ throw new Error("invalid responses configuration")
104
+ const types = "types" in value ? stringArray(value.types) : undefined
105
+ const tools = "tools" in value ? stringArray(value.tools) : undefined
106
+ return {
107
+ ...(types === undefined ? {} : { types }),
108
+ ...(tools === undefined ? {} : { tools }),
109
+ }
110
+ }
111
+
112
+ function stringArray(value: unknown) {
113
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string"))
114
+ throw new Error("response types and tools must be string arrays")
115
+ return value
116
+ }
117
+
118
+ function isResponseConfiguration(
119
+ value: unknown,
120
+ ): value is ResponseConfiguration {
121
+ if (typeof value !== "object" || value === null) return false
122
+ if (!("types" in value) || !stringArrayValue(value.types)) return false
123
+ return "tools" in value && stringArrayValue(value.tools)
124
+ }
125
+
126
+ function stringArrayValue(value: unknown) {
127
+ return Array.isArray(value) && value.every((item) => typeof item === "string")
128
+ }
129
+
130
+ function responseError(response: string) {
131
+ return new Error(response.replace(/^error:\s*/, "") || "empty control response")
132
+ }
133
+
34
134
  async function listen(server: ReturnType<typeof createServer>, path: string) {
35
135
  await rm(path, { force: true })
36
136
  await new Promise<void>((resolve, reject) => {
package/src/cli/index.ts CHANGED
@@ -4,16 +4,32 @@ import { Effect, Option } from "effect"
4
4
  import { Command, Flag } from "effect/unstable/cli"
5
5
  import { api } from "./api.js"
6
6
  import { extractCommands } from "./parse.js"
7
+ import { list } from "./list.js"
8
+ import { logs } from "./logs.js"
9
+ import { restart } from "./restart.js"
10
+ import { responses } from "./responses.js"
7
11
  import { send } from "./send.js"
8
12
  import { start } from "./start.js"
9
- import { restart } from "./restart.js"
13
+ import { stop } from "./stop.js"
10
14
  import type { DriveCommand, SendOptions, StartOptions } from "./types.js"
11
15
 
12
16
  const extracted = extract()
17
+ const startName = Flag.string("name").pipe(
18
+ Flag.withDefault("default"),
19
+ Flag.withDescription("Instance name"),
20
+ )
21
+ const name = Flag.string("name").pipe(
22
+ Flag.optional,
23
+ Flag.withDescription("Instance name (inferred when exactly one is running)"),
24
+ )
13
25
 
14
26
  const startCommand = Command.make(
15
27
  "start",
16
28
  {
29
+ name: startName,
30
+ daemon: Flag.boolean("daemon").pipe(
31
+ Flag.withDescription("Run as detached instance owner"),
32
+ ),
17
33
  script: Flag.string("script").pipe(
18
34
  Flag.optional,
19
35
  Flag.withDescription("JavaScript or TypeScript automation module"),
@@ -52,8 +68,16 @@ const startCommand = Command.make(
52
68
  ]),
53
69
  )
54
70
 
55
- const sendCommand = Command.make("send", {}, () =>
56
- execute(() => send(toSendOptions(extracted.commands, extracted.app))),
71
+ const sendCommand = Command.make("send", { name }, (config) =>
72
+ execute(() =>
73
+ send(
74
+ toSendOptions(
75
+ Option.getOrUndefined(config.name),
76
+ extracted.commands,
77
+ extracted.app,
78
+ ),
79
+ ),
80
+ ),
57
81
  ).pipe(
58
82
  Command.withDescription("Send UI commands to OpenCode on the default port"),
59
83
  Command.withExamples([
@@ -69,18 +93,59 @@ const apiCommand = Command.make("api", {}, () => execute(api)).pipe(
69
93
  Command.withDescription("Print the OpenCode drive UI protocol"),
70
94
  )
71
95
 
72
- const restartCommand = Command.make("restart", {}, () => execute(restart)).pipe(
96
+ const restartCommand = Command.make("restart", { name }, (config) =>
97
+ execute(() => restart(Option.getOrUndefined(config.name))),
98
+ ).pipe(
73
99
  Command.withDescription(
74
- "Restart the active visible OpenCode instance and rerun its script",
100
+ "Restart a named OpenCode instance and rerun its script",
75
101
  ),
76
102
  )
77
103
 
104
+ const stopCommand = Command.make("stop", { name }, (config) =>
105
+ execute(() => stop(Option.getOrUndefined(config.name))),
106
+ ).pipe(Command.withDescription("Stop a named OpenCode instance"))
107
+
108
+ const logsCommand = Command.make("logs", { name }, (config) =>
109
+ execute(() => logs(Option.getOrUndefined(config.name))),
110
+ ).pipe(Command.withDescription("List log files for a named OpenCode instance"))
111
+
112
+ const listCommand = Command.make("list", {}, () => execute(list)).pipe(
113
+ Command.withDescription("List active OpenCode instances"),
114
+ )
115
+
116
+ const responsesCommand = Command.make(
117
+ "responses",
118
+ {
119
+ name,
120
+ types: Flag.string("types").pipe(
121
+ Flag.optional,
122
+ Flag.withDescription("Comma-delimited response types"),
123
+ ),
124
+ tools: Flag.string("tools").pipe(
125
+ Flag.optional,
126
+ Flag.withDescription("Comma-delimited tool names, or * for all tools"),
127
+ ),
128
+ },
129
+ (config) =>
130
+ execute(() =>
131
+ responses({
132
+ name: Option.getOrUndefined(config.name),
133
+ types: Option.getOrUndefined(config.types),
134
+ tools: Option.getOrUndefined(config.tools),
135
+ }),
136
+ ),
137
+ ).pipe(Command.withDescription("Configure simulated LLM response generation"))
138
+
78
139
  const root = Command.make("opencode-drive").pipe(
79
140
  Command.withDescription("Drive real and simulated OpenCode instances"),
80
141
  Command.withSubcommands([
81
142
  startCommand,
82
143
  sendCommand,
144
+ listCommand,
145
+ responsesCommand,
146
+ logsCommand,
83
147
  restartCommand,
148
+ stopCommand,
84
149
  apiCommand,
85
150
  ]),
86
151
  )
@@ -93,6 +158,8 @@ Command.runWith(root, { version: "0.1.0" })(extracted.args).pipe(
93
158
  function toStartOptions(
94
159
  config: {
95
160
  readonly script: Option.Option<string>
161
+ readonly name: string
162
+ readonly daemon: boolean
96
163
  readonly visible: boolean
97
164
  readonly dev: Option.Option<string>
98
165
  readonly state: Option.Option<string>
@@ -104,6 +171,8 @@ function toStartOptions(
104
171
  throw new Error("start does not accept command flags; use send or --script")
105
172
  const options = {
106
173
  kind: "start" as const,
174
+ name: config.name,
175
+ daemon: config.daemon,
107
176
  script: Option.getOrUndefined(config.script),
108
177
  visible: config.visible,
109
178
  dev: Option.getOrUndefined(config.dev),
@@ -116,11 +185,12 @@ function toStartOptions(
116
185
  }
117
186
 
118
187
  function toSendOptions(
188
+ name: string | undefined,
119
189
  commands: ReadonlyArray<DriveCommand>,
120
190
  app: ReadonlyArray<string>,
121
191
  ): SendOptions {
122
192
  if (app.length > 0) throw new Error("send does not accept a command after --")
123
- return { kind: "send", commands }
193
+ return { kind: "send", name, commands }
124
194
  }
125
195
 
126
196
  function execute(task: () => Promise<void>) {
@@ -1,7 +1,6 @@
1
1
  import { mkdir, rm } from "node:fs/promises"
2
2
  import { tmpdir } from "node:os"
3
3
  import { join, resolve } from "node:path"
4
- import { defaultBackendPort, defaultPort } from "../client/index.js"
5
4
 
6
5
  export interface LaunchOptions {
7
6
  readonly name: string
@@ -13,7 +12,7 @@ export interface LaunchOptions {
13
12
  readonly env?: Readonly<Record<string, string>>
14
13
  }
15
14
 
16
- export async function launchInstance(options: LaunchOptions = {}) {
15
+ export async function launchInstance(options: LaunchOptions) {
17
16
  const artifacts = resolve(
18
17
  join(tmpdir(), "opencode-drive", `run-${crypto.randomUUID().slice(0, 6)}`),
19
18
  )
@@ -31,7 +30,10 @@ export async function launchInstance(options: LaunchOptions = {}) {
31
30
  mkdir(join(artifacts, "home", ".local", "share"), { recursive: true }),
32
31
  mkdir(join(artifacts, "home", ".local", "state"), { recursive: true }),
33
32
  ])
34
- await Bun.write(join(drive, `${options.name}.json`), `${JSON.stringify({ endpoints }, undefined, 2)}\n`)
33
+ await Bun.write(
34
+ join(drive, `${options.name}.json`),
35
+ `${JSON.stringify({ endpoints }, undefined, 2)}\n`,
36
+ )
35
37
  const state = options.state
36
38
  ? resolve(options.state)
37
39
  : join(artifacts, "state")
@@ -40,37 +42,44 @@ export async function launchInstance(options: LaunchOptions = {}) {
40
42
  await Promise.all([
41
43
  mkdir(join(state, "files", ".git"), { recursive: true }),
42
44
  mkdir(join(state, "files", ".opencode"), { recursive: true }),
45
+ mkdir(join(state, "files", "src"), { recursive: true }),
43
46
  ])
44
- await Bun.write(
45
- join(state, "files", ".opencode", "opencode.jsonc"),
46
- `${JSON.stringify(
47
- {
48
- model: "simulation/sim-model",
49
- permissions: [{ action: "*", resource: "*", effect: "allow" }],
50
- providers: {
51
- simulation: {
52
- name: "Simulation",
53
- package: "aisdk:@ai-sdk/openai-compatible",
54
- settings: { baseURL: "https://api.openai.com/v1" },
55
- request: { body: { apiKey: "sim-key" } },
56
- models: {
57
- "sim-model": {
58
- name: "Simulated Model",
59
- capabilities: {
60
- tools: true,
61
- input: ["text"],
62
- output: ["text"],
47
+ await Promise.all([
48
+ Bun.write(
49
+ join(state, "files", ".opencode", "opencode.jsonc"),
50
+ `${JSON.stringify(
51
+ {
52
+ model: "simulation/gpt-sim-model",
53
+ permissions: [{ action: "*", resource: "*", effect: "allow" }],
54
+ providers: {
55
+ simulation: {
56
+ name: "Simulation",
57
+ package: "aisdk:@ai-sdk/openai-compatible",
58
+ settings: { baseURL: "https://api.openai.com/v1" },
59
+ request: { body: { apiKey: "sim-key" } },
60
+ models: {
61
+ "gpt-sim-model": {
62
+ name: "Simulated Model",
63
+ capabilities: {
64
+ tools: true,
65
+ input: ["text"],
66
+ output: ["text"],
67
+ },
68
+ limit: { context: 128000, output: 16000 },
63
69
  },
64
- limit: { context: 128000, output: 16000 },
65
70
  },
66
71
  },
67
72
  },
68
73
  },
69
- },
70
- undefined,
71
- 2,
72
- )}\n`,
73
- )
74
+ undefined,
75
+ 2,
76
+ )}\n`,
77
+ ),
78
+ Bun.write(
79
+ join(state, "files", "src", "garden.js"),
80
+ 'export function greet(name) {\n return `Hello, ${name}.`\n}\n',
81
+ ),
82
+ ])
74
83
  }
75
84
  const environment = cleanEnv({
76
85
  ...process.env,
@@ -301,7 +310,7 @@ async function waitForWebSocket(
301
310
  const connected = await Promise.race([
302
311
  open(url)
303
312
  .then((socket) => {
304
- socket.close()
313
+ socket.terminate()
305
314
  return true
306
315
  })
307
316
  .catch(() => false),
@@ -0,0 +1,10 @@
1
+ import { listInstances, manifestPath } from "./registry.js"
2
+
3
+ export async function list() {
4
+ const instances = await listInstances()
5
+ console.log(
6
+ instances
7
+ .map((instance) => `${instance.name}: ${manifestPath(instance.name)}`)
8
+ .join("\n"),
9
+ )
10
+ }
@@ -0,0 +1,9 @@
1
+ import { join } from "node:path"
2
+ import { resolveInstance } from "./registry.js"
3
+
4
+ export async function logs(name?: string) {
5
+ const manifest = await resolveInstance(name)
6
+ console.log(
7
+ join(manifest.artifacts, "logs", "opencode", "log", "opencode*.log"),
8
+ )
9
+ }
@@ -1,25 +1,37 @@
1
1
  import { connectBackendSimulation } from "../client/index.js"
2
+ import { generateResponse } from "./response-generator.js"
3
+ import type { createResponseSettings } from "./response-generator.js"
2
4
 
3
- const response = "This is a sample response from opencode-drive."
4
-
5
- export async function connectMockBackend(endpoint: string) {
5
+ export async function connectMockBackend(
6
+ endpoint: string,
7
+ responses: ReturnType<typeof createResponseSettings>,
8
+ ) {
6
9
  const backend = await connectBackendSimulation({ url: endpoint })
7
- let closing = false
8
- await backend.attach((request) => {
9
- void backend
10
- .chunk(request.id, [{ type: "textDelta", text: response }])
11
- .then(() => backend.finish(request.id))
12
- .catch((error) => {
13
- if (!closing)
14
- console.error(
15
- `error: ${error instanceof Error ? error.message : String(error)}`,
16
- )
17
- })
10
+ await backend.attach(async (request) => {
11
+ const response = generateResponse(responses.current(), request)
12
+ for (const item of response.items) {
13
+ if (item.type !== "textDelta" && item.type !== "reasoningDelta") {
14
+ await backend.chunk(request.id, [item])
15
+ continue
16
+ }
17
+ for (const text of splitText(item.text)) {
18
+ await backend.chunk(request.id, [{ ...item, text }])
19
+ await Bun.sleep(45 + Math.floor(Math.random() * 35))
20
+ }
21
+ }
22
+ await backend.finish(request.id, response.finish)
18
23
  })
19
24
  return {
20
25
  close() {
21
- closing = true
22
26
  backend.close()
23
27
  },
24
28
  }
25
29
  }
30
+
31
+ export function splitText(text: string) {
32
+ const words = text.match(/\S+\s*/g) ?? [text]
33
+ return Array.from(
34
+ { length: Math.ceil(words.length / 3) },
35
+ (_, index) => words.slice(index * 3, index * 3 + 3).join(""),
36
+ )
37
+ }