Skip to content

Commit 4c8efc2

Browse files
committed
feat: rewrite in TypeScript with OpenCode SDK
Complete rewrite of Opencoder from Zig to TypeScript using Bun runtime and @opencode-ai/sdk for the autonomous development loop. Source modules: - index.ts: Entry point - cli.ts: CLI argument parsing with commander - config.ts: Configuration loading (file + env + CLI merge) - types.ts: TypeScript interfaces and types - state.ts: State persistence (JSON) - fs.ts: File system utilities - logger.ts: Logging with live output streaming - plan.ts: Plan parsing, validation, prompt generation - ideas.ts: Ideas queue management, selection logic - builder.ts: OpenCode SDK wrapper with event streaming - evaluator.ts: Evaluation response parsing - loop.ts: Main autonomous loop Features: - Three-phase loop: planning, build, evaluation - Ideas queue for user-specified tasks - Resumable state persistence - Live streaming output in verbose mode Signed-off-by: leocavalcante <[email protected]>
1 parent 245c7a3 commit 4c8efc2

File tree

12 files changed

+2403
-0
lines changed

12 files changed

+2403
-0
lines changed

src/builder.ts

Lines changed: 403 additions & 0 deletions
Large diffs are not rendered by default.

src/cli.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* CLI argument parsing and program setup
3+
*/
4+
5+
import { Command } from "commander"
6+
import { loadConfig } from "./config.ts"
7+
import { runLoop } from "./loop.ts"
8+
import type { CliOptions } from "./types.ts"
9+
10+
const VERSION = "1.0.0"
11+
12+
/**
13+
* Parse CLI arguments and run the application
14+
*/
15+
export async function run(): Promise<void> {
16+
const program = new Command()
17+
18+
program
19+
.name("opencoder")
20+
.description("Autonomous development loop powered by OpenCode")
21+
.version(VERSION)
22+
.argument("[hint]", "Optional hint/instruction for the AI")
23+
.option("-p, --project <dir>", "Project directory (default: current directory)")
24+
.option("-m, --model <model>", "Model for both planning and build (provider/model format)")
25+
.option("-P, --planning-model <model>", "Model for planning phase (provider/model format)")
26+
.option("-B, --build-model <model>", "Model for build phase (provider/model format)")
27+
.option("-v, --verbose", "Enable verbose logging")
28+
.action(async (hint: string | undefined, opts: Record<string, unknown>) => {
29+
try {
30+
const cliOptions: CliOptions = {
31+
project: opts.project as string | undefined,
32+
model: opts.model as string | undefined,
33+
planningModel: opts.planningModel as string | undefined,
34+
buildModel: opts.buildModel as string | undefined,
35+
verbose: opts.verbose as boolean | undefined,
36+
}
37+
38+
const config = await loadConfig(cliOptions, hint)
39+
await runLoop(config)
40+
} catch (err) {
41+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`)
42+
process.exit(1)
43+
}
44+
})
45+
46+
// Add examples to help
47+
program.addHelpText(
48+
"after",
49+
`
50+
Examples:
51+
$ opencoder --model anthropic/claude-sonnet-4
52+
Run with Claude Sonnet for both planning and build
53+
54+
$ opencoder -m anthropic/claude-sonnet-4 "build a REST API"
55+
Run with a specific hint/instruction
56+
57+
$ opencoder -P anthropic/claude-opus-4 -B anthropic/claude-sonnet-4
58+
Use different models for planning and build
59+
60+
$ opencoder -m openai/gpt-4o -p ./myproject -v
61+
Run with verbose logging in a specific directory
62+
63+
Options:
64+
-p, --project <dir> Project directory (default: current directory)
65+
-m, --model <model> Model for both planning and build
66+
-P, --planning-model Model for planning phase
67+
-B, --build-model Model for build phase
68+
-v, --verbose Enable verbose logging
69+
70+
Environment variables:
71+
OPENCODER_PLANNING_MODEL Default planning model
72+
OPENCODER_BUILD_MODEL Default build model
73+
OPENCODER_VERBOSE Enable verbose logging (true/1)
74+
OPENCODER_PROJECT_DIR Default project directory
75+
76+
Config file (opencoder.json):
77+
{
78+
"planningModel": "anthropic/claude-sonnet-4",
79+
"buildModel": "anthropic/claude-sonnet-4",
80+
"verbose": false
81+
}
82+
`,
83+
)
84+
85+
await program.parseAsync()
86+
}

src/config.ts

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
/**
2+
* Configuration loading and management
3+
*
4+
* Priority (lowest to highest):
5+
* 1. Defaults
6+
* 2. opencoder.json in project directory
7+
* 3. Environment variables
8+
* 4. CLI arguments
9+
*/
10+
11+
import { existsSync } from "node:fs"
12+
import { readFile } from "node:fs/promises"
13+
import { resolve } from "node:path"
14+
import type { CliOptions, Config, ConfigFile } from "./types.ts"
15+
16+
/** Default configuration values */
17+
const DEFAULTS: Omit<Config, "planningModel" | "buildModel" | "projectDir"> = {
18+
verbose: false,
19+
maxRetries: 3,
20+
backoffBase: 10,
21+
logRetention: 30,
22+
taskPauseSeconds: 2,
23+
}
24+
25+
/** Environment variable prefix */
26+
const ENV_PREFIX = "OPENCODER_"
27+
28+
/**
29+
* Load configuration from all sources and merge them
30+
*/
31+
export async function loadConfig(cliOptions: CliOptions, hint?: string): Promise<Config> {
32+
// Start with defaults
33+
const projectDir = resolveProjectDir(cliOptions.project)
34+
35+
// Load config file if it exists
36+
const fileConfig = await loadConfigFile(projectDir)
37+
38+
// Load environment variables
39+
const envConfig = loadEnvConfig()
40+
41+
// Merge CLI options
42+
const cliConfig = {
43+
planningModel: cliOptions.planningModel || cliOptions.model,
44+
buildModel: cliOptions.buildModel || cliOptions.model,
45+
verbose: cliOptions.verbose,
46+
}
47+
48+
// Merge all sources (later sources override earlier ones)
49+
const config: Config = {
50+
...DEFAULTS,
51+
projectDir,
52+
planningModel: "",
53+
buildModel: "",
54+
...fileConfig,
55+
...envConfig,
56+
...filterUndefined(cliConfig),
57+
userHint: hint,
58+
}
59+
60+
// Validate required fields
61+
validateConfig(config)
62+
63+
return config
64+
}
65+
66+
/**
67+
* Resolve the project directory
68+
*/
69+
function resolveProjectDir(cliProject?: string): string {
70+
if (cliProject) {
71+
return resolve(cliProject)
72+
}
73+
74+
const envProject = process.env[`${ENV_PREFIX}PROJECT_DIR`]
75+
if (envProject) {
76+
return resolve(envProject)
77+
}
78+
79+
return process.cwd()
80+
}
81+
82+
/**
83+
* Load configuration from opencoder.json file
84+
*/
85+
async function loadConfigFile(projectDir: string): Promise<Partial<Config>> {
86+
const configPath = resolve(projectDir, "opencoder.json")
87+
88+
if (!existsSync(configPath)) {
89+
return {}
90+
}
91+
92+
try {
93+
const content = await readFile(configPath, "utf-8")
94+
const parsed = JSON.parse(content) as ConfigFile
95+
96+
return {
97+
planningModel: parsed.planningModel,
98+
buildModel: parsed.buildModel,
99+
verbose: parsed.verbose,
100+
maxRetries: parsed.maxRetries,
101+
backoffBase: parsed.backoffBase,
102+
logRetention: parsed.logRetention,
103+
taskPauseSeconds: parsed.taskPauseSeconds,
104+
}
105+
} catch (err) {
106+
console.warn(`Warning: Failed to parse opencoder.json: ${err}`)
107+
return {}
108+
}
109+
}
110+
111+
/**
112+
* Load configuration from environment variables
113+
*/
114+
function loadEnvConfig(): Partial<Config> {
115+
const config: Partial<Config> = {}
116+
117+
const planningModel = process.env[`${ENV_PREFIX}PLANNING_MODEL`]
118+
if (planningModel) config.planningModel = planningModel
119+
120+
const buildModel = process.env[`${ENV_PREFIX}BUILD_MODEL`]
121+
if (buildModel) config.buildModel = buildModel
122+
123+
const verbose = process.env[`${ENV_PREFIX}VERBOSE`]
124+
if (verbose) config.verbose = verbose === "true" || verbose === "1"
125+
126+
const maxRetries = process.env[`${ENV_PREFIX}MAX_RETRIES`]
127+
if (maxRetries) {
128+
const parsed = Number.parseInt(maxRetries, 10)
129+
if (!Number.isNaN(parsed)) config.maxRetries = parsed
130+
}
131+
132+
const backoffBase = process.env[`${ENV_PREFIX}BACKOFF_BASE`]
133+
if (backoffBase) {
134+
const parsed = Number.parseInt(backoffBase, 10)
135+
if (!Number.isNaN(parsed)) config.backoffBase = parsed
136+
}
137+
138+
const logRetention = process.env[`${ENV_PREFIX}LOG_RETENTION`]
139+
if (logRetention) {
140+
const parsed = Number.parseInt(logRetention, 10)
141+
if (!Number.isNaN(parsed)) config.logRetention = parsed
142+
}
143+
144+
const taskPause = process.env[`${ENV_PREFIX}TASK_PAUSE_SECONDS`]
145+
if (taskPause) {
146+
const parsed = Number.parseInt(taskPause, 10)
147+
if (!Number.isNaN(parsed)) config.taskPauseSeconds = parsed
148+
}
149+
150+
return config
151+
}
152+
153+
/**
154+
* Validate configuration has all required fields
155+
*/
156+
function validateConfig(config: Config): void {
157+
if (!config.planningModel) {
158+
throw new Error(
159+
"Missing planning model. Provide via --model, --planning-model, opencoder.json, or OPENCODER_PLANNING_MODEL env var.",
160+
)
161+
}
162+
163+
if (!config.buildModel) {
164+
throw new Error(
165+
"Missing build model. Provide via --model, --build-model, opencoder.json, or OPENCODER_BUILD_MODEL env var.",
166+
)
167+
}
168+
169+
// Validate model format (should be provider/model)
170+
if (!isValidModelFormat(config.planningModel)) {
171+
throw new Error(
172+
`Invalid planning model format: ${config.planningModel}. Expected format: provider/model`,
173+
)
174+
}
175+
176+
if (!isValidModelFormat(config.buildModel)) {
177+
throw new Error(
178+
`Invalid build model format: ${config.buildModel}. Expected format: provider/model`,
179+
)
180+
}
181+
182+
// Validate project directory exists
183+
if (!existsSync(config.projectDir)) {
184+
throw new Error(`Project directory does not exist: ${config.projectDir}`)
185+
}
186+
}
187+
188+
/**
189+
* Check if model string has valid format (provider/model)
190+
*/
191+
function isValidModelFormat(model: string): boolean {
192+
const parts = model.split("/")
193+
return parts.length >= 2 && (parts[0]?.length ?? 0) > 0 && (parts[1]?.length ?? 0) > 0
194+
}
195+
196+
/**
197+
* Filter out undefined values from an object
198+
*/
199+
function filterUndefined<T extends object>(obj: T): Partial<T> {
200+
return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== undefined)) as Partial<T>
201+
}
202+
203+
/**
204+
* Parse a model string into provider and model ID
205+
*/
206+
export function parseModel(model: string): { providerID: string; modelID: string } {
207+
const [providerID, ...rest] = model.split("/")
208+
return {
209+
providerID: providerID ?? "",
210+
modelID: rest.join("/"),
211+
}
212+
}

src/evaluator.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* Plan evaluation response parsing
3+
*/
4+
5+
import type { EvaluationResult } from "./types.ts"
6+
7+
/**
8+
* Parse evaluation response from the AI
9+
*
10+
* Expected formats:
11+
* - COMPLETE\nReason: ...
12+
* - NEEDS_WORK\nReason: ...
13+
*/
14+
export function parseEvaluation(response: string): EvaluationResult {
15+
const trimmed = response.trim().toUpperCase()
16+
17+
// Check for COMPLETE
18+
if (trimmed.startsWith("COMPLETE") || trimmed.includes("\nCOMPLETE")) {
19+
return "COMPLETE"
20+
}
21+
22+
// Check for NEEDS_WORK
23+
if (trimmed.startsWith("NEEDS_WORK") || trimmed.includes("\nNEEDS_WORK")) {
24+
return "NEEDS_WORK"
25+
}
26+
27+
// Check within code blocks
28+
const codeBlockMatch = response.match(/```[\s\S]*?(COMPLETE|NEEDS_WORK)[\s\S]*?```/i)
29+
if (codeBlockMatch?.[1]) {
30+
return codeBlockMatch[1].toUpperCase() === "COMPLETE" ? "COMPLETE" : "NEEDS_WORK"
31+
}
32+
33+
// Default to NEEDS_WORK if unclear
34+
return "NEEDS_WORK"
35+
}
36+
37+
/**
38+
* Extract the reason from an evaluation response
39+
*/
40+
export function extractEvaluationReason(response: string): string | null {
41+
// Look for Reason: pattern
42+
const reasonMatch = response.match(/Reason:\s*(.+?)(?:\n|$)/i)
43+
if (reasonMatch?.[1]) {
44+
return reasonMatch[1].trim()
45+
}
46+
47+
// Look for reason in a code block
48+
const codeBlockMatch = response.match(
49+
/```[\s\S]*?(?:COMPLETE|NEEDS_WORK)\s*\n\s*Reason:\s*(.+?)(?:\n|```)[\s\S]*?```/i,
50+
)
51+
if (codeBlockMatch?.[1]) {
52+
return codeBlockMatch[1].trim()
53+
}
54+
55+
return null
56+
}
57+
58+
/**
59+
* Check if evaluation indicates cycle is complete
60+
*/
61+
export function isComplete(result: EvaluationResult): boolean {
62+
return result === "COMPLETE"
63+
}
64+
65+
/**
66+
* Check if evaluation indicates more work is needed
67+
*/
68+
export function needsWork(result: EvaluationResult): boolean {
69+
return result === "NEEDS_WORK"
70+
}

0 commit comments

Comments
 (0)