1use std::collections::HashMap;
28use std::path::{Path, PathBuf};
29use std::process::Stdio;
30use std::sync::atomic::{AtomicU64, Ordering};
31use std::sync::{Arc, Mutex};
32use std::time::{SystemTime, UNIX_EPOCH};
33
34use anyhow::{anyhow, bail, Context, Result};
35use futures_util::StreamExt;
36use serde_json::{json, Value};
37use tokio::io::AsyncReadExt;
38use tokio::runtime::Runtime;
39
40use crate::forge_config::{ForgeProvider, ForgeUserConfig};
41
42pub const DEFAULT_MODEL: &str = "claude-opus-4-7";
46
47const MAX_TOKENS: u32 = 8192;
49
50const ANTHROPIC_VERSION: &str = "2023-06-01";
51const ANTHROPIC_URL: &str = "https://api.anthropic.com/v1/messages";
52const OPENAI_URL: &str = "https://api.openai.com/v1/chat/completions";
53const XAI_URL: &str = "https://api.x.ai/v1/chat/completions";
54const GEMINI_BASE: &str = "https://generativelanguage.googleapis.com/v1beta/models";
55
56const MAX_AUTO_RETRIES: u32 = 3;
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64pub enum ForgeMessageRole {
65 User,
66 Assistant,
67}
68
69#[derive(Clone, Debug)]
71pub struct ForgeChatMessage {
72 pub role: ForgeMessageRole,
73 pub content: String,
74}
75
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub enum ForgePhase {
79 Idle,
81 Streaming,
83 StreamComplete,
85 Building,
87 BuildOk,
89 Error,
91}
92
93#[derive(Clone, Debug)]
96pub struct ForgeSnapshot {
97 pub id: u64,
98 pub slug: String,
99 pub prompt: String,
100 pub code: String,
101 pub phase: ForgePhase,
102 pub build_log: String,
103 pub artifact_path: Option<PathBuf>,
104 pub error: Option<String>,
105 pub revision: u64,
108 pub retries_used: u32,
110 pub max_retries: u32,
112 pub auto_fix: bool,
114 pub project_dir: PathBuf,
117 pub updated_at_ms: u64,
119 pub messages: Vec<ForgeChatMessage>,
121 pub provider: ForgeProvider,
123 pub model: String,
125}
126
127#[derive(Clone, Debug)]
128pub struct ForgeCreationSummary {
129 pub id: u64,
130 pub slug: String,
131 pub prompt: String,
132 pub phase: ForgePhase,
133 pub project_dir: PathBuf,
134 pub artifact_path: Option<PathBuf>,
135 pub updated_at_ms: u64,
136}
137
138struct Session {
141 id: u64,
142 slug: String,
143 prompt: String,
144 code: String,
145 phase: ForgePhase,
146 build_log: String,
147 artifact_path: Option<PathBuf>,
148 error: Option<String>,
149 revision: u64,
150 project_dir: PathBuf,
152 retries_used: u32,
153 auto_fix: bool,
154 updated_at_ms: u64,
155 messages: Vec<ForgeChatMessage>,
156 provider: ForgeProvider,
157 model: String,
158}
159
160impl Session {
161 fn snapshot(&self) -> ForgeSnapshot {
162 ForgeSnapshot {
163 id: self.id,
164 slug: self.slug.clone(),
165 prompt: self.prompt.clone(),
166 code: self.code.clone(),
167 phase: self.phase,
168 build_log: self.build_log.clone(),
169 artifact_path: self.artifact_path.clone(),
170 error: self.error.clone(),
171 revision: self.revision,
172 retries_used: self.retries_used,
173 max_retries: MAX_AUTO_RETRIES,
174 auto_fix: self.auto_fix,
175 project_dir: self.project_dir.clone(),
176 updated_at_ms: self.updated_at_ms,
177 messages: self.messages.clone(),
178 provider: self.provider,
179 model: self.model.clone(),
180 }
181 }
182
183 fn bump(&mut self) {
184 self.revision = self.revision.wrapping_add(1);
185 self.updated_at_ms = now_ms();
186 }
187
188 fn summary(&self) -> ForgeCreationSummary {
189 ForgeCreationSummary {
190 id: self.id,
191 slug: self.slug.clone(),
192 prompt: self.prompt.clone(),
193 phase: self.phase,
194 project_dir: self.project_dir.clone(),
195 artifact_path: self.artifact_path.clone(),
196 updated_at_ms: self.updated_at_ms,
197 }
198 }
199}
200
201type SharedSession = Arc<Mutex<Session>>;
202
203pub struct ForgeState {
206 runtime: Runtime,
207 sessions: HashMap<u64, SharedSession>,
208 next_id: AtomicU64,
209 forge_dir: PathBuf,
210 template_dir: PathBuf,
211 system_prompt: String,
212 provider: ForgeProvider,
213 api_key: String,
214 model: String,
215}
216
217impl ForgeState {
218 pub fn new() -> Option<Self> {
220 Self::from_config(&ForgeUserConfig::load())
221 }
222
223 pub fn from_config(config: &ForgeUserConfig) -> Option<Self> {
226 let api_key = config.active_api_key()?;
227 let provider = config.active_provider;
228 let model = config.active_model();
229 Self::with_credentials(provider, api_key, model)
230 }
231
232 pub fn with_api_key(api_key: impl Into<String>) -> Option<Self> {
234 let mut config = ForgeUserConfig::load();
235 config.set_api_key(config.active_provider, api_key.into());
236 Self::from_config(&config)
237 }
238
239 fn with_credentials(provider: ForgeProvider, api_key: String, model: String) -> Option<Self> {
240 let api_key = api_key.trim().to_string();
241 if api_key.is_empty() {
242 return None;
243 }
244 let runtime = Runtime::new().ok()?;
245
246 let repo_root = repo_root();
247 let forge_dir = std::env::var_os("OXIDE_FORGE_DIR")
248 .map(PathBuf::from)
249 .unwrap_or_else(|| repo_root.join("target").join("forge"));
250 let template_dir = repo_root.join("forge").join("templates").join("base");
251
252 let _ = std::fs::create_dir_all(&forge_dir);
253
254 let system_prompt = build_system_prompt(&repo_root).unwrap_or_else(|_| {
255 "You are Oxide Forge. Produce a single Rust `src/lib.rs` in \
256 one fenced code block. Import only from `oxide_sdk`. Export \
257 `start_app` and `on_frame`."
258 .to_string()
259 });
260
261 let model = if model.trim().is_empty() {
262 let from_env = std::env::var("OXIDE_FORGE_MODEL").unwrap_or_default();
263 provider.normalize_model(&from_env)
264 } else {
265 provider.normalize_model(&model)
266 };
267
268 let mut state = Self {
269 runtime,
270 sessions: HashMap::new(),
271 next_id: AtomicU64::new(1),
272 forge_dir,
273 template_dir,
274 system_prompt,
275 provider,
276 api_key,
277 model,
278 };
279 state.load_existing_sessions();
280 Some(state)
281 }
282
283 pub fn apply_config(&mut self, config: &ForgeUserConfig) -> bool {
285 if let Some(key) = config.active_api_key() {
286 self.provider = config.active_provider;
287 self.model = config.active_model();
288 self.api_key = key;
289 true
290 } else {
291 false
292 }
293 }
294
295 pub fn provider(&self) -> ForgeProvider {
296 self.provider
297 }
298
299 pub fn model(&self) -> &str {
300 &self.model
301 }
302
303 pub fn set_provider(&mut self, provider: ForgeProvider) {
304 self.provider = provider;
305 }
306
307 pub fn set_model(&mut self, model: impl Into<String>) {
308 let model = model.into().trim().to_string();
309 if !model.is_empty() {
310 self.model = model;
311 }
312 }
313
314 pub fn set_api_key(&mut self, api_key: impl Into<String>) -> bool {
316 let api_key = api_key.into().trim().to_string();
317 if api_key.is_empty() {
318 return false;
319 }
320 self.api_key = api_key;
321 true
322 }
323
324 pub fn start(&mut self, prompt: String) -> Result<u64> {
327 let id = self.next_id.fetch_add(1, Ordering::SeqCst);
328 let slug = make_slug(id);
329 let project_dir = self.forge_dir.join(&slug);
330 scaffold_project(&self.template_dir, &project_dir)
331 .with_context(|| format!("scaffold {project_dir:?}"))?;
332
333 let session = Arc::new(Mutex::new(Session {
334 id,
335 slug,
336 prompt: prompt.clone(),
337 code: String::new(),
338 phase: ForgePhase::Idle,
339 build_log: String::new(),
340 artifact_path: None,
341 error: None,
342 revision: 0,
343 project_dir,
344 retries_used: 0,
345 auto_fix: true,
346 updated_at_ms: now_ms(),
347 messages: Vec::new(),
348 provider: self.provider,
349 model: self.model.clone(),
350 }));
351
352 self.sessions.insert(id, session.clone());
353 persist_session(&session);
354
355 let system = self.system_prompt.clone();
356 let api_key = self.api_key.clone();
357 let model = self.model.clone();
358 let provider = self.provider;
359
360 self.runtime.spawn(async move {
361 run_stream_then_build(session, system, provider, api_key, model, prompt).await;
362 });
363
364 Ok(id)
365 }
366
367 pub fn revise(&mut self, id: u64, prompt: String) -> Result<()> {
370 let session = self
371 .sessions
372 .get(&id)
373 .ok_or_else(|| anyhow!("unknown forge session {id}"))?
374 .clone();
375
376 let (revision_prompt, chat_user) = {
377 let mut s = session.lock().unwrap();
378 if matches!(s.phase, ForgePhase::Streaming | ForgePhase::Building) {
379 bail!("session {id} is busy (phase={:?})", s.phase);
380 }
381 s.prompt = prompt.clone();
382 s.error = None;
383 s.build_log.clear();
384 s.artifact_path = None;
385 s.retries_used = 0;
386 s.bump();
387 persist_locked_session(&s);
388 let api = format!(
389 "Revise this existing Oxide app.\n\n\
390 User change request:\n{}\n\n\
391 Current src/lib.rs:\n```rust\n{}\n```\n\n\
392 Reply with the complete updated src/lib.rs in one ```rust fenced block.",
393 prompt, s.code
394 );
395 (api, prompt)
396 };
397
398 let system = self.system_prompt.clone();
399 let api_key = self.api_key.clone();
400 let model = self.model.clone();
401 let provider = self.provider;
402 self.runtime.spawn(async move {
403 run_stream_then_build_with(
404 session,
405 system,
406 provider,
407 api_key,
408 model,
409 chat_user,
410 revision_prompt,
411 )
412 .await;
413 });
414
415 Ok(())
416 }
417
418 pub fn snapshot(&self, id: u64) -> Option<ForgeSnapshot> {
420 let s = self.sessions.get(&id)?;
421 let s = s.lock().ok()?;
422 Some(s.snapshot())
423 }
424
425 pub fn list_ids(&self) -> Vec<u64> {
427 let mut ids: Vec<u64> = self.sessions.keys().copied().collect();
428 ids.sort_unstable();
429 ids
430 }
431
432 pub fn list_creations(&self) -> Vec<ForgeCreationSummary> {
434 let mut items = self
435 .sessions
436 .values()
437 .filter_map(|s| s.lock().ok().map(|s| s.summary()))
438 .collect::<Vec<_>>();
439 items.sort_by(|a, b| {
440 b.updated_at_ms
441 .cmp(&a.updated_at_ms)
442 .then_with(|| b.id.cmp(&a.id))
443 });
444 items
445 }
446
447 pub fn delete_creation(&mut self, id: u64) -> Result<()> {
449 let session = self
450 .sessions
451 .get(&id)
452 .ok_or_else(|| anyhow!("unknown forge session {id}"))?
453 .clone();
454 let project_dir = {
455 let s = session.lock().unwrap();
456 if matches!(s.phase, ForgePhase::Streaming | ForgePhase::Building) {
457 bail!("session {id} is busy (phase={:?})", s.phase);
458 }
459 s.project_dir.clone()
460 };
461 self.sessions.remove(&id);
462 if project_dir.exists() {
463 std::fs::remove_dir_all(&project_dir)
464 .with_context(|| format!("delete {}", project_dir.display()))?;
465 }
466 Ok(())
467 }
468
469 pub fn build(&mut self, id: u64) -> Result<()> {
474 let session = self
475 .sessions
476 .get(&id)
477 .ok_or_else(|| anyhow!("unknown forge session {id}"))?
478 .clone();
479
480 {
481 let s = session.lock().unwrap();
482 if !matches!(
483 s.phase,
484 ForgePhase::StreamComplete | ForgePhase::Error | ForgePhase::BuildOk
485 ) {
486 bail!("session {id} is not ready to build (phase={:?})", s.phase);
487 }
488 }
489
490 self.runtime.spawn(async move {
493 run_build(session).await;
494 });
495
496 Ok(())
497 }
498
499 pub fn set_auto_fix(&mut self, id: u64, enabled: bool) {
501 if let Some(s) = self.sessions.get(&id) {
502 let mut s = s.lock().unwrap();
503 s.auto_fix = enabled;
504 s.bump();
505 }
506 }
507
508 pub fn artifact_bytes(&self, id: u64) -> Option<Vec<u8>> {
510 let snap = self.snapshot(id)?;
511 let path = snap.artifact_path?;
512 std::fs::read(&path).ok()
513 }
514
515 pub fn system_prompt_len(&self) -> usize {
518 self.system_prompt.len()
519 }
520
521 pub fn output_dir(&self) -> PathBuf {
522 self.forge_dir.clone()
523 }
524
525 pub fn set_output_dir(&mut self, dir: PathBuf) -> Result<()> {
526 std::fs::create_dir_all(&dir)
527 .with_context(|| format!("create forge output dir {}", dir.display()))?;
528 self.forge_dir = dir;
529 self.load_existing_sessions();
530 Ok(())
531 }
532
533 fn load_existing_sessions(&mut self) {
534 let mut max_id = 0;
535 let mut loaded = HashMap::new();
536 let Ok(entries) = std::fs::read_dir(&self.forge_dir) else {
537 self.sessions.clear();
538 self.next_id.store(1, Ordering::SeqCst);
539 return;
540 };
541 for entry in entries.flatten() {
542 let path = entry.path();
543 if !path.is_dir() {
544 continue;
545 }
546 if let Some(session) = load_session_from_dir(&path) {
547 let id = session.lock().unwrap().id;
548 max_id = max_id.max(id);
549 loaded.insert(id, session);
550 }
551 }
552 self.sessions = loaded;
553 self.next_id.store(max_id + 1, Ordering::SeqCst);
554 }
555}
556
557async fn run_stream_then_build(
562 session: SharedSession,
563 system: String,
564 provider: ForgeProvider,
565 api_key: String,
566 model: String,
567 initial_prompt: String,
568) {
569 run_stream_then_build_with(
570 session,
571 system,
572 provider,
573 api_key,
574 model,
575 initial_prompt.clone(),
576 initial_prompt,
577 )
578 .await;
579}
580
581async fn run_stream_then_build_with(
582 session: SharedSession,
583 system: String,
584 provider: ForgeProvider,
585 api_key: String,
586 model: String,
587 chat_user: String,
588 api_prompt: String,
589) {
590 if !stream_one_attempt(
591 &session,
592 &system,
593 provider,
594 &api_key,
595 &model,
596 &chat_user,
597 &api_prompt,
598 )
599 .await
600 {
601 return;
602 }
603
604 loop {
605 run_build(session.clone()).await;
606
607 let (should_retry, retry_prompt) = {
609 let s = session.lock().unwrap();
610 let can_retry = matches!(s.phase, ForgePhase::Error)
611 && s.auto_fix
612 && s.retries_used < MAX_AUTO_RETRIES;
613 if can_retry {
614 (true, build_retry_prompt(&s))
615 } else {
616 (false, String::new())
617 }
618 };
619
620 if !should_retry {
621 break;
622 }
623
624 {
625 let mut s = session.lock().unwrap();
626 s.retries_used += 1;
627 s.code.clear();
628 s.bump();
629 persist_locked_session(&s);
630 }
631
632 if !stream_one_attempt(
633 &session,
634 &system,
635 provider,
636 &api_key,
637 &model,
638 "Fixing compile errors…",
639 &retry_prompt,
640 )
641 .await
642 {
643 break;
644 }
645 }
646}
647
648async fn stream_one_attempt(
651 session: &SharedSession,
652 system: &str,
653 provider: ForgeProvider,
654 api_key: &str,
655 model: &str,
656 chat_user: &str,
657 api_prompt: &str,
658) -> bool {
659 {
660 let mut s = session.lock().unwrap();
661 s.phase = ForgePhase::Streaming;
662 s.code.clear();
663 s.artifact_path = None;
664 s.error = None;
665 s.provider = provider;
666 s.model = model.to_string();
667 s.messages.push(ForgeChatMessage {
668 role: ForgeMessageRole::User,
669 content: chat_user.to_string(),
670 });
671 s.messages.push(ForgeChatMessage {
672 role: ForgeMessageRole::Assistant,
673 content: String::new(),
674 });
675 s.bump();
676 persist_locked_session(&s);
677 }
678
679 if let Err(e) = drive_llm_stream(session, system, provider, api_key, model, api_prompt).await {
680 let mut s = session.lock().unwrap();
681 s.phase = ForgePhase::Error;
682 s.error = Some(e.to_string());
683 s.bump();
684 persist_locked_session(&s);
685 return false;
686 }
687
688 let code_on_disk = {
689 let s = session.lock().unwrap();
690 extract_rust_block(&s.code)
691 };
692
693 let project_dir = session.lock().unwrap().project_dir.clone();
694 if let Err(e) = write_lib_rs(&project_dir, &code_on_disk) {
695 let mut s = session.lock().unwrap();
696 s.phase = ForgePhase::Error;
697 s.error = Some(format!("failed to write lib.rs: {e}"));
698 s.bump();
699 persist_locked_session(&s);
700 return false;
701 }
702
703 let mut s = session.lock().unwrap();
704 s.code = code_on_disk.clone();
705 if let Some(last) = s.messages.last_mut() {
706 if last.role == ForgeMessageRole::Assistant {
707 last.content = code_on_disk;
708 }
709 }
710 s.phase = ForgePhase::StreamComplete;
711 s.bump();
712 persist_locked_session(&s);
713 true
714}
715
716fn append_stream_delta(session: &SharedSession, delta: &str) {
717 if delta.is_empty() {
718 return;
719 }
720 let mut s = session.lock().unwrap();
721 s.code.push_str(delta);
722 if let Some(last) = s.messages.last_mut() {
723 if last.role == ForgeMessageRole::Assistant {
724 last.content.push_str(delta);
725 }
726 }
727 s.bump();
728}
729
730fn build_retry_prompt(s: &Session) -> String {
732 let log = truncate_middle(&s.build_log, 6_000);
734 format!(
735 "Your previous attempt at this app did not compile. Fix it.\n\n\
736 Original request:\n{}\n\n\
737 Previous lib.rs:\n```rust\n{}\n```\n\n\
738 Compiler output:\n```\n{}\n```\n\n\
739 Reply with the complete corrected lib.rs in one ```rust fenced block.",
740 s.prompt, s.code, log
741 )
742}
743
744fn truncate_middle(text: &str, max_bytes: usize) -> String {
747 if text.len() <= max_bytes {
748 return text.to_string();
749 }
750 let half = max_bytes / 2;
751 let mut head_end = half;
752 while head_end < text.len() && !text.is_char_boundary(head_end) {
753 head_end -= 1;
754 }
755 let mut tail_start = text.len().saturating_sub(half);
756 while tail_start < text.len() && !text.is_char_boundary(tail_start) {
757 tail_start += 1;
758 }
759 format!(
760 "{}\n…[truncated {} bytes]…\n{}",
761 &text[..head_end],
762 text.len() - head_end - (text.len() - tail_start),
763 &text[tail_start..]
764 )
765}
766
767async fn drive_llm_stream(
768 session: &SharedSession,
769 system: &str,
770 provider: ForgeProvider,
771 api_key: &str,
772 model: &str,
773 api_prompt: &str,
774) -> Result<()> {
775 match provider {
776 ForgeProvider::Anthropic => {
777 drive_anthropic_stream(session, system, api_key, model, api_prompt).await
778 }
779 ForgeProvider::Openai => {
780 drive_openai_stream(session, system, api_key, model, OPENAI_URL, api_prompt).await
781 }
782 ForgeProvider::Xai => {
783 drive_openai_stream(session, system, api_key, model, XAI_URL, api_prompt).await
784 }
785 ForgeProvider::Gemini => {
786 drive_gemini_stream(session, system, api_key, model, api_prompt).await
787 }
788 }
789}
790
791fn http_client() -> Result<reqwest::Client> {
792 reqwest::Client::builder()
793 .connect_timeout(std::time::Duration::from_secs(20))
794 .read_timeout(std::time::Duration::from_secs(120))
795 .build()
796 .context("build http client")
797}
798
799fn chat_history_for_api(session: &SharedSession, api_prompt: &str) -> Vec<(String, String)> {
800 let s = session.lock().unwrap();
801 let mut history: Vec<(String, String)> = s
802 .messages
803 .iter()
804 .filter(|m| !m.content.is_empty())
805 .map(|m| {
806 let role = match m.role {
807 ForgeMessageRole::User => "user",
808 ForgeMessageRole::Assistant => "assistant",
809 };
810 (role.to_string(), m.content.clone())
811 })
812 .collect();
813 if let Some((role, content)) = history.last_mut() {
815 if role == "user" {
816 *content = api_prompt.to_string();
817 }
818 }
819 history
820}
821
822async fn consume_sse_stream(
823 session: &SharedSession,
824 resp: reqwest::Response,
825 parse_event: impl Fn(&[u8]) -> Option<String>,
826) -> Result<()> {
827 let status = resp.status();
828 if !status.is_success() {
829 let err_body = resp.text().await.unwrap_or_default();
830 bail!("API {}: {}", status, err_body);
831 }
832
833 let mut stream = resp.bytes_stream();
834 let mut buf = Vec::<u8>::new();
835 while let Some(next) = stream.next().await {
836 let chunk = next.context("stream read")?;
837 buf.extend_from_slice(&chunk);
838
839 while let Some(pos) = find_event_boundary(&buf) {
840 let event = buf.drain(..pos).collect::<Vec<u8>>();
841 let skip = if buf.starts_with(b"\r\n\r\n") { 4 } else { 2 };
842 buf.drain(..skip.min(buf.len()));
843
844 if let Some(delta) = parse_event(&event) {
845 append_stream_delta(session, &delta);
846 }
847 }
848 }
849 Ok(())
850}
851
852async fn drive_anthropic_stream(
853 session: &SharedSession,
854 system: &str,
855 api_key: &str,
856 model: &str,
857 api_prompt: &str,
858) -> Result<()> {
859 let client = http_client()?;
860 let history = chat_history_for_api(session, api_prompt);
861 let messages: Vec<Value> = history
862 .into_iter()
863 .map(|(role, content)| json!({ "role": role, "content": content }))
864 .collect();
865
866 let body = json!({
867 "model": model,
868 "max_tokens": MAX_TOKENS,
869 "stream": true,
870 "system": system,
871 "messages": messages,
872 });
873 let body_bytes = serde_json::to_vec(&body).context("serialise request body")?;
874
875 let resp = client
876 .post(ANTHROPIC_URL)
877 .header("x-api-key", api_key)
878 .header("anthropic-version", ANTHROPIC_VERSION)
879 .header("content-type", "application/json")
880 .body(body_bytes)
881 .send()
882 .await
883 .context("POST anthropic /v1/messages")?;
884
885 consume_sse_stream(session, resp, parse_anthropic_sse_event).await
886}
887
888async fn drive_openai_stream(
889 session: &SharedSession,
890 system: &str,
891 api_key: &str,
892 model: &str,
893 url: &str,
894 api_prompt: &str,
895) -> Result<()> {
896 let client = http_client()?;
897 let history = chat_history_for_api(session, api_prompt);
898 let mut messages = vec![json!({ "role": "system", "content": system })];
899 for (role, content) in history {
900 messages.push(json!({ "role": role, "content": content }));
901 }
902
903 let body = json!({
904 "model": model,
905 "max_tokens": MAX_TOKENS,
906 "stream": true,
907 "messages": messages,
908 });
909 let body_bytes = serde_json::to_vec(&body).context("serialise request body")?;
910
911 let resp = client
912 .post(url)
913 .header("Authorization", format!("Bearer {api_key}"))
914 .header("content-type", "application/json")
915 .body(body_bytes)
916 .send()
917 .await
918 .context("POST chat/completions")?;
919
920 consume_sse_stream(session, resp, parse_openai_sse_event).await
921}
922
923async fn drive_gemini_stream(
924 session: &SharedSession,
925 system: &str,
926 api_key: &str,
927 model: &str,
928 api_prompt: &str,
929) -> Result<()> {
930 let client = http_client()?;
931 let history = chat_history_for_api(session, api_prompt);
932 let mut contents = Vec::new();
933 for (role, content) in history {
934 let gemini_role = if role == "assistant" { "model" } else { "user" };
935 contents.push(json!({
936 "role": gemini_role,
937 "parts": [{ "text": content }],
938 }));
939 }
940
941 let body = json!({
942 "systemInstruction": { "parts": [{ "text": system }] },
943 "contents": contents,
944 "generationConfig": { "maxOutputTokens": MAX_TOKENS },
945 });
946 let body_bytes = serde_json::to_vec(&body).context("serialise request body")?;
947 let url = format!("{GEMINI_BASE}/{model}:streamGenerateContent?alt=sse");
948
949 let resp = client
950 .post(&url)
951 .header("x-goog-api-key", api_key)
952 .header("content-type", "application/json")
953 .body(body_bytes)
954 .send()
955 .await
956 .context("POST gemini streamGenerateContent")?;
957
958 consume_sse_stream(session, resp, parse_gemini_sse_event).await
959}
960
961fn find_event_boundary(buf: &[u8]) -> Option<usize> {
964 for i in 0..buf.len().saturating_sub(1) {
965 if buf[i] == b'\n' && buf[i + 1] == b'\n' {
966 return Some(i);
967 }
968 if i + 3 < buf.len() && &buf[i..i + 4] == b"\r\n\r\n" {
969 return Some(i);
970 }
971 }
972 None
973}
974
975fn parse_anthropic_sse_event(event: &[u8]) -> Option<String> {
976 parse_sse_event_with(event, |kind, v| match kind {
977 "content_block_delta" => {
978 let t = v.get("delta")?.get("text")?.as_str()?;
979 Some(t.to_string())
980 }
981 _ => None,
982 })
983}
984
985fn parse_openai_sse_event(event: &[u8]) -> Option<String> {
986 parse_sse_event_with(event, |kind, v| {
987 let _ = kind;
988 v.get("choices")?
989 .as_array()?
990 .first()?
991 .get("delta")?
992 .get("content")?
993 .as_str()
994 .map(|s| s.to_string())
995 })
996}
997
998fn parse_gemini_sse_event(event: &[u8]) -> Option<String> {
999 parse_sse_event_with(event, |kind, v| {
1000 let _ = kind;
1001 v.get("candidates")?
1002 .as_array()?
1003 .first()?
1004 .get("content")?
1005 .get("parts")?
1006 .as_array()?
1007 .first()?
1008 .get("text")?
1009 .as_str()
1010 .map(|s| s.to_string())
1011 })
1012}
1013
1014fn parse_sse_event_with(
1017 event: &[u8],
1018 extract: impl Fn(&str, &Value) -> Option<String>,
1019) -> Option<String> {
1020 let text = std::str::from_utf8(event).ok()?;
1022 let mut data = String::new();
1023 for line in text.lines() {
1024 if let Some(rest) = line.strip_prefix("data:") {
1025 data.push_str(rest.trim_start());
1026 data.push('\n');
1027 }
1028 }
1029 if data.is_empty() {
1030 return None;
1031 }
1032 let data = data.trim();
1033 if data == "[DONE]" {
1034 return None;
1035 }
1036 let v: Value = serde_json::from_str(data).ok()?;
1037 let kind = v
1038 .get("type")
1039 .and_then(Value::as_str)
1040 .or_else(|| v.get("object").and_then(Value::as_str))
1041 .unwrap_or("");
1042 extract(kind, &v)
1043}
1044
1045#[cfg(test)]
1046fn parse_sse_event(event: &[u8]) -> Option<String> {
1047 parse_anthropic_sse_event(event)
1048}
1049
1050fn extract_rust_block(reply: &str) -> String {
1053 if let Some(start) = reply.find("```rust") {
1054 let after = &reply[start + "```rust".len()..];
1055 let after = after.strip_prefix('\n').unwrap_or(after);
1057 if let Some(end) = after.find("```") {
1058 return after[..end].trim_end().to_string();
1059 }
1060 }
1061 if let Some(start) = reply.find("```") {
1062 let after = &reply[start + 3..];
1063 let after = after.strip_prefix('\n').unwrap_or(after);
1064 if let Some(end) = after.find("```") {
1065 return after[..end].trim_end().to_string();
1066 }
1067 }
1068 reply.trim().to_string()
1069}
1070
1071fn scaffold_project(template: &Path, project_dir: &Path) -> Result<()> {
1074 std::fs::create_dir_all(project_dir.join("src"))?;
1075
1076 let cargo_toml = template.join("Cargo.toml");
1077 let lib_rs = template.join("src").join("lib.rs");
1078
1079 if cargo_toml.is_file() {
1080 let mut cargo = std::fs::read_to_string(&cargo_toml)?;
1081 let sdk_path = toml_string(&repo_root().join("oxide-sdk").to_string_lossy());
1082 cargo = cargo.replace(
1083 "oxide-sdk = { path = \"../../../oxide-sdk\" }",
1084 &format!("oxide-sdk = {{ path = \"{sdk_path}\" }}"),
1085 );
1086 std::fs::write(project_dir.join("Cargo.toml"), cargo)?;
1087 } else {
1088 bail!("template Cargo.toml missing at {cargo_toml:?}");
1089 }
1090 if lib_rs.is_file() {
1091 std::fs::copy(&lib_rs, project_dir.join("src").join("lib.rs"))?;
1092 } else {
1093 bail!("template src/lib.rs missing at {lib_rs:?}");
1094 }
1095
1096 Ok(())
1097}
1098
1099fn write_lib_rs(project_dir: &Path, code: &str) -> Result<()> {
1100 let path = project_dir.join("src").join("lib.rs");
1101 std::fs::write(&path, code).with_context(|| format!("write {path:?}"))?;
1102 Ok(())
1103}
1104
1105fn toml_string(s: &str) -> String {
1106 s.replace('\\', "\\\\").replace('"', "\\\"")
1107}
1108
1109async fn run_build(session: SharedSession) {
1112 let project_dir = {
1113 let mut s = session.lock().unwrap();
1114 s.phase = ForgePhase::Building;
1115 s.build_log.clear();
1116 s.error = None;
1117 s.bump();
1118 persist_locked_session(&s);
1119 s.project_dir.clone()
1120 };
1121
1122 let mut cmd = tokio::process::Command::new("cargo");
1123 cmd.arg("build")
1124 .arg("--target")
1125 .arg("wasm32-unknown-unknown")
1126 .arg("--release")
1127 .arg("--quiet")
1128 .arg("--color")
1129 .arg("never")
1130 .env("CARGO_TERM_COLOR", "never")
1131 .current_dir(&project_dir)
1132 .stdin(Stdio::null())
1133 .stdout(Stdio::piped())
1134 .stderr(Stdio::piped());
1135
1136 let mut child = match cmd.spawn() {
1137 Ok(c) => c,
1138 Err(e) => {
1139 let mut s = session.lock().unwrap();
1140 s.phase = ForgePhase::Error;
1141 s.error = Some(format!("spawn cargo: {e}"));
1142 s.bump();
1143 persist_locked_session(&s);
1144 return;
1145 }
1146 };
1147
1148 let mut stderr_buf = String::new();
1150 if let Some(mut stderr) = child.stderr.take() {
1151 let _ = stderr.read_to_string(&mut stderr_buf).await;
1152 }
1153 if let Some(mut stdout) = child.stdout.take() {
1155 let mut _discard = String::new();
1156 let _ = stdout.read_to_string(&mut _discard).await;
1157 }
1158
1159 let status = child.wait().await;
1160
1161 let mut s = session.lock().unwrap();
1162 s.build_log = stderr_buf;
1163
1164 match status {
1165 Ok(st) if st.success() => {
1166 let artifact = project_dir
1167 .join("target")
1168 .join("wasm32-unknown-unknown")
1169 .join("release")
1170 .join("forge_app.wasm");
1171 if artifact.is_file() {
1172 let exported = s.project_dir.join(format!("{}.wasm", s.slug));
1173 match std::fs::copy(&artifact, &exported) {
1174 Ok(_) => {
1175 s.artifact_path = Some(exported);
1176 s.phase = ForgePhase::BuildOk;
1177 }
1178 Err(e) => {
1179 s.artifact_path = Some(artifact);
1180 s.phase = ForgePhase::Error;
1181 s.error = Some(format!("copy wasm artifact failed: {e}"));
1182 }
1183 }
1184 } else {
1185 s.phase = ForgePhase::Error;
1186 s.error = Some(format!("cargo returned success but {artifact:?} missing"));
1187 }
1188 }
1189 Ok(st) => {
1190 s.phase = ForgePhase::Error;
1191 s.error = Some(format!("cargo exited with {st}"));
1192 }
1193 Err(e) => {
1194 s.phase = ForgePhase::Error;
1195 s.error = Some(format!("cargo wait failed: {e}"));
1196 }
1197 }
1198 s.bump();
1199 persist_locked_session(&s);
1200}
1201
1202fn repo_root() -> PathBuf {
1205 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1208 .parent()
1209 .map(PathBuf::from)
1210 .unwrap_or_else(|| PathBuf::from("."))
1211}
1212
1213fn make_slug(id: u64) -> String {
1214 let secs = SystemTime::now()
1215 .duration_since(UNIX_EPOCH)
1216 .map(|d| d.as_secs())
1217 .unwrap_or(0);
1218 format!("s{secs:010}-{id:04}")
1219}
1220
1221fn now_ms() -> u64 {
1222 SystemTime::now()
1223 .duration_since(UNIX_EPOCH)
1224 .map(|d| d.as_millis() as u64)
1225 .unwrap_or(0)
1226}
1227
1228fn metadata_path(project_dir: &Path) -> PathBuf {
1229 project_dir.join("forge.json")
1230}
1231
1232fn phase_to_str(phase: ForgePhase) -> &'static str {
1233 match phase {
1234 ForgePhase::Idle => "idle",
1235 ForgePhase::Streaming => "streaming",
1236 ForgePhase::StreamComplete => "stream_complete",
1237 ForgePhase::Building => "building",
1238 ForgePhase::BuildOk => "build_ok",
1239 ForgePhase::Error => "error",
1240 }
1241}
1242
1243fn str_to_phase(s: &str) -> ForgePhase {
1244 match s {
1245 "build_ok" => ForgePhase::BuildOk,
1246 "error" => ForgePhase::Error,
1247 "building" | "streaming" | "stream_complete" => ForgePhase::StreamComplete,
1248 _ => ForgePhase::StreamComplete,
1249 }
1250}
1251
1252fn persist_session(session: &SharedSession) {
1253 if let Ok(s) = session.lock() {
1254 persist_locked_session(&s);
1255 }
1256}
1257
1258fn persist_locked_session(s: &Session) {
1259 let artifact = s
1260 .artifact_path
1261 .as_ref()
1262 .map(|p| p.to_string_lossy().to_string());
1263 let messages: Vec<Value> = s
1264 .messages
1265 .iter()
1266 .map(|m| {
1267 let role = match m.role {
1268 ForgeMessageRole::User => "user",
1269 ForgeMessageRole::Assistant => "assistant",
1270 };
1271 json!({ "role": role, "content": m.content })
1272 })
1273 .collect();
1274 let meta = json!({
1275 "id": s.id,
1276 "slug": s.slug,
1277 "prompt": s.prompt,
1278 "phase": phase_to_str(s.phase),
1279 "artifact_path": artifact,
1280 "updated_at_ms": s.updated_at_ms,
1281 "retries_used": s.retries_used,
1282 "auto_fix": s.auto_fix,
1283 "provider": s.provider.id(),
1284 "model": s.model,
1285 "messages": messages,
1286 });
1287 let _ = std::fs::write(
1288 metadata_path(&s.project_dir),
1289 serde_json::to_string_pretty(&meta).unwrap_or_else(|_| "{}".to_string()),
1290 );
1291}
1292
1293fn load_session_from_dir(project_dir: &Path) -> Option<SharedSession> {
1294 let meta = std::fs::read_to_string(metadata_path(project_dir)).ok()?;
1295 let v: Value = serde_json::from_str(&meta).ok()?;
1296 let id = v.get("id")?.as_u64()?;
1297 let slug = v.get("slug")?.as_str()?.to_string();
1298 let prompt = v
1299 .get("prompt")
1300 .and_then(Value::as_str)
1301 .unwrap_or("")
1302 .to_string();
1303 let code = std::fs::read_to_string(project_dir.join("src").join("lib.rs")).unwrap_or_default();
1304 let artifact_path = v
1305 .get("artifact_path")
1306 .and_then(Value::as_str)
1307 .map(PathBuf::from)
1308 .filter(|p| p.is_file())
1309 .or_else(|| {
1310 let p = project_dir.join(format!("{slug}.wasm"));
1311 p.is_file().then_some(p)
1312 });
1313 let phase = if artifact_path.is_some() {
1314 ForgePhase::BuildOk
1315 } else {
1316 str_to_phase(v.get("phase").and_then(Value::as_str).unwrap_or(""))
1317 };
1318 let provider = v
1319 .get("provider")
1320 .and_then(Value::as_str)
1321 .and_then(ForgeProvider::from_id)
1322 .unwrap_or(ForgeProvider::Anthropic);
1323 let model =
1324 provider.normalize_model(v.get("model").and_then(Value::as_str).unwrap_or_default());
1325 let messages = v
1326 .get("messages")
1327 .and_then(Value::as_array)
1328 .map(|arr| {
1329 arr.iter()
1330 .filter_map(|item| {
1331 let role = item.get("role")?.as_str()?;
1332 let content = item.get("content")?.as_str()?.to_string();
1333 let role = match role {
1334 "assistant" => ForgeMessageRole::Assistant,
1335 _ => ForgeMessageRole::User,
1336 };
1337 Some(ForgeChatMessage { role, content })
1338 })
1339 .collect::<Vec<_>>()
1340 })
1341 .unwrap_or_default();
1342 Some(Arc::new(Mutex::new(Session {
1343 id,
1344 slug,
1345 prompt,
1346 code,
1347 phase,
1348 build_log: String::new(),
1349 artifact_path,
1350 error: None,
1351 revision: 0,
1352 project_dir: project_dir.to_path_buf(),
1353 retries_used: v.get("retries_used").and_then(Value::as_u64).unwrap_or(0) as u32,
1354 auto_fix: v.get("auto_fix").and_then(Value::as_bool).unwrap_or(true),
1355 updated_at_ms: v
1356 .get("updated_at_ms")
1357 .and_then(Value::as_u64)
1358 .unwrap_or_else(now_ms),
1359 messages,
1360 provider,
1361 model,
1362 })))
1363}
1364
1365const FORGE_SKILL_NAME: &str = "oxide-wasm-app";
1369
1370fn build_system_prompt(repo_root: &Path) -> Result<String> {
1378 let skill_dir = repo_root
1379 .join("forge")
1380 .join("skills")
1381 .join(FORGE_SKILL_NAME);
1382 let skill_md_path = skill_dir.join("SKILL.md");
1383 let skill_md = std::fs::read_to_string(&skill_md_path)
1384 .with_context(|| format!("read skill at {}", skill_md_path.display()))?;
1385 let (frontmatter, body) = split_skill_frontmatter(&skill_md);
1386 if frontmatter.is_none() {
1387 bail!(
1388 "skill {} is missing required YAML frontmatter (see https://agentskills.io/specification)",
1389 skill_md_path.display()
1390 );
1391 }
1392
1393 let mut out = String::with_capacity(body.len() + 8 * 1024);
1394 out.push_str(body.trim_start());
1395
1396 let references_dir = skill_dir.join("references");
1397 let mut reference_files: Vec<PathBuf> = match std::fs::read_dir(&references_dir) {
1398 Ok(entries) => entries
1399 .flatten()
1400 .map(|e| e.path())
1401 .filter(|p| {
1402 p.is_file()
1403 && p.extension()
1404 .and_then(|e| e.to_str())
1405 .map(|e| e.eq_ignore_ascii_case("md"))
1406 .unwrap_or(false)
1407 })
1408 .collect(),
1409 Err(_) => Vec::new(),
1410 };
1411 reference_files.sort();
1412
1413 for path in reference_files {
1414 let body = std::fs::read_to_string(&path)
1415 .with_context(|| format!("read reference {}", path.display()))?;
1416 let title = path
1417 .file_stem()
1418 .and_then(|s| s.to_str())
1419 .unwrap_or("Reference");
1420 out.push_str("\n\n---\n\n# Reference: ");
1421 out.push_str(title);
1422 out.push_str("\n\n");
1423 out.push_str(body.trim_end());
1424 out.push('\n');
1425 }
1426
1427 Ok(out)
1428}
1429
1430fn split_skill_frontmatter(doc: &str) -> (Option<&str>, &str) {
1435 let rest = match doc.strip_prefix("---\n") {
1436 Some(r) => r,
1437 None => match doc.strip_prefix("---\r\n") {
1438 Some(r) => r,
1439 None => return (None, doc),
1440 },
1441 };
1442 let mut search_from = 0usize;
1444 while let Some(rel) = rest[search_from..].find("\n---") {
1445 let end = search_from + rel;
1446 let after_marker = end + "\n---".len();
1447 let tail = &rest[after_marker..];
1448 let is_line_terminated =
1449 tail.is_empty() || tail.starts_with('\n') || tail.starts_with("\r\n");
1450 if is_line_terminated {
1451 let fm = &rest[..end];
1452 let body_start = if tail.starts_with("\r\n") {
1454 after_marker + 2
1455 } else if tail.starts_with('\n') {
1456 after_marker + 1
1457 } else {
1458 after_marker
1459 };
1460 return (Some(fm), &rest[body_start..]);
1461 }
1462 search_from = end + 1;
1463 }
1464 (None, doc)
1465}
1466
1467#[cfg(test)]
1470mod tests {
1471 use super::*;
1472
1473 #[test]
1474 fn extracts_rust_fence() {
1475 let reply = "Here is your app:\n```rust\nuse oxide_sdk::*;\n```\n";
1476 assert_eq!(extract_rust_block(reply), "use oxide_sdk::*;");
1477 }
1478
1479 #[test]
1480 fn extracts_plain_fence() {
1481 let reply = "```\nuse oxide_sdk::*;\n```\nfootnote";
1482 assert_eq!(extract_rust_block(reply), "use oxide_sdk::*;");
1483 }
1484
1485 #[test]
1486 fn passthrough_when_no_fence() {
1487 let reply = "use oxide_sdk::*;\nfn main(){}";
1488 assert_eq!(extract_rust_block(reply), reply.trim());
1489 }
1490
1491 #[test]
1492 fn slug_has_expected_shape() {
1493 let s = make_slug(42);
1494 assert!(s.starts_with('s'), "slug was: {s}");
1495 assert!(s.ends_with("-0042"), "slug was: {s}");
1496 assert_eq!(s.len(), 16, "slug was: {s}");
1498 }
1499
1500 #[test]
1501 fn parses_content_block_delta() {
1502 let event = b"event: content_block_delta\n\
1503 data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}";
1504 assert_eq!(parse_sse_event(event).as_deref(), Some("hi"));
1505 }
1506
1507 #[test]
1508 fn ignores_ping_events() {
1509 let event = b"event: ping\ndata: {\"type\":\"ping\"}";
1510 assert_eq!(parse_sse_event(event), None);
1511 }
1512
1513 #[test]
1514 fn finds_sse_boundary() {
1515 let buf = b"data: x\n\ndata: y\n\n";
1516 assert_eq!(find_event_boundary(buf), Some(7));
1517 }
1518
1519 #[test]
1520 fn scaffold_copies_template() {
1521 let tmp = tempfile::tempdir().unwrap();
1522 let template = repo_root().join("forge").join("templates").join("base");
1523 assert!(template.is_dir(), "template must exist");
1524
1525 let project = tmp.path().join("sandbox-project");
1526 scaffold_project(&template, &project).expect("scaffold");
1527
1528 assert!(project.join("Cargo.toml").is_file());
1529 assert!(project.join("src").join("lib.rs").is_file());
1530 let cargo = std::fs::read_to_string(project.join("Cargo.toml")).unwrap();
1531 assert!(cargo.contains(&repo_root().join("oxide-sdk").to_string_lossy().to_string()));
1532
1533 let code = "pub fn hi() -> i32 { 42 }";
1535 write_lib_rs(&project, code).expect("write lib.rs");
1536 let written = std::fs::read_to_string(project.join("src").join("lib.rs")).unwrap();
1537 assert_eq!(written, code);
1538 }
1539
1540 #[test]
1541 fn repo_root_contains_forge_skill() {
1542 let root = repo_root();
1545 let skill = root
1546 .join("forge")
1547 .join("skills")
1548 .join(FORGE_SKILL_NAME)
1549 .join("SKILL.md");
1550 assert!(skill.is_file(), "missing skill at {}", skill.display());
1551 assert!(root.join("oxide-sdk").join("Cargo.toml").is_file());
1552 }
1553
1554 #[test]
1555 fn build_system_prompt_non_empty_and_references_contract() {
1556 let prompt = build_system_prompt(&repo_root()).expect("build prompt");
1557 assert!(prompt.len() > 5_000, "prompt too small: {}", prompt.len());
1559 assert!(prompt.contains("Oxide Forge — Guest WASM App Skill"));
1561 assert!(prompt.contains("start_app"));
1562 assert!(prompt.contains("on_frame"));
1563 assert!(!prompt.starts_with("---"));
1565 assert!(!prompt.contains("name: oxide-wasm-app"));
1566 assert!(prompt.contains("Reference: CAPABILITIES"));
1568 assert!(prompt.contains("Reference: PATTERNS"));
1569 assert!(prompt.contains("Reference: RECIPES"));
1570 }
1571
1572 #[test]
1573 fn splits_skill_frontmatter() {
1574 let doc = "---\nname: demo\ndescription: test\n---\n# Body\ntext\n";
1575 let (fm, body) = split_skill_frontmatter(doc);
1576 assert_eq!(fm, Some("name: demo\ndescription: test"));
1577 assert_eq!(body, "# Body\ntext\n");
1578 }
1579
1580 #[test]
1581 fn missing_frontmatter_passes_through() {
1582 let doc = "# No frontmatter\n";
1583 let (fm, body) = split_skill_frontmatter(doc);
1584 assert!(fm.is_none());
1585 assert_eq!(body, doc);
1586 }
1587}