@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.
Files changed (2) hide show
  1. package/bin/dream.ts +573 -0
  2. 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
+ }