@vercel/dream 0.2.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/bin/dream.ts +573 -0
- package/package.json +35 -0
package/bin/dream.ts
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { type OpencodeClient, createOpencode } from "@opencode-ai/sdk";
|
|
5
|
+
import { program } from "commander";
|
|
6
|
+
|
|
7
|
+
const STOP_WORD = "<DREAM DONE>";
|
|
8
|
+
|
|
9
|
+
const SYSTEM_PROMPT = `# Dream Agent
|
|
10
|
+
|
|
11
|
+
You are an autonomous agent building a project from specifications. You run in a loop until completion.
|
|
12
|
+
|
|
13
|
+
## Critical: State Lives on Disk
|
|
14
|
+
|
|
15
|
+
Each iteration starts with fresh context. You must:
|
|
16
|
+
- Read specifications from the \`specs/\` directory in the current working directory
|
|
17
|
+
- Track your progress in a \`PROGRESS.md\` file (create it on first run)
|
|
18
|
+
- On each iteration, read \`PROGRESS.md\` to understand what's done and what remains
|
|
19
|
+
- Update \`PROGRESS.md\` after completing each task
|
|
20
|
+
|
|
21
|
+
This ensures you can resume from any point if interrupted.
|
|
22
|
+
|
|
23
|
+
## Workflow
|
|
24
|
+
|
|
25
|
+
1. **Read state**: Read all files in \`specs/\` and \`PROGRESS.md\` (if exists)
|
|
26
|
+
2. **Plan**: If no \`PROGRESS.md\`, create it with a task breakdown from the specs
|
|
27
|
+
3. **Execute**: Work on the next incomplete task
|
|
28
|
+
4. **Update**: Mark the task complete in \`PROGRESS.md\`
|
|
29
|
+
5. **Verify**: Check your work meets the spec requirements
|
|
30
|
+
6. **Repeat or complete**: If tasks remain, continue. If all done, output completion signal.
|
|
31
|
+
|
|
32
|
+
## Build Output API
|
|
33
|
+
|
|
34
|
+
Your output must use [Vercel's Build Output API](https://vercel.com/docs/build-output-api/v3).
|
|
35
|
+
|
|
36
|
+
### Directory Structure
|
|
37
|
+
|
|
38
|
+
\`\`\`
|
|
39
|
+
.vercel/output/
|
|
40
|
+
├── config.json # Required: { "version": 3 }
|
|
41
|
+
└── static/ # Static files served from root (/)
|
|
42
|
+
├── index.html
|
|
43
|
+
├── styles.css
|
|
44
|
+
└── ...
|
|
45
|
+
\`\`\`
|
|
46
|
+
|
|
47
|
+
### Minimal config.json
|
|
48
|
+
|
|
49
|
+
\`\`\`json
|
|
50
|
+
{
|
|
51
|
+
"version": 3
|
|
52
|
+
}
|
|
53
|
+
\`\`\`
|
|
54
|
+
|
|
55
|
+
Static files in \`.vercel/output/static/\` are served at the deployment root. Subdirectories are preserved in URLs.
|
|
56
|
+
|
|
57
|
+
## PROGRESS.md Format
|
|
58
|
+
|
|
59
|
+
\`\`\`markdown
|
|
60
|
+
# Progress
|
|
61
|
+
|
|
62
|
+
## Tasks
|
|
63
|
+
- [x] Completed task
|
|
64
|
+
- [ ] Pending task
|
|
65
|
+
- [ ] Another pending task
|
|
66
|
+
|
|
67
|
+
## Notes
|
|
68
|
+
Any learnings or context for future iterations.
|
|
69
|
+
\`\`\`
|
|
70
|
+
|
|
71
|
+
## Completion
|
|
72
|
+
|
|
73
|
+
**Only output the completion signal when ALL of the following are true:**
|
|
74
|
+
- Every task in \`PROGRESS.md\` is marked complete \`[x]\`
|
|
75
|
+
- All specifications in \`specs/\` are fully implemented
|
|
76
|
+
- \`.vercel/output/config.json\` exists with \`"version": 3\`
|
|
77
|
+
- All required static files exist in \`.vercel/output/static/\`
|
|
78
|
+
|
|
79
|
+
When complete, output exactly:
|
|
80
|
+
|
|
81
|
+
${STOP_WORD}
|
|
82
|
+
|
|
83
|
+
Do NOT output this signal if any work remains. Continue iterating until the specs are fully met.`;
|
|
84
|
+
const DEFAULT_TIMEOUT = 3600000;
|
|
85
|
+
const DEFAULT_MAX_ITERATIONS = 100;
|
|
86
|
+
const DEFAULT_MODEL = "vercel/anthropic/claude-opus-4.5";
|
|
87
|
+
|
|
88
|
+
const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
|
|
89
|
+
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
|
|
90
|
+
const green = (s: string) => `\x1b[32m${s}\x1b[39m`;
|
|
91
|
+
const red = (s: string) => `\x1b[31m${s}\x1b[39m`;
|
|
92
|
+
const cyan = (s: string) => `\x1b[36m${s}\x1b[39m`;
|
|
93
|
+
const log = console.log;
|
|
94
|
+
|
|
95
|
+
program
|
|
96
|
+
.name("dream")
|
|
97
|
+
.description("Run OpenCode in a loop until specs are complete")
|
|
98
|
+
.version("0.1.0")
|
|
99
|
+
.option("-d, --dir <directory>", "Working directory", ".");
|
|
100
|
+
|
|
101
|
+
program
|
|
102
|
+
.command("init")
|
|
103
|
+
.description("Initialize a new dream project")
|
|
104
|
+
.action(() => {
|
|
105
|
+
const workDir = path.resolve(program.opts().dir);
|
|
106
|
+
const specsDir = path.join(workDir, "specs");
|
|
107
|
+
const packageJsonPath = path.join(workDir, "package.json");
|
|
108
|
+
|
|
109
|
+
log(`\n ${bold("▲ dream")} ${dim("· init")}\n`);
|
|
110
|
+
|
|
111
|
+
if (!fs.existsSync(workDir)) {
|
|
112
|
+
fs.mkdirSync(workDir, { recursive: true });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!fs.existsSync(specsDir)) {
|
|
116
|
+
fs.mkdirSync(specsDir);
|
|
117
|
+
fs.writeFileSync(
|
|
118
|
+
path.join(specsDir, "sample.md"),
|
|
119
|
+
"# Sample Spec\n\n## Context\n\nDescribe what you want to build here.\n\n## Tasks\n\n- [ ] First task\n- [ ] Second task\n",
|
|
120
|
+
);
|
|
121
|
+
log(` ${green("+")} specs/sample.md`);
|
|
122
|
+
} else {
|
|
123
|
+
log(` ${dim("·")} specs/ ${dim("already exists")}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
127
|
+
const pkg = {
|
|
128
|
+
name: path.basename(workDir),
|
|
129
|
+
version: "0.1.0",
|
|
130
|
+
private: true,
|
|
131
|
+
scripts: { build: "dream" },
|
|
132
|
+
dependencies: { "@vercel/dream": "^0.1.0" },
|
|
133
|
+
};
|
|
134
|
+
fs.writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, "\t")}\n`);
|
|
135
|
+
log(` ${green("+")} package.json`);
|
|
136
|
+
} else {
|
|
137
|
+
log(` ${dim("·")} package.json ${dim("already exists")}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
log(`\n Run ${cyan("pnpm install")} then ${cyan("dream")} to start\n`);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
program
|
|
144
|
+
.command("config")
|
|
145
|
+
.description("Show project configuration and specs")
|
|
146
|
+
.action(() => {
|
|
147
|
+
const workDir = path.resolve(program.opts().dir);
|
|
148
|
+
const specsDir = path.join(workDir, "specs");
|
|
149
|
+
|
|
150
|
+
log(`\n ${bold("▲ dream")} ${dim("· config")}\n`);
|
|
151
|
+
log(` ${dim("dir")} ${workDir}`);
|
|
152
|
+
log(` ${dim("timeout")} ${formatTime(DEFAULT_TIMEOUT)}`);
|
|
153
|
+
log(` ${dim("max")} ${DEFAULT_MAX_ITERATIONS} iterations`);
|
|
154
|
+
|
|
155
|
+
if (!fs.existsSync(specsDir)) {
|
|
156
|
+
log(`\n ${red("✗")} specs/ not found\n`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const specFiles = fs.readdirSync(specsDir).filter((f) => f.endsWith(".md"));
|
|
161
|
+
log(`\n ${dim("specs")} ${dim(`(${specFiles.length})`)}`);
|
|
162
|
+
for (const file of specFiles) {
|
|
163
|
+
log(` ${dim("·")} ${file}`);
|
|
164
|
+
}
|
|
165
|
+
log("");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
program
|
|
169
|
+
.command("models")
|
|
170
|
+
.description("List available models and check provider auth")
|
|
171
|
+
.action(async () => {
|
|
172
|
+
log(`\n ${bold("▲ dream")} ${dim("· models")}\n`);
|
|
173
|
+
log(` ${dim("◌")} Starting OpenCode...`);
|
|
174
|
+
const { client, server } = await createOpencode({
|
|
175
|
+
port: 0,
|
|
176
|
+
config: { enabled_providers: ["vercel"] },
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const res = await client.provider.list();
|
|
181
|
+
if (res.error) {
|
|
182
|
+
log(` ${red("✗")} Failed to list providers\n`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const { all, connected } = res.data;
|
|
187
|
+
for (const provider of all) {
|
|
188
|
+
const isConnected = connected.includes(provider.id);
|
|
189
|
+
const icon = isConnected ? green("●") : red("○");
|
|
190
|
+
const npm = provider.npm ? dim(` npm:${provider.npm}`) : "";
|
|
191
|
+
log(
|
|
192
|
+
` ${icon} ${bold(provider.name)} ${dim(`(${provider.id})`)}${npm}`,
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const models = Object.entries(provider.models);
|
|
196
|
+
for (const [id, model] of models) {
|
|
197
|
+
const name = (model as { name?: string }).name ?? id;
|
|
198
|
+
log(` ${dim("·")} ${provider.id}/${id} ${dim(name)}`);
|
|
199
|
+
}
|
|
200
|
+
if (models.length === 0) {
|
|
201
|
+
log(` ${dim("no models")}`);
|
|
202
|
+
}
|
|
203
|
+
log("");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
log(
|
|
207
|
+
` ${dim("connected")} ${connected.length ? connected.join(", ") : "none"}\n`,
|
|
208
|
+
);
|
|
209
|
+
} finally {
|
|
210
|
+
server.close();
|
|
211
|
+
process.exit(0);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
program
|
|
216
|
+
.option("-m, --model <model>", "Model to use (provider/model format)")
|
|
217
|
+
.option("-t, --timeout <ms>", "Timeout in milliseconds")
|
|
218
|
+
.option("-i, --max-iterations <n>", "Maximum iterations")
|
|
219
|
+
.option("-v, --verbose", "Verbose output")
|
|
220
|
+
.action(async (opts) => {
|
|
221
|
+
const workDir = path.resolve(opts.dir);
|
|
222
|
+
const specsDir = path.join(workDir, "specs");
|
|
223
|
+
|
|
224
|
+
if (!fs.existsSync(specsDir)) {
|
|
225
|
+
log(`\n ${red("✗")} specs/ not found in ${workDir}\n`);
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const timeout = opts.timeout
|
|
230
|
+
? Number.parseInt(opts.timeout, 10)
|
|
231
|
+
: DEFAULT_TIMEOUT;
|
|
232
|
+
const maxIterations = opts.maxIterations
|
|
233
|
+
? Number.parseInt(opts.maxIterations, 10)
|
|
234
|
+
: DEFAULT_MAX_ITERATIONS;
|
|
235
|
+
const verbose = opts.verbose ?? false;
|
|
236
|
+
const model = opts.model ?? process.env.DREAM_MODEL ?? DEFAULT_MODEL;
|
|
237
|
+
const title = path.basename(workDir);
|
|
238
|
+
|
|
239
|
+
log(`\n ${bold("▲ dream")} ${dim("·")} ${title}\n`);
|
|
240
|
+
log(` ${dim("dir")} ${workDir}`);
|
|
241
|
+
log(` ${dim("model")} ${model || dim("default")}`);
|
|
242
|
+
log(` ${dim("timeout")} ${formatTime(timeout)}`);
|
|
243
|
+
log(` ${dim("max")} ${maxIterations} iterations\n`);
|
|
244
|
+
|
|
245
|
+
log(` ${dim("◌")} Starting OpenCode...`);
|
|
246
|
+
const oidcToken = process.env.VERCEL_OIDC_TOKEN;
|
|
247
|
+
const { client, server } = await createOpencode({
|
|
248
|
+
port: 0,
|
|
249
|
+
config: {
|
|
250
|
+
model,
|
|
251
|
+
permission: {
|
|
252
|
+
edit: "allow",
|
|
253
|
+
bash: "allow",
|
|
254
|
+
webfetch: "allow",
|
|
255
|
+
doom_loop: "allow",
|
|
256
|
+
external_directory: "allow",
|
|
257
|
+
},
|
|
258
|
+
provider: {
|
|
259
|
+
vercel: {
|
|
260
|
+
env: ["VERCEL_API_KEY", "VERCEL_OIDC_TOKEN"],
|
|
261
|
+
...(oidcToken && {
|
|
262
|
+
options: {
|
|
263
|
+
apiKey: oidcToken,
|
|
264
|
+
headers: {
|
|
265
|
+
"ai-gateway-auth-method": "oidc",
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
}),
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
enabled_providers: ["vercel"],
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
log(` ${green("●")} OpenCode ready`);
|
|
275
|
+
|
|
276
|
+
const providerId = model?.split("/")[0];
|
|
277
|
+
if (providerId) {
|
|
278
|
+
const providers = await client.provider.list();
|
|
279
|
+
if (providers.error) {
|
|
280
|
+
log(` ${red("✗")} Failed to list providers\n`);
|
|
281
|
+
server.close();
|
|
282
|
+
process.exit(1);
|
|
283
|
+
}
|
|
284
|
+
const connected = providers.data.connected ?? [];
|
|
285
|
+
if (!connected.includes(providerId)) {
|
|
286
|
+
log(` ${red("✗")} Provider ${bold(providerId)} is not connected`);
|
|
287
|
+
log(
|
|
288
|
+
` ${dim("connected")} ${connected.length ? connected.join(", ") : "none"}`,
|
|
289
|
+
);
|
|
290
|
+
log(
|
|
291
|
+
`\n Run ${cyan("opencode")} and authenticate the ${bold(providerId)} provider\n`,
|
|
292
|
+
);
|
|
293
|
+
server.close();
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
const provider = providers.data.all.find((p) => p.id === providerId);
|
|
297
|
+
const modelId = model.split("/").slice(1).join("/");
|
|
298
|
+
if (provider && modelId && !provider.models[modelId]) {
|
|
299
|
+
log(
|
|
300
|
+
` ${red("✗")} Model ${bold(modelId)} not found in ${bold(providerId)}`,
|
|
301
|
+
);
|
|
302
|
+
const available = Object.keys(provider.models);
|
|
303
|
+
if (available.length) {
|
|
304
|
+
log(
|
|
305
|
+
` ${dim("available")} ${available.slice(0, 5).join(", ")}${available.length > 5 ? ` (+${available.length - 5} more)` : ""}`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
log("");
|
|
309
|
+
server.close();
|
|
310
|
+
process.exit(1);
|
|
311
|
+
}
|
|
312
|
+
log(` ${green("●")} Provider ${bold(providerId)} connected\n`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const cleanup = () => {
|
|
316
|
+
server.close();
|
|
317
|
+
process.exit(1);
|
|
318
|
+
};
|
|
319
|
+
process.on("SIGINT", cleanup);
|
|
320
|
+
process.on("SIGTERM", cleanup);
|
|
321
|
+
|
|
322
|
+
const startTime = Date.now();
|
|
323
|
+
let iteration = 0;
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
while (iteration < maxIterations) {
|
|
327
|
+
const elapsed = Date.now() - startTime;
|
|
328
|
+
if (elapsed >= timeout) {
|
|
329
|
+
log(`\n ${red("✗")} Timeout after ${formatTime(elapsed)}\n`);
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
iteration++;
|
|
334
|
+
const iterStart = Date.now();
|
|
335
|
+
log(` ${cyan(`[${iteration}]`)} Running session...`);
|
|
336
|
+
|
|
337
|
+
const result = await runSession(client, title, SYSTEM_PROMPT, verbose);
|
|
338
|
+
const iterElapsed = Date.now() - iterStart;
|
|
339
|
+
|
|
340
|
+
if (result === "done") {
|
|
341
|
+
log(
|
|
342
|
+
` ${cyan(`[${iteration}]`)} ${green("✓")} Done ${dim(`(${formatTime(iterElapsed)})`)}`,
|
|
343
|
+
);
|
|
344
|
+
log(
|
|
345
|
+
`\n ${green("✓")} Completed in ${bold(String(iteration))} iteration(s) ${dim(`(${formatTime(Date.now() - startTime)})`)}\n`,
|
|
346
|
+
);
|
|
347
|
+
process.exit(0);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (result === "error") {
|
|
351
|
+
log(
|
|
352
|
+
`\n ${red("✗")} Session failed after ${bold(String(iteration))} iteration(s) ${dim(`(${formatTime(Date.now() - startTime)})`)}\n`,
|
|
353
|
+
);
|
|
354
|
+
process.exit(1);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
log(
|
|
358
|
+
` ${cyan(`[${iteration}]`)} ${dim(`${formatTime(iterElapsed)} · continuing...`)}\n`,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
log(`\n ${red("✗")} Max iterations reached\n`);
|
|
363
|
+
process.exit(1);
|
|
364
|
+
} finally {
|
|
365
|
+
server.close();
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
async function runSession(
|
|
370
|
+
client: OpencodeClient,
|
|
371
|
+
title: string,
|
|
372
|
+
systemPrompt: string,
|
|
373
|
+
verbose: boolean,
|
|
374
|
+
): Promise<"done" | "continue" | "error"> {
|
|
375
|
+
log(` ${dim("creating session...")}`);
|
|
376
|
+
const sessionResponse = await client.session.create({
|
|
377
|
+
body: { title: `Dream: ${title}` },
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
if (sessionResponse.error) {
|
|
381
|
+
throw new Error(
|
|
382
|
+
`Failed to create session: ${JSON.stringify(sessionResponse.error.errors)}`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const sessionId = sessionResponse.data.id;
|
|
387
|
+
log(` ${dim(`session ${sessionId.slice(0, 8)}`)}`);
|
|
388
|
+
|
|
389
|
+
log(` ${dim("subscribing to events...")}`);
|
|
390
|
+
const events = await client.event.subscribe();
|
|
391
|
+
|
|
392
|
+
log(` ${dim("sending prompt...")}`);
|
|
393
|
+
const promptResponse = await client.session.promptAsync({
|
|
394
|
+
path: { id: sessionId },
|
|
395
|
+
body: {
|
|
396
|
+
parts: [{ type: "text", text: systemPrompt }],
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
if (promptResponse.error) {
|
|
401
|
+
log(
|
|
402
|
+
` ${red("✗")} prompt error: ${JSON.stringify(promptResponse.error)}`,
|
|
403
|
+
);
|
|
404
|
+
return "error";
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
let responseText = "";
|
|
408
|
+
let toolCalls = 0;
|
|
409
|
+
let totalCost = 0;
|
|
410
|
+
let totalTokensIn = 0;
|
|
411
|
+
let totalTokensOut = 0;
|
|
412
|
+
const seenTools = new Set<string>();
|
|
413
|
+
let lastOutput: "text" | "tool" | "none" = "none";
|
|
414
|
+
|
|
415
|
+
const pad = " ";
|
|
416
|
+
|
|
417
|
+
for await (const event of events.stream) {
|
|
418
|
+
const props = event.properties as { sessionID?: string };
|
|
419
|
+
|
|
420
|
+
if (verbose) {
|
|
421
|
+
const sid = props.sessionID ? props.sessionID.slice(0, 8) : "global";
|
|
422
|
+
log(dim(` event: ${event.type} [${sid}]`));
|
|
423
|
+
if (event.type !== "server.connected") {
|
|
424
|
+
log(
|
|
425
|
+
dim(
|
|
426
|
+
` ${JSON.stringify(event.properties).slice(0, 200)}`,
|
|
427
|
+
),
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (props.sessionID && props.sessionID !== sessionId) {
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (event.type === "message.part.updated") {
|
|
437
|
+
const { part } = event.properties;
|
|
438
|
+
const delta = event.properties.delta as string | undefined;
|
|
439
|
+
if (part.type === "text" && delta) {
|
|
440
|
+
responseText += delta;
|
|
441
|
+
if (lastOutput === "tool") process.stdout.write("\n");
|
|
442
|
+
const indented = delta.replace(/\n/g, `\n${pad}`);
|
|
443
|
+
process.stdout.write(
|
|
444
|
+
lastOutput !== "text" ? `${pad}${dim(indented)}` : dim(indented),
|
|
445
|
+
);
|
|
446
|
+
lastOutput = "text";
|
|
447
|
+
}
|
|
448
|
+
if (part.type === "reasoning" && delta) {
|
|
449
|
+
if (lastOutput === "tool") process.stdout.write("\n");
|
|
450
|
+
const indented = delta.replace(/\n/g, `\n${pad}`);
|
|
451
|
+
process.stdout.write(
|
|
452
|
+
lastOutput !== "text" ? `${pad}${dim(indented)}` : dim(indented),
|
|
453
|
+
);
|
|
454
|
+
lastOutput = "text";
|
|
455
|
+
}
|
|
456
|
+
if (part.type === "tool") {
|
|
457
|
+
const callID = part.callID as string;
|
|
458
|
+
const toolName = part.tool as string;
|
|
459
|
+
const state = part.state as {
|
|
460
|
+
status: string;
|
|
461
|
+
title?: string;
|
|
462
|
+
input?: Record<string, unknown>;
|
|
463
|
+
output?: string;
|
|
464
|
+
error?: string;
|
|
465
|
+
};
|
|
466
|
+
if (state.status === "running" && !seenTools.has(callID)) {
|
|
467
|
+
seenTools.add(callID);
|
|
468
|
+
toolCalls++;
|
|
469
|
+
if (lastOutput === "text") process.stdout.write("\n\n");
|
|
470
|
+
const context = toolContext(toolName, state.input) ?? state.title;
|
|
471
|
+
log(
|
|
472
|
+
`${pad}${dim("▸")} ${toolName}${context ? dim(` ${context}`) : ""}`,
|
|
473
|
+
);
|
|
474
|
+
lastOutput = "tool";
|
|
475
|
+
}
|
|
476
|
+
if (state.status === "error") {
|
|
477
|
+
if (lastOutput === "text") process.stdout.write("\n");
|
|
478
|
+
log(`${pad}${red("✗")} ${toolName}: ${state.error}`);
|
|
479
|
+
lastOutput = "tool";
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (part.type === "step-finish") {
|
|
483
|
+
const step = part as {
|
|
484
|
+
cost?: number;
|
|
485
|
+
tokens?: { input: number; output: number };
|
|
486
|
+
};
|
|
487
|
+
totalCost += step.cost ?? 0;
|
|
488
|
+
totalTokensIn += step.tokens?.input ?? 0;
|
|
489
|
+
totalTokensOut += step.tokens?.output ?? 0;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (event.type === "file.edited") {
|
|
494
|
+
const file = (event.properties as { file?: string }).file;
|
|
495
|
+
if (file) {
|
|
496
|
+
if (lastOutput === "text") process.stdout.write("\n");
|
|
497
|
+
const relative = file.replace(`${process.cwd()}/`, "");
|
|
498
|
+
log(`${pad}${green("✎")} ${relative}`);
|
|
499
|
+
lastOutput = "tool";
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (event.type === "session.error") {
|
|
504
|
+
const errProps = event.properties as {
|
|
505
|
+
error?: { name?: string; data?: { message?: string } };
|
|
506
|
+
};
|
|
507
|
+
const msg =
|
|
508
|
+
errProps.error?.data?.message ??
|
|
509
|
+
errProps.error?.name ??
|
|
510
|
+
"session error";
|
|
511
|
+
if (lastOutput === "text") process.stdout.write("\n");
|
|
512
|
+
log(`${pad}${red("✗")} ${msg}`);
|
|
513
|
+
return "error";
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (event.type === "session.idle") {
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (lastOutput === "text") process.stdout.write("\n");
|
|
522
|
+
const tokens = `${formatTokens(totalTokensIn)}→${formatTokens(totalTokensOut)}`;
|
|
523
|
+
const cost = totalCost > 0 ? ` · $${totalCost.toFixed(2)}` : "";
|
|
524
|
+
log(`${pad}${dim(`${toolCalls} tools · ${tokens}${cost}`)}`);
|
|
525
|
+
|
|
526
|
+
if (responseText.length === 0) {
|
|
527
|
+
log(`${pad}${red("✗")} No response from model`);
|
|
528
|
+
return "error";
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return responseText.includes(STOP_WORD) ? "done" : "continue";
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function formatTime(ms: number): string {
|
|
535
|
+
if (ms < 1000) return `${ms}ms`;
|
|
536
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
537
|
+
return `${(ms / 60000).toFixed(1)}m`;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function formatTokens(n: number): string {
|
|
541
|
+
if (n < 1000) return `${n}`;
|
|
542
|
+
if (n < 1000000) return `${(n / 1000).toFixed(1)}k`;
|
|
543
|
+
return `${(n / 1000000).toFixed(1)}M`;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function toolContext(
|
|
547
|
+
tool: string,
|
|
548
|
+
input?: Record<string, unknown>,
|
|
549
|
+
): string | undefined {
|
|
550
|
+
if (!input) return undefined;
|
|
551
|
+
const filePath = input.filePath as string | undefined;
|
|
552
|
+
const rel = filePath?.replace(`${process.cwd()}/`, "");
|
|
553
|
+
switch (tool) {
|
|
554
|
+
case "read":
|
|
555
|
+
case "write":
|
|
556
|
+
case "edit":
|
|
557
|
+
return rel;
|
|
558
|
+
case "bash": {
|
|
559
|
+
const cmd = input.command as string | undefined;
|
|
560
|
+
if (!cmd) return undefined;
|
|
561
|
+
return cmd.length > 60 ? `${cmd.slice(0, 60)}…` : cmd;
|
|
562
|
+
}
|
|
563
|
+
case "glob":
|
|
564
|
+
case "grep":
|
|
565
|
+
return input.pattern as string | undefined;
|
|
566
|
+
case "fetch":
|
|
567
|
+
return input.url as string | undefined;
|
|
568
|
+
default:
|
|
569
|
+
return undefined;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
await program.parseAsync();
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vercel/dream",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A CLI that runs OpenCode in a loop until specs are complete",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"dream": "./bin/dream.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": ["bin"],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"check": "biome check .",
|
|
12
|
+
"check:fix": "biome check --write .",
|
|
13
|
+
"version": "changeset version",
|
|
14
|
+
"release": "changeset publish"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@ai-sdk/gateway": "^3.0.39",
|
|
18
|
+
"@opencode-ai/sdk": "^1.1.0",
|
|
19
|
+
"commander": "^12.0.0",
|
|
20
|
+
"tsx": "^4.0.0"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"opencode-ai": ">=1.0.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@biomejs/biome": "^1.9.0",
|
|
27
|
+
"@changesets/cli": "^2.29.8",
|
|
28
|
+
"@types/node": "^22.0.0",
|
|
29
|
+
"lefthook": "^2.1.0",
|
|
30
|
+
"typescript": "^5.4.0"
|
|
31
|
+
},
|
|
32
|
+
"keywords": ["cli", "opencode", "ai", "automation"],
|
|
33
|
+
"author": "",
|
|
34
|
+
"license": "MIT"
|
|
35
|
+
}
|