@vercel/dream 0.2.0 → 0.2.1
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 +133 -0
- package/dist/dream.js +509 -0
- package/package.json +9 -6
- package/bin/dream.ts +0 -573
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# @vercel/dream
|
|
2
|
+
|
|
3
|
+
Write a spec. Get a deployed app.
|
|
4
|
+
|
|
5
|
+
Dream is a CLI that turns markdown specifications into fully built applications on Vercel. It runs an AI agent in a loop, reading your specs, writing code, and producing a deployable build — no scaffolding, no boilerplate, no manual steps.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pnpm add @vercel/dream
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## How it works
|
|
12
|
+
|
|
13
|
+
1. You write a specification in `specs/`
|
|
14
|
+
2. `dream` reads it, plans the work, and starts building
|
|
15
|
+
3. The agent loops — reading, coding, verifying — until every requirement is met
|
|
16
|
+
4. Output lands in `.vercel/output/` ready to deploy
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
▲ dream · my-app
|
|
20
|
+
|
|
21
|
+
dir /Users/you/code/my-app
|
|
22
|
+
model vercel/anthropic/claude-opus-4.5
|
|
23
|
+
timeout 60.0m
|
|
24
|
+
max 100 iterations
|
|
25
|
+
|
|
26
|
+
● OpenCode ready
|
|
27
|
+
● Provider vercel connected
|
|
28
|
+
|
|
29
|
+
[1] Running session...
|
|
30
|
+
▸ read specs/app.md
|
|
31
|
+
▸ write PROGRESS.md
|
|
32
|
+
|
|
33
|
+
Starting with the HTML structure and core game logic.
|
|
34
|
+
|
|
35
|
+
▸ write .vercel/output/config.json
|
|
36
|
+
▸ write .vercel/output/static/index.html
|
|
37
|
+
✎ .vercel/output/static/index.html
|
|
38
|
+
▸ write .vercel/output/static/styles.css
|
|
39
|
+
✎ .vercel/output/static/styles.css
|
|
40
|
+
▸ write .vercel/output/static/app.js
|
|
41
|
+
✎ .vercel/output/static/app.js
|
|
42
|
+
|
|
43
|
+
All tasks complete. Verifying output structure.
|
|
44
|
+
|
|
45
|
+
▸ glob .vercel/output/**/*
|
|
46
|
+
12 tools · 48.2k→3.1k · $0.12
|
|
47
|
+
[1] ✓ Done (34.2s)
|
|
48
|
+
|
|
49
|
+
✓ Completed in 1 iteration(s) (34.2s)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Scaffold a new project
|
|
56
|
+
dream init my-app
|
|
57
|
+
cd my-app
|
|
58
|
+
|
|
59
|
+
# Write your spec
|
|
60
|
+
cat > specs/app.md << 'EOF'
|
|
61
|
+
# Landing Page
|
|
62
|
+
|
|
63
|
+
A minimal dark-themed landing page with:
|
|
64
|
+
- Hero section with animated gradient title
|
|
65
|
+
- Feature grid (3 columns)
|
|
66
|
+
- Email signup form
|
|
67
|
+
EOF
|
|
68
|
+
|
|
69
|
+
# Build it
|
|
70
|
+
pnpm install
|
|
71
|
+
dream
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Specs
|
|
75
|
+
|
|
76
|
+
Specs are markdown files in the `specs/` directory. Write what you want — the more detail, the better the output. The agent reads every `.md` file in the directory.
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
specs/
|
|
80
|
+
├── app.md # Main application spec
|
|
81
|
+
├── design.md # Visual design requirements
|
|
82
|
+
└── accessibility.md # A11y requirements
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Output
|
|
86
|
+
|
|
87
|
+
Dream produces [Vercel Build Output API v3](https://vercel.com/docs/build-output-api/v3) — static files in `.vercel/output/static/` with a `config.json`. Deploy to Vercel or serve anywhere.
|
|
88
|
+
|
|
89
|
+
## Commands
|
|
90
|
+
|
|
91
|
+
| Command | Description |
|
|
92
|
+
|---------|-------------|
|
|
93
|
+
| `dream` | Build the project from specs |
|
|
94
|
+
| `dream init` | Scaffold a new dream project |
|
|
95
|
+
| `dream models` | List available models and auth status |
|
|
96
|
+
| `dream config` | Show project configuration |
|
|
97
|
+
|
|
98
|
+
## Options
|
|
99
|
+
|
|
100
|
+
| Flag | Description | Default |
|
|
101
|
+
|------|-------------|---------|
|
|
102
|
+
| `-m, --model` | Model in `provider/model` format | `vercel/anthropic/claude-opus-4.5` |
|
|
103
|
+
| `-t, --timeout` | Timeout in milliseconds | `3600000` (60m) |
|
|
104
|
+
| `-i, --max-iterations` | Maximum agent loops | `100` |
|
|
105
|
+
| `-v, --verbose` | Show all events | `false` |
|
|
106
|
+
| `-d, --dir` | Working directory | `.` |
|
|
107
|
+
|
|
108
|
+
## Authentication
|
|
109
|
+
|
|
110
|
+
Dream uses the [Vercel AI Gateway](https://vercel.com/ai-gateway). Authenticate with either:
|
|
111
|
+
|
|
112
|
+
**OIDC token** (Vercel deployments & local dev):
|
|
113
|
+
```bash
|
|
114
|
+
vercel env pull # writes .env.local with VERCEL_OIDC_TOKEN
|
|
115
|
+
source .env.local
|
|
116
|
+
dream
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**API key**:
|
|
120
|
+
```bash
|
|
121
|
+
export VERCEL_API_KEY=your_key
|
|
122
|
+
dream
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Requirements
|
|
126
|
+
|
|
127
|
+
- [OpenCode](https://opencode.ai) installed and available on PATH
|
|
128
|
+
- Node.js 18+
|
|
129
|
+
- A Vercel account with AI Gateway access
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT
|
package/dist/dream.js
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// bin/dream.ts
|
|
4
|
+
import * as fs from "fs";
|
|
5
|
+
import * as path from "path";
|
|
6
|
+
import { createOpencode } from "@opencode-ai/sdk";
|
|
7
|
+
import { program } from "commander";
|
|
8
|
+
var STOP_WORD = "<DREAM DONE>";
|
|
9
|
+
var 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
|
+
\u251C\u2500\u2500 config.json # Required: { "version": 3 }
|
|
41
|
+
\u2514\u2500\u2500 static/ # Static files served from root (/)
|
|
42
|
+
\u251C\u2500\u2500 index.html
|
|
43
|
+
\u251C\u2500\u2500 styles.css
|
|
44
|
+
\u2514\u2500\u2500 ...
|
|
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
|
+
var DEFAULT_TIMEOUT = 36e5;
|
|
85
|
+
var DEFAULT_MAX_ITERATIONS = 100;
|
|
86
|
+
var DEFAULT_MODEL = "vercel/anthropic/claude-opus-4.5";
|
|
87
|
+
var dim = (s) => `\x1B[2m${s}\x1B[22m`;
|
|
88
|
+
var bold = (s) => `\x1B[1m${s}\x1B[22m`;
|
|
89
|
+
var green = (s) => `\x1B[32m${s}\x1B[39m`;
|
|
90
|
+
var red = (s) => `\x1B[31m${s}\x1B[39m`;
|
|
91
|
+
var cyan = (s) => `\x1B[36m${s}\x1B[39m`;
|
|
92
|
+
var log = console.log;
|
|
93
|
+
program.name("dream").description("Run OpenCode in a loop until specs are complete").version("0.1.0").option("-d, --dir <directory>", "Working directory", ".");
|
|
94
|
+
program.command("init").description("Initialize a new dream project").action(() => {
|
|
95
|
+
const workDir = path.resolve(program.opts().dir);
|
|
96
|
+
const specsDir = path.join(workDir, "specs");
|
|
97
|
+
const packageJsonPath = path.join(workDir, "package.json");
|
|
98
|
+
log(`
|
|
99
|
+
${bold("\u25B2 dream")} ${dim("\xB7 init")}
|
|
100
|
+
`);
|
|
101
|
+
if (!fs.existsSync(workDir)) {
|
|
102
|
+
fs.mkdirSync(workDir, { recursive: true });
|
|
103
|
+
}
|
|
104
|
+
if (!fs.existsSync(specsDir)) {
|
|
105
|
+
fs.mkdirSync(specsDir);
|
|
106
|
+
fs.writeFileSync(
|
|
107
|
+
path.join(specsDir, "sample.md"),
|
|
108
|
+
"# Sample Spec\n\n## Context\n\nDescribe what you want to build here.\n\n## Tasks\n\n- [ ] First task\n- [ ] Second task\n"
|
|
109
|
+
);
|
|
110
|
+
log(` ${green("+")} specs/sample.md`);
|
|
111
|
+
} else {
|
|
112
|
+
log(` ${dim("\xB7")} specs/ ${dim("already exists")}`);
|
|
113
|
+
}
|
|
114
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
115
|
+
const pkg = {
|
|
116
|
+
name: path.basename(workDir),
|
|
117
|
+
version: "0.1.0",
|
|
118
|
+
private: true,
|
|
119
|
+
scripts: { build: "dream" },
|
|
120
|
+
dependencies: { "@vercel/dream": "^0.1.0" }
|
|
121
|
+
};
|
|
122
|
+
fs.writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, " ")}
|
|
123
|
+
`);
|
|
124
|
+
log(` ${green("+")} package.json`);
|
|
125
|
+
} else {
|
|
126
|
+
log(` ${dim("\xB7")} package.json ${dim("already exists")}`);
|
|
127
|
+
}
|
|
128
|
+
log(`
|
|
129
|
+
Run ${cyan("pnpm install")} then ${cyan("dream")} to start
|
|
130
|
+
`);
|
|
131
|
+
});
|
|
132
|
+
program.command("config").description("Show project configuration and specs").action(() => {
|
|
133
|
+
const workDir = path.resolve(program.opts().dir);
|
|
134
|
+
const specsDir = path.join(workDir, "specs");
|
|
135
|
+
log(`
|
|
136
|
+
${bold("\u25B2 dream")} ${dim("\xB7 config")}
|
|
137
|
+
`);
|
|
138
|
+
log(` ${dim("dir")} ${workDir}`);
|
|
139
|
+
log(` ${dim("timeout")} ${formatTime(DEFAULT_TIMEOUT)}`);
|
|
140
|
+
log(` ${dim("max")} ${DEFAULT_MAX_ITERATIONS} iterations`);
|
|
141
|
+
if (!fs.existsSync(specsDir)) {
|
|
142
|
+
log(`
|
|
143
|
+
${red("\u2717")} specs/ not found
|
|
144
|
+
`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const specFiles = fs.readdirSync(specsDir).filter((f) => f.endsWith(".md"));
|
|
148
|
+
log(`
|
|
149
|
+
${dim("specs")} ${dim(`(${specFiles.length})`)}`);
|
|
150
|
+
for (const file of specFiles) {
|
|
151
|
+
log(` ${dim("\xB7")} ${file}`);
|
|
152
|
+
}
|
|
153
|
+
log("");
|
|
154
|
+
});
|
|
155
|
+
program.command("models").description("List available models and check provider auth").action(async () => {
|
|
156
|
+
log(`
|
|
157
|
+
${bold("\u25B2 dream")} ${dim("\xB7 models")}
|
|
158
|
+
`);
|
|
159
|
+
log(` ${dim("\u25CC")} Starting OpenCode...`);
|
|
160
|
+
const { client, server } = await createOpencode({
|
|
161
|
+
port: 0,
|
|
162
|
+
config: { enabled_providers: ["vercel"] }
|
|
163
|
+
});
|
|
164
|
+
try {
|
|
165
|
+
const res = await client.provider.list();
|
|
166
|
+
if (res.error) {
|
|
167
|
+
log(` ${red("\u2717")} Failed to list providers
|
|
168
|
+
`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const { all, connected } = res.data;
|
|
172
|
+
for (const provider of all) {
|
|
173
|
+
const isConnected = connected.includes(provider.id);
|
|
174
|
+
const icon = isConnected ? green("\u25CF") : red("\u25CB");
|
|
175
|
+
const npm = provider.npm ? dim(` npm:${provider.npm}`) : "";
|
|
176
|
+
log(
|
|
177
|
+
` ${icon} ${bold(provider.name)} ${dim(`(${provider.id})`)}${npm}`
|
|
178
|
+
);
|
|
179
|
+
const models = Object.entries(provider.models);
|
|
180
|
+
for (const [id, model] of models) {
|
|
181
|
+
const name = model.name ?? id;
|
|
182
|
+
log(` ${dim("\xB7")} ${provider.id}/${id} ${dim(name)}`);
|
|
183
|
+
}
|
|
184
|
+
if (models.length === 0) {
|
|
185
|
+
log(` ${dim("no models")}`);
|
|
186
|
+
}
|
|
187
|
+
log("");
|
|
188
|
+
}
|
|
189
|
+
log(
|
|
190
|
+
` ${dim("connected")} ${connected.length ? connected.join(", ") : "none"}
|
|
191
|
+
`
|
|
192
|
+
);
|
|
193
|
+
} finally {
|
|
194
|
+
server.close();
|
|
195
|
+
process.exit(0);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
program.option("-m, --model <model>", "Model to use (provider/model format)").option("-t, --timeout <ms>", "Timeout in milliseconds").option("-i, --max-iterations <n>", "Maximum iterations").option("-v, --verbose", "Verbose output").action(async (opts) => {
|
|
199
|
+
const workDir = path.resolve(opts.dir);
|
|
200
|
+
const specsDir = path.join(workDir, "specs");
|
|
201
|
+
if (!fs.existsSync(specsDir)) {
|
|
202
|
+
log(`
|
|
203
|
+
${red("\u2717")} specs/ not found in ${workDir}
|
|
204
|
+
`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
const timeout = opts.timeout ? Number.parseInt(opts.timeout, 10) : DEFAULT_TIMEOUT;
|
|
208
|
+
const maxIterations = opts.maxIterations ? Number.parseInt(opts.maxIterations, 10) : DEFAULT_MAX_ITERATIONS;
|
|
209
|
+
const verbose = opts.verbose ?? false;
|
|
210
|
+
const model = opts.model ?? process.env.DREAM_MODEL ?? DEFAULT_MODEL;
|
|
211
|
+
const title = path.basename(workDir);
|
|
212
|
+
log(`
|
|
213
|
+
${bold("\u25B2 dream")} ${dim("\xB7")} ${title}
|
|
214
|
+
`);
|
|
215
|
+
log(` ${dim("dir")} ${workDir}`);
|
|
216
|
+
log(` ${dim("model")} ${model || dim("default")}`);
|
|
217
|
+
log(` ${dim("timeout")} ${formatTime(timeout)}`);
|
|
218
|
+
log(` ${dim("max")} ${maxIterations} iterations
|
|
219
|
+
`);
|
|
220
|
+
log(` ${dim("\u25CC")} Starting OpenCode...`);
|
|
221
|
+
const oidcToken = process.env.VERCEL_OIDC_TOKEN;
|
|
222
|
+
const { client, server } = await createOpencode({
|
|
223
|
+
port: 0,
|
|
224
|
+
config: {
|
|
225
|
+
model,
|
|
226
|
+
permission: {
|
|
227
|
+
edit: "allow",
|
|
228
|
+
bash: "allow",
|
|
229
|
+
webfetch: "allow",
|
|
230
|
+
doom_loop: "allow",
|
|
231
|
+
external_directory: "allow"
|
|
232
|
+
},
|
|
233
|
+
provider: {
|
|
234
|
+
vercel: {
|
|
235
|
+
env: ["VERCEL_API_KEY", "VERCEL_OIDC_TOKEN"],
|
|
236
|
+
...oidcToken && {
|
|
237
|
+
options: {
|
|
238
|
+
apiKey: oidcToken,
|
|
239
|
+
headers: {
|
|
240
|
+
"ai-gateway-auth-method": "oidc"
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
enabled_providers: ["vercel"]
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
log(` ${green("\u25CF")} OpenCode ready`);
|
|
250
|
+
const providerId = model?.split("/")[0];
|
|
251
|
+
if (providerId) {
|
|
252
|
+
const providers = await client.provider.list();
|
|
253
|
+
if (providers.error) {
|
|
254
|
+
log(` ${red("\u2717")} Failed to list providers
|
|
255
|
+
`);
|
|
256
|
+
server.close();
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
const connected = providers.data.connected ?? [];
|
|
260
|
+
if (!connected.includes(providerId)) {
|
|
261
|
+
log(` ${red("\u2717")} Provider ${bold(providerId)} is not connected`);
|
|
262
|
+
log(
|
|
263
|
+
` ${dim("connected")} ${connected.length ? connected.join(", ") : "none"}`
|
|
264
|
+
);
|
|
265
|
+
log(
|
|
266
|
+
`
|
|
267
|
+
Run ${cyan("opencode")} and authenticate the ${bold(providerId)} provider
|
|
268
|
+
`
|
|
269
|
+
);
|
|
270
|
+
server.close();
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
const provider = providers.data.all.find((p) => p.id === providerId);
|
|
274
|
+
const modelId = model.split("/").slice(1).join("/");
|
|
275
|
+
if (provider && modelId && !provider.models[modelId]) {
|
|
276
|
+
log(
|
|
277
|
+
` ${red("\u2717")} Model ${bold(modelId)} not found in ${bold(providerId)}`
|
|
278
|
+
);
|
|
279
|
+
const available = Object.keys(provider.models);
|
|
280
|
+
if (available.length) {
|
|
281
|
+
log(
|
|
282
|
+
` ${dim("available")} ${available.slice(0, 5).join(", ")}${available.length > 5 ? ` (+${available.length - 5} more)` : ""}`
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
log("");
|
|
286
|
+
server.close();
|
|
287
|
+
process.exit(1);
|
|
288
|
+
}
|
|
289
|
+
log(` ${green("\u25CF")} Provider ${bold(providerId)} connected
|
|
290
|
+
`);
|
|
291
|
+
}
|
|
292
|
+
const cleanup = () => {
|
|
293
|
+
server.close();
|
|
294
|
+
process.exit(1);
|
|
295
|
+
};
|
|
296
|
+
process.on("SIGINT", cleanup);
|
|
297
|
+
process.on("SIGTERM", cleanup);
|
|
298
|
+
const startTime = Date.now();
|
|
299
|
+
let iteration = 0;
|
|
300
|
+
try {
|
|
301
|
+
while (iteration < maxIterations) {
|
|
302
|
+
const elapsed = Date.now() - startTime;
|
|
303
|
+
if (elapsed >= timeout) {
|
|
304
|
+
log(`
|
|
305
|
+
${red("\u2717")} Timeout after ${formatTime(elapsed)}
|
|
306
|
+
`);
|
|
307
|
+
process.exit(1);
|
|
308
|
+
}
|
|
309
|
+
iteration++;
|
|
310
|
+
const iterStart = Date.now();
|
|
311
|
+
log(` ${cyan(`[${iteration}]`)} Running session...`);
|
|
312
|
+
const result = await runSession(client, title, SYSTEM_PROMPT, verbose);
|
|
313
|
+
const iterElapsed = Date.now() - iterStart;
|
|
314
|
+
if (result === "done") {
|
|
315
|
+
log(
|
|
316
|
+
` ${cyan(`[${iteration}]`)} ${green("\u2713")} Done ${dim(`(${formatTime(iterElapsed)})`)}`
|
|
317
|
+
);
|
|
318
|
+
log(
|
|
319
|
+
`
|
|
320
|
+
${green("\u2713")} Completed in ${bold(String(iteration))} iteration(s) ${dim(`(${formatTime(Date.now() - startTime)})`)}
|
|
321
|
+
`
|
|
322
|
+
);
|
|
323
|
+
process.exit(0);
|
|
324
|
+
}
|
|
325
|
+
if (result === "error") {
|
|
326
|
+
log(
|
|
327
|
+
`
|
|
328
|
+
${red("\u2717")} Session failed after ${bold(String(iteration))} iteration(s) ${dim(`(${formatTime(Date.now() - startTime)})`)}
|
|
329
|
+
`
|
|
330
|
+
);
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
333
|
+
log(
|
|
334
|
+
` ${cyan(`[${iteration}]`)} ${dim(`${formatTime(iterElapsed)} \xB7 continuing...`)}
|
|
335
|
+
`
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
log(`
|
|
339
|
+
${red("\u2717")} Max iterations reached
|
|
340
|
+
`);
|
|
341
|
+
process.exit(1);
|
|
342
|
+
} finally {
|
|
343
|
+
server.close();
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
async function runSession(client, title, systemPrompt, verbose) {
|
|
347
|
+
log(` ${dim("creating session...")}`);
|
|
348
|
+
const sessionResponse = await client.session.create({
|
|
349
|
+
body: { title: `Dream: ${title}` }
|
|
350
|
+
});
|
|
351
|
+
if (sessionResponse.error) {
|
|
352
|
+
throw new Error(
|
|
353
|
+
`Failed to create session: ${JSON.stringify(sessionResponse.error.errors)}`
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
const sessionId = sessionResponse.data.id;
|
|
357
|
+
log(` ${dim(`session ${sessionId.slice(0, 8)}`)}`);
|
|
358
|
+
log(` ${dim("subscribing to events...")}`);
|
|
359
|
+
const events = await client.event.subscribe();
|
|
360
|
+
log(` ${dim("sending prompt...")}`);
|
|
361
|
+
const promptResponse = await client.session.promptAsync({
|
|
362
|
+
path: { id: sessionId },
|
|
363
|
+
body: {
|
|
364
|
+
parts: [{ type: "text", text: systemPrompt }]
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
if (promptResponse.error) {
|
|
368
|
+
log(
|
|
369
|
+
` ${red("\u2717")} prompt error: ${JSON.stringify(promptResponse.error)}`
|
|
370
|
+
);
|
|
371
|
+
return "error";
|
|
372
|
+
}
|
|
373
|
+
let responseText = "";
|
|
374
|
+
let toolCalls = 0;
|
|
375
|
+
let totalCost = 0;
|
|
376
|
+
let totalTokensIn = 0;
|
|
377
|
+
let totalTokensOut = 0;
|
|
378
|
+
const seenTools = /* @__PURE__ */ new Set();
|
|
379
|
+
let lastOutput = "none";
|
|
380
|
+
const pad = " ";
|
|
381
|
+
for await (const event of events.stream) {
|
|
382
|
+
const props = event.properties;
|
|
383
|
+
if (verbose) {
|
|
384
|
+
const sid = props.sessionID ? props.sessionID.slice(0, 8) : "global";
|
|
385
|
+
log(dim(` event: ${event.type} [${sid}]`));
|
|
386
|
+
if (event.type !== "server.connected") {
|
|
387
|
+
log(
|
|
388
|
+
dim(
|
|
389
|
+
` ${JSON.stringify(event.properties).slice(0, 200)}`
|
|
390
|
+
)
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (props.sessionID && props.sessionID !== sessionId) {
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (event.type === "message.part.updated") {
|
|
398
|
+
const { part } = event.properties;
|
|
399
|
+
const delta = event.properties.delta;
|
|
400
|
+
if (part.type === "text" && delta) {
|
|
401
|
+
responseText += delta;
|
|
402
|
+
if (lastOutput === "tool") process.stdout.write("\n");
|
|
403
|
+
const indented = delta.replace(/\n/g, `
|
|
404
|
+
${pad}`);
|
|
405
|
+
process.stdout.write(
|
|
406
|
+
lastOutput !== "text" ? `${pad}${dim(indented)}` : dim(indented)
|
|
407
|
+
);
|
|
408
|
+
lastOutput = "text";
|
|
409
|
+
}
|
|
410
|
+
if (part.type === "reasoning" && delta) {
|
|
411
|
+
if (lastOutput === "tool") process.stdout.write("\n");
|
|
412
|
+
const indented = delta.replace(/\n/g, `
|
|
413
|
+
${pad}`);
|
|
414
|
+
process.stdout.write(
|
|
415
|
+
lastOutput !== "text" ? `${pad}${dim(indented)}` : dim(indented)
|
|
416
|
+
);
|
|
417
|
+
lastOutput = "text";
|
|
418
|
+
}
|
|
419
|
+
if (part.type === "tool") {
|
|
420
|
+
const callID = part.callID;
|
|
421
|
+
const toolName = part.tool;
|
|
422
|
+
const state = part.state;
|
|
423
|
+
if (state.status === "running" && !seenTools.has(callID)) {
|
|
424
|
+
seenTools.add(callID);
|
|
425
|
+
toolCalls++;
|
|
426
|
+
if (lastOutput === "text") process.stdout.write("\n\n");
|
|
427
|
+
const context = toolContext(toolName, state.input) ?? state.title;
|
|
428
|
+
log(
|
|
429
|
+
`${pad}${dim("\u25B8")} ${toolName}${context ? dim(` ${context}`) : ""}`
|
|
430
|
+
);
|
|
431
|
+
lastOutput = "tool";
|
|
432
|
+
}
|
|
433
|
+
if (state.status === "error") {
|
|
434
|
+
if (lastOutput === "text") process.stdout.write("\n");
|
|
435
|
+
log(`${pad}${red("\u2717")} ${toolName}: ${state.error}`);
|
|
436
|
+
lastOutput = "tool";
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (part.type === "step-finish") {
|
|
440
|
+
const step = part;
|
|
441
|
+
totalCost += step.cost ?? 0;
|
|
442
|
+
totalTokensIn += step.tokens?.input ?? 0;
|
|
443
|
+
totalTokensOut += step.tokens?.output ?? 0;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (event.type === "file.edited") {
|
|
447
|
+
const file = event.properties.file;
|
|
448
|
+
if (file) {
|
|
449
|
+
if (lastOutput === "text") process.stdout.write("\n");
|
|
450
|
+
const relative = file.replace(`${process.cwd()}/`, "");
|
|
451
|
+
log(`${pad}${green("\u270E")} ${relative}`);
|
|
452
|
+
lastOutput = "tool";
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (event.type === "session.error") {
|
|
456
|
+
const errProps = event.properties;
|
|
457
|
+
const msg = errProps.error?.data?.message ?? errProps.error?.name ?? "session error";
|
|
458
|
+
if (lastOutput === "text") process.stdout.write("\n");
|
|
459
|
+
log(`${pad}${red("\u2717")} ${msg}`);
|
|
460
|
+
return "error";
|
|
461
|
+
}
|
|
462
|
+
if (event.type === "session.idle") {
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (lastOutput === "text") process.stdout.write("\n");
|
|
467
|
+
const tokens = `${formatTokens(totalTokensIn)}\u2192${formatTokens(totalTokensOut)}`;
|
|
468
|
+
const cost = totalCost > 0 ? ` \xB7 $${totalCost.toFixed(2)}` : "";
|
|
469
|
+
log(`${pad}${dim(`${toolCalls} tools \xB7 ${tokens}${cost}`)}`);
|
|
470
|
+
if (responseText.length === 0) {
|
|
471
|
+
log(`${pad}${red("\u2717")} No response from model`);
|
|
472
|
+
return "error";
|
|
473
|
+
}
|
|
474
|
+
return responseText.includes(STOP_WORD) ? "done" : "continue";
|
|
475
|
+
}
|
|
476
|
+
function formatTime(ms) {
|
|
477
|
+
if (ms < 1e3) return `${ms}ms`;
|
|
478
|
+
if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
|
|
479
|
+
return `${(ms / 6e4).toFixed(1)}m`;
|
|
480
|
+
}
|
|
481
|
+
function formatTokens(n) {
|
|
482
|
+
if (n < 1e3) return `${n}`;
|
|
483
|
+
if (n < 1e6) return `${(n / 1e3).toFixed(1)}k`;
|
|
484
|
+
return `${(n / 1e6).toFixed(1)}M`;
|
|
485
|
+
}
|
|
486
|
+
function toolContext(tool, input) {
|
|
487
|
+
if (!input) return void 0;
|
|
488
|
+
const filePath = input.filePath;
|
|
489
|
+
const rel = filePath?.replace(`${process.cwd()}/`, "");
|
|
490
|
+
switch (tool) {
|
|
491
|
+
case "read":
|
|
492
|
+
case "write":
|
|
493
|
+
case "edit":
|
|
494
|
+
return rel;
|
|
495
|
+
case "bash": {
|
|
496
|
+
const cmd = input.command;
|
|
497
|
+
if (!cmd) return void 0;
|
|
498
|
+
return cmd.length > 60 ? `${cmd.slice(0, 60)}\u2026` : cmd;
|
|
499
|
+
}
|
|
500
|
+
case "glob":
|
|
501
|
+
case "grep":
|
|
502
|
+
return input.pattern;
|
|
503
|
+
case "fetch":
|
|
504
|
+
return input.url;
|
|
505
|
+
default:
|
|
506
|
+
return void 0;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
await program.parseAsync();
|
package/package.json
CHANGED
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vercel/dream",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "A CLI that runs OpenCode in a loop until specs are complete",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"dream": "./
|
|
7
|
+
"dream": "./dist/dream.js"
|
|
8
8
|
},
|
|
9
|
-
"files": ["
|
|
9
|
+
"files": ["dist"],
|
|
10
10
|
"scripts": {
|
|
11
|
+
"build": "tsup",
|
|
12
|
+
"dev": "tsx bin/dream.ts",
|
|
11
13
|
"check": "biome check .",
|
|
12
14
|
"check:fix": "biome check --write .",
|
|
13
15
|
"version": "changeset version",
|
|
14
|
-
"release": "changeset publish"
|
|
16
|
+
"release": "pnpm build && changeset publish"
|
|
15
17
|
},
|
|
16
18
|
"dependencies": {
|
|
17
19
|
"@ai-sdk/gateway": "^3.0.39",
|
|
18
20
|
"@opencode-ai/sdk": "^1.1.0",
|
|
19
|
-
"commander": "^12.0.0"
|
|
20
|
-
"tsx": "^4.0.0"
|
|
21
|
+
"commander": "^12.0.0"
|
|
21
22
|
},
|
|
22
23
|
"peerDependencies": {
|
|
23
24
|
"opencode-ai": ">=1.0.0"
|
|
@@ -27,6 +28,8 @@
|
|
|
27
28
|
"@changesets/cli": "^2.29.8",
|
|
28
29
|
"@types/node": "^22.0.0",
|
|
29
30
|
"lefthook": "^2.1.0",
|
|
31
|
+
"tsup": "^8.5.1",
|
|
32
|
+
"tsx": "^4.0.0",
|
|
30
33
|
"typescript": "^5.4.0"
|
|
31
34
|
},
|
|
32
35
|
"keywords": ["cli", "opencode", "ai", "automation"],
|
package/bin/dream.ts
DELETED
|
@@ -1,573 +0,0 @@
|
|
|
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();
|