webxos commited on
Commit
72e7c4a
Β·
verified Β·
1 Parent(s): 95cb896

Upload 3 files

Browse files
Files changed (3) hide show
  1. Cargo.toml +27 -0
  2. main.rs +1161 -0
  3. start.sh +78 -0
Cargo.toml ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [package]
2
+ name = "rustyclaw"
3
+ version = "0.6.0"
4
+ edition = "2021"
5
+
6
+ [[bin]]
7
+ name = "rustyclaw"
8
+ path = "src/main.rs"
9
+
10
+ [dependencies]
11
+ anyhow = "1.0"
12
+ bytes = "1.5"
13
+ chrono = { version = "0.4", features = ["serde"] }
14
+ crossterm = "0.27"
15
+ dirs = "5.0"
16
+ rand = "0.8"
17
+ ratatui = "0.25"
18
+ regex = "1.10"
19
+ reqwest = { version = "0.11", features = ["json"] }
20
+ serde = { version = "1.0", features = ["derive"] }
21
+ serde_json = "1.0"
22
+ serde_yaml = "0.9"
23
+ tokio = { version = "1.35", features = ["full", "process"] }
24
+ tracing = "0.1"
25
+ tracing-subscriber = { version = "0.3", features = ["env-filter", "time", "chrono"] }
26
+ walkdir = "2"
27
+ warp = "0.3"
main.rs ADDED
@@ -0,0 +1,1161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ============================================================
2
+ // RustyClaw v0.6.0 – single‑file TUI with permanent logo
3
+ // ============================================================
4
+
5
+ // ──────────────────────────────────────────────────────────────
6
+ // Imports
7
+ // ──────────────────────────────────────────────────────────────
8
+ use anyhow::{Context, Result};
9
+ use crossterm::event::{self, Event, KeyCode, KeyEventKind};
10
+ use ratatui::{
11
+ layout::{Alignment, Constraint, Direction, Layout},
12
+ style::{Color, Modifier, Style},
13
+ text::{Line, Span},
14
+ widgets::{Block, Borders, List, ListItem, Paragraph},
15
+ Frame,
16
+ };
17
+ use serde::{Deserialize, Serialize};
18
+ use std::collections::VecDeque;
19
+ use std::path::{Path, PathBuf};
20
+ use std::sync::Arc;
21
+ use std::time::Duration;
22
+ use tokio::fs;
23
+ use tokio::io::AsyncWriteExt;
24
+ use tokio::process::Command;
25
+ use tokio::sync::{mpsc, RwLock};
26
+ use tokio::time;
27
+ use tracing::{error, info, warn};
28
+ use walkdir::WalkDir;
29
+ use regex::Regex;
30
+ use warp::Filter;
31
+
32
+ // ---------- Config ----------
33
+ #[derive(Debug, Clone, Serialize, Deserialize)]
34
+ pub struct Config {
35
+ pub ollama_url: String,
36
+ pub ollama_model: String,
37
+ pub api_port: u16,
38
+ pub root_dir: PathBuf,
39
+ pub bio_file: PathBuf,
40
+ pub heartbeat_log: PathBuf,
41
+ pub memory_sync_interval_secs: u64,
42
+ pub max_log_lines: usize,
43
+ pub git_auto_commit: bool,
44
+ }
45
+
46
+ impl Default for Config {
47
+ fn default() -> Self {
48
+ let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
49
+ let root_dir = home.join(".rustyclaw");
50
+ Self {
51
+ ollama_url: "http://localhost:11434".to_string(),
52
+ ollama_model: "qwen2.5:0.5b".to_string(),
53
+ api_port: 3030,
54
+ root_dir: root_dir.clone(),
55
+ bio_file: root_dir.join("bio.md"),
56
+ heartbeat_log: root_dir.join("data/logs/heartbeat.log"),
57
+ memory_sync_interval_secs: 3600,
58
+ max_log_lines: 200,
59
+ git_auto_commit: true,
60
+ }
61
+ }
62
+ }
63
+
64
+ impl Config {
65
+ pub async fn load(path: &Path) -> Result<Self> {
66
+ if path.exists() {
67
+ let raw = fs::read_to_string(path).await?;
68
+ let cfg: Config = serde_yaml::from_str(&raw)?;
69
+ Ok(cfg)
70
+ } else {
71
+ Ok(Config::default())
72
+ }
73
+ }
74
+
75
+ pub async fn save(&self, path: &Path) -> Result<()> {
76
+ let yaml = serde_yaml::to_string(self)?;
77
+ fs::write(path, yaml).await?;
78
+ Ok(())
79
+ }
80
+ }
81
+
82
+ // ---------- JsonLogger ----------
83
+ struct JsonLogger {
84
+ log_file: PathBuf,
85
+ }
86
+
87
+ impl JsonLogger {
88
+ fn new(log_file: PathBuf) -> Self {
89
+ Self { log_file }
90
+ }
91
+
92
+ fn init_global(&self) -> Result<()> {
93
+ tracing_subscriber::fmt()
94
+ .with_env_filter(
95
+ tracing_subscriber::EnvFilter::from_default_env()
96
+ .add_directive(tracing::Level::INFO.into()),
97
+ )
98
+ .with_target(true)
99
+ .with_timer(tracing_subscriber::fmt::time::ChronoLocal::rfc_3339())
100
+ .init();
101
+ info!("JsonLogger initialized β†’ {}", self.log_file.display());
102
+ Ok(())
103
+ }
104
+ }
105
+
106
+ // ---------- Agent ----------
107
+ pub struct Agent {
108
+ config: Arc<RwLock<Config>>,
109
+ bio_path: PathBuf,
110
+ heartbeat_log_path: PathBuf,
111
+ }
112
+
113
+ impl Agent {
114
+ pub async fn new(config: Arc<RwLock<Config>>) -> Result<Self> {
115
+ let cfg = config.read().await;
116
+ let bio_path = cfg.bio_file.clone();
117
+ let heartbeat_log_path = cfg.heartbeat_log.clone();
118
+ drop(cfg);
119
+
120
+ if let Some(parent) = bio_path.parent() {
121
+ fs::create_dir_all(parent).await?;
122
+ }
123
+ if let Some(parent) = heartbeat_log_path.parent() {
124
+ fs::create_dir_all(parent).await?;
125
+ }
126
+
127
+ if !bio_path.exists() {
128
+ let template = format!(
129
+ r#"# BIO.MD – Living Agent Identity
130
+ **Last Updated:** {}
131
+ **Agent Role:** Local Assistant
132
+
133
+ ## SOUL
134
+ Core personality, values, constraints, and behavioral rules.
135
+ - You are a local-only agent running on Ollama with no internet access.
136
+ - Stay sandboxed and respect the host system's security.
137
+ - Personality traits: concise, reflective, self-improving, helpful.
138
+
139
+ ## SKILLS
140
+ Reusable capabilities and "how-to" instructions.
141
+ - Read and write local files using incremental edits.
142
+ - Execute safe, whitelisted shell commands.
143
+ - Summarize interactions and distill insights.
144
+
145
+ ## MEMORY
146
+ Curated long-term knowledge and history.
147
+
148
+ ## CONTEXT
149
+ Current runtime state.
150
+ - Operating System: Debian Linux
151
+ - Working Directory: {}
152
+ - Config: {}
153
+
154
+ ## SESSION TREE
155
+ Pointers or summaries of active conversation branches.
156
+ "#,
157
+ chrono::Utc::now().to_rfc3339(),
158
+ std::env::current_dir().unwrap_or_default().display(),
159
+ config.read().await.ollama_model
160
+ );
161
+ fs::write(&bio_path, template).await?;
162
+ info!("Created initial bio.md at {}", bio_path.display());
163
+ }
164
+
165
+ if !heartbeat_log_path.exists() {
166
+ fs::File::create(&heartbeat_log_path).await?;
167
+ info!("Created heartbeat log at {}", heartbeat_log_path.display());
168
+ }
169
+
170
+ Ok(Self {
171
+ config,
172
+ bio_path,
173
+ heartbeat_log_path,
174
+ })
175
+ }
176
+
177
+ pub fn bio_path(&self) -> &Path {
178
+ &self.bio_path
179
+ }
180
+
181
+ pub async fn read_bio(&self) -> Result<String> {
182
+ fs::read_to_string(&self.bio_path).await.context("Failed to read bio.md")
183
+ }
184
+
185
+ pub async fn update_bio_timestamp(&self) -> Result<()> {
186
+ let content = self.read_bio().await?;
187
+ let now = chrono::Utc::now().to_rfc3339();
188
+ let updated = content
189
+ .lines()
190
+ .map(|line| {
191
+ if line.starts_with("**Last Updated:**") {
192
+ format!("**Last Updated:** {}", now)
193
+ } else {
194
+ line.to_string()
195
+ }
196
+ })
197
+ .collect::<Vec<_>>()
198
+ .join("\n");
199
+ fs::write(&self.bio_path, updated).await?;
200
+ Ok(())
201
+ }
202
+
203
+ pub async fn append_heartbeat(&self, user_msg: &str, assistant_reply: &str) -> Result<()> {
204
+ let timestamp = chrono::Utc::now().to_rfc3339();
205
+ let entry = format!(
206
+ r#"{{"timestamp":"{}","user":{},"assistant":{}}}"#,
207
+ timestamp,
208
+ serde_json::to_string(user_msg)?,
209
+ serde_json::to_string(assistant_reply)?
210
+ );
211
+ let mut file = fs::OpenOptions::new()
212
+ .create(true)
213
+ .append(true)
214
+ .open(&self.heartbeat_log_path)
215
+ .await?;
216
+ file.write_all(entry.as_bytes()).await?;
217
+ file.write_all(b"\n").await?;
218
+ Ok(())
219
+ }
220
+
221
+ pub async fn consolidate_memory(&self) -> Result<()> {
222
+ let heartbeat_content = fs::read_to_string(&self.heartbeat_log_path).await?;
223
+ let entries: Vec<serde_json::Value> = heartbeat_content
224
+ .lines()
225
+ .filter_map(|line| serde_json::from_str(line).ok())
226
+ .collect();
227
+
228
+ if entries.is_empty() {
229
+ return Ok(());
230
+ }
231
+
232
+ let mut summary_text = String::new();
233
+ for entry in entries.iter().take(20) {
234
+ let user = entry["user"].as_str().unwrap_or("");
235
+ let assistant = entry["assistant"].as_str().unwrap_or("");
236
+ summary_text.push_str(&format!("User: {}\nAssistant: {}\n\n", user, assistant));
237
+ }
238
+
239
+ let cfg = self.config.read().await;
240
+ let prompt = format!(
241
+ "You are a memory summarizer. Please distill the following recent interactions into a concise note for the agent's MEMORY section. Keep it factual and useful.\n\n{}",
242
+ summary_text
243
+ );
244
+ let summary = match ollama_generate(&cfg.ollama_url, &cfg.ollama_model, &prompt).await {
245
+ Ok(s) => s,
246
+ Err(e) => {
247
+ error!("Memory summarization failed: {}", e);
248
+ return Ok(());
249
+ }
250
+ };
251
+ drop(cfg);
252
+
253
+ let mut bio = self.read_bio().await?;
254
+ let memory_marker = "## MEMORY";
255
+ if let Some(pos) = bio.find(memory_marker) {
256
+ let insert_pos = pos + memory_marker.len();
257
+ let memory_block = format!(
258
+ "\n### Summary for {}\n{}\n",
259
+ chrono::Utc::now().format("%Y-%m-%d %H:%M:%S"),
260
+ summary
261
+ );
262
+ bio.insert_str(insert_pos, &memory_block);
263
+ fs::write(&self.bio_path, bio).await?;
264
+ info!("Memory consolidated and bio.md updated.");
265
+ } else {
266
+ warn!("MEMORY section not found in bio.md");
267
+ }
268
+ Ok(())
269
+ }
270
+
271
+ pub async fn chat(&self, user_msg: &str) -> Result<String> {
272
+ let bio_content = self.read_bio().await?;
273
+ let cfg = self.config.read().await;
274
+ ollama_generate_with_system(
275
+ &cfg.ollama_url,
276
+ &cfg.ollama_model,
277
+ &bio_content,
278
+ user_msg,
279
+ )
280
+ .await
281
+ }
282
+ }
283
+
284
+ // ---------- Ollama functions ----------
285
+ #[derive(Debug, Serialize)]
286
+ struct OllamaChatRequest {
287
+ model: String,
288
+ messages: Vec<ChatMessage>,
289
+ stream: bool,
290
+ }
291
+
292
+ #[derive(Debug, Serialize, Deserialize)]
293
+ struct ChatMessage {
294
+ role: String,
295
+ content: String,
296
+ }
297
+
298
+ #[derive(Debug, Deserialize)]
299
+ struct OllamaChatResponse {
300
+ message: ChatMessage,
301
+ }
302
+
303
+ async fn ollama_generate_with_system(
304
+ base_url: &str,
305
+ model: &str,
306
+ system: &str,
307
+ user: &str,
308
+ ) -> Result<String> {
309
+ let client = reqwest::Client::new();
310
+ let req = OllamaChatRequest {
311
+ model: model.to_string(),
312
+ messages: vec![
313
+ ChatMessage {
314
+ role: "system".to_string(),
315
+ content: system.to_string(),
316
+ },
317
+ ChatMessage {
318
+ role: "user".to_string(),
319
+ content: user.to_string(),
320
+ },
321
+ ],
322
+ stream: false,
323
+ };
324
+ let url = format!("{}/api/chat", base_url);
325
+ let resp = client
326
+ .post(&url)
327
+ .json(&req)
328
+ .timeout(Duration::from_secs(60))
329
+ .send()
330
+ .await
331
+ .context("Failed to contact Ollama")?;
332
+ let body: OllamaChatResponse = resp.json().await.context("Failed to parse Ollama response")?;
333
+ Ok(body.message.content)
334
+ }
335
+
336
+ async fn ollama_generate(base_url: &str, model: &str, prompt: &str) -> Result<String> {
337
+ let client = reqwest::Client::new();
338
+ #[derive(Debug, Serialize)]
339
+ struct GenerateRequest {
340
+ model: String,
341
+ prompt: String,
342
+ stream: bool,
343
+ }
344
+ #[derive(Debug, Deserialize)]
345
+ struct GenerateResponse {
346
+ response: String,
347
+ }
348
+ let req = GenerateRequest {
349
+ model: model.to_string(),
350
+ prompt: prompt.to_string(),
351
+ stream: false,
352
+ };
353
+ let url = format!("{}/api/generate", base_url);
354
+ let resp = client
355
+ .post(&url)
356
+ .json(&req)
357
+ .timeout(Duration::from_secs(30))
358
+ .send()
359
+ .await
360
+ .context("Failed to contact Ollama")?;
361
+ let body: GenerateResponse = resp.json().await.context("Failed to parse Ollama response")?;
362
+ Ok(body.response)
363
+ }
364
+
365
+ #[derive(Debug, Deserialize)]
366
+ pub struct OllamaTagsResponse {
367
+ pub models: Vec<OllamaModelInfo>,
368
+ }
369
+
370
+ #[derive(Debug, Deserialize)]
371
+ pub struct OllamaModelInfo {
372
+ pub name: String,
373
+ pub size: u64,
374
+ pub modified_at: String,
375
+ }
376
+
377
+ async fn list_ollama_models(base_url: &str) -> Result<Vec<OllamaModelInfo>> {
378
+ let url = format!("{}/api/tags", base_url);
379
+ let resp = reqwest::get(&url)
380
+ .await
381
+ .context("Failed to fetch models")?
382
+ .json::<OllamaTagsResponse>()
383
+ .await?;
384
+ Ok(resp.models)
385
+ }
386
+
387
+ // ---------- Sandbox ----------
388
+ fn normalize_path(path: &Path) -> PathBuf {
389
+ let mut components = Vec::new();
390
+ for comp in path.components() {
391
+ match comp {
392
+ std::path::Component::ParentDir => {
393
+ components.pop();
394
+ }
395
+ std::path::Component::Normal(c) => components.push(c),
396
+ _ => {}
397
+ }
398
+ }
399
+ let mut result = PathBuf::new();
400
+ for comp in components {
401
+ result.push(comp);
402
+ }
403
+ result
404
+ }
405
+
406
+ fn sanitize_path(root: &Path, relative: &str) -> Result<PathBuf> {
407
+ let full = root.join(relative);
408
+ if full.exists() {
409
+ let resolved = full.canonicalize().context("Failed to canonicalize path")?;
410
+ if !resolved.starts_with(root) {
411
+ anyhow::bail!("Access denied: path outside sandbox");
412
+ }
413
+ Ok(resolved)
414
+ } else {
415
+ let normalized = normalize_path(&full);
416
+ if !normalized.starts_with(root) {
417
+ anyhow::bail!("Access denied: path would be outside sandbox");
418
+ }
419
+ Ok(normalized)
420
+ }
421
+ }
422
+
423
+ // ---------- Safe command ----------
424
+ async fn run_safe_command(cmd: &str, args: &[&str], cwd: &PathBuf) -> Result<String> {
425
+ let allowed = ["ls", "cat", "echo", "git", "pwd"];
426
+ if !allowed.contains(&cmd) {
427
+ anyhow::bail!("Command not allowed: {}", cmd);
428
+ }
429
+ let output = Command::new(cmd)
430
+ .args(args)
431
+ .current_dir(cwd)
432
+ .output()
433
+ .await?;
434
+ let stdout = String::from_utf8(output.stdout)?;
435
+ let stderr = String::from_utf8(output.stderr)?;
436
+ if output.status.success() {
437
+ Ok(format!("{}{}", stdout, stderr))
438
+ } else {
439
+ anyhow::bail!("Command failed: {}", stderr)
440
+ }
441
+ }
442
+
443
+ // ---------- AppCommand ----------
444
+ #[derive(Debug)]
445
+ pub enum AppCommand {
446
+ Chat(String),
447
+ ConsolidateMemory,
448
+ WriteFile { path: String, content: String },
449
+ ReadFile { path: String },
450
+ ListModels,
451
+ SelectModel(String),
452
+ ListDir(String),
453
+ SearchFiles(String),
454
+ RunCommand(String),
455
+ GitStatus,
456
+ GitLog(usize),
457
+ GitCommit(String),
458
+ Quit,
459
+ }
460
+
461
+ // ---------- Command dispatcher ----------
462
+ async fn run_command(
463
+ cmd: AppCommand,
464
+ agent: Arc<Agent>,
465
+ config: Arc<RwLock<Config>>,
466
+ log_tx: mpsc::Sender<String>,
467
+ ) -> Result<()> {
468
+ match cmd {
469
+ AppCommand::Chat(prompt) => {
470
+ let _ = log_tx.send(format!("πŸ€– Thinking: {}", prompt)).await;
471
+ match agent.chat(&prompt).await {
472
+ Ok(reply) => {
473
+ let _ = log_tx.send(format!("πŸ’¬ {}", reply)).await;
474
+ if let Err(e) = agent.append_heartbeat(&prompt, &reply).await {
475
+ let _ = log_tx.send(format!("❌ Failed to log heartbeat: {e}")).await;
476
+ } else if let Err(e) = agent.update_bio_timestamp().await {
477
+ let _ = log_tx.send(format!("❌ Failed to update bio timestamp: {e}")).await;
478
+ }
479
+ }
480
+ Err(e) => {
481
+ let _ = log_tx.send(format!("❌ Ollama error: {e}")).await;
482
+ }
483
+ }
484
+ }
485
+ AppCommand::ConsolidateMemory => {
486
+ let _ = log_tx.send("🧠 Consolidating memory...".to_string()).await;
487
+ match agent.consolidate_memory().await {
488
+ Ok(_) => {
489
+ let _ = log_tx.send("βœ… Memory consolidated.".to_string()).await;
490
+ }
491
+ Err(e) => {
492
+ let _ = log_tx.send(format!("❌ Consolidation error: {e}")).await;
493
+ }
494
+ }
495
+ }
496
+ AppCommand::WriteFile { path, content } => {
497
+ let _ = log_tx.send(format!("✍️ Writing file: {}", path)).await;
498
+ let cfg = config.read().await;
499
+ let data_root = cfg.root_dir.join("data");
500
+ drop(cfg);
501
+ let full_path = sanitize_path(&data_root, &path)?;
502
+ if let Some(parent) = full_path.parent() {
503
+ tokio::fs::create_dir_all(parent).await?;
504
+ }
505
+ tokio::fs::write(&full_path, content).await?;
506
+ let _ = log_tx.send(format!("βœ… File written: {}", full_path.display())).await;
507
+
508
+ let auto_commit = config.read().await.git_auto_commit;
509
+ if auto_commit {
510
+ let repo_path = data_root;
511
+ let file_rel = path;
512
+ let msg = format!("Agent write: {}", file_rel);
513
+ let result = run_safe_command("git", &["add", &file_rel], &repo_path).await;
514
+ if let Err(e) = result {
515
+ let _ = log_tx.send(format!("⚠️ Git add failed: {e}")).await;
516
+ } else {
517
+ let result = run_safe_command("git", &["commit", "-m", &msg], &repo_path).await;
518
+ if let Err(e) = result {
519
+ let _ = log_tx.send(format!("⚠️ Git commit failed: {e}")).await;
520
+ } else {
521
+ let _ = log_tx.send(format!("βœ… Committed: {}", msg)).await;
522
+ }
523
+ }
524
+ }
525
+ }
526
+ AppCommand::ReadFile { path } => {
527
+ let _ = log_tx.send(format!("πŸ“– Reading file: {}", path)).await;
528
+ let cfg = config.read().await;
529
+ let data_root = cfg.root_dir.join("data");
530
+ drop(cfg);
531
+ let full_path = sanitize_path(&data_root, &path)?;
532
+ let content = fs::read_to_string(&full_path).await?;
533
+ let _ = log_tx.send(format!("πŸ“„ Content of {}:\n{}", full_path.display(), content)).await;
534
+ }
535
+ AppCommand::ListModels => {
536
+ let _ = log_tx.send("πŸ“¦ Fetching Ollama models...".to_string()).await;
537
+ let cfg = config.read().await;
538
+ match list_ollama_models(&cfg.ollama_url).await {
539
+ Ok(models) => {
540
+ for m in models {
541
+ let size_mb = m.size / (1024 * 1024);
542
+ let _ = log_tx.send(format!(" {} ({} MB, updated {})", m.name, size_mb, m.modified_at)).await;
543
+ }
544
+ }
545
+ Err(e) => {
546
+ let _ = log_tx.send(format!("❌ Failed to list models: {e}")).await;
547
+ }
548
+ }
549
+ }
550
+ AppCommand::SelectModel(model_name) => {
551
+ let _ = log_tx.send(format!("πŸ”§ Switching to model: {}", model_name)).await;
552
+ let mut cfg = config.write().await;
553
+ cfg.ollama_model = model_name.clone();
554
+ cfg.save(PathBuf::from("config.yaml").as_path()).await?;
555
+ let _ = log_tx.send(format!("βœ… Model switched to {}.", model_name)).await;
556
+ }
557
+ AppCommand::ListDir(path) => {
558
+ let cfg = config.read().await;
559
+ let data_root = cfg.root_dir.join("data");
560
+ drop(cfg);
561
+ let target = if path.is_empty() {
562
+ data_root
563
+ } else {
564
+ sanitize_path(&data_root, &path)?
565
+ };
566
+ let _ = log_tx.send(format!("πŸ“ Listing: {}", target.display())).await;
567
+ let entries = WalkDir::new(&target)
568
+ .min_depth(1)
569
+ .max_depth(1)
570
+ .into_iter()
571
+ .filter_map(|e| e.ok())
572
+ .map(|e| {
573
+ let typ = if e.file_type().is_dir() { "πŸ“" } else { "πŸ“„" };
574
+ format!("{} {}", typ, e.file_name().to_string_lossy())
575
+ })
576
+ .collect::<Vec<_>>();
577
+ if entries.is_empty() {
578
+ let _ = log_tx.send(" (empty)".to_string()).await;
579
+ } else {
580
+ for entry in entries {
581
+ let _ = log_tx.send(entry).await;
582
+ }
583
+ }
584
+ }
585
+ AppCommand::SearchFiles(query) => {
586
+ let _ = log_tx.send(format!("πŸ” Searching for: {}", query)).await;
587
+ let cfg = config.read().await;
588
+ let data_root = cfg.root_dir.join("data");
589
+ drop(cfg);
590
+ let re = Regex::new(&regex::escape(&query)).unwrap();
591
+ let walker = WalkDir::new(&data_root)
592
+ .into_iter()
593
+ .filter_map(|e| e.ok())
594
+ .filter(|e| e.file_type().is_file());
595
+ let mut matches = Vec::new();
596
+ for entry in walker {
597
+ if let Ok(content) = std::fs::read_to_string(entry.path()) {
598
+ if re.is_match(&content) {
599
+ matches.push(entry.path().strip_prefix(&data_root).unwrap_or(entry.path()).display().to_string());
600
+ }
601
+ }
602
+ }
603
+ if matches.is_empty() {
604
+ let _ = log_tx.send(" No matches found.".to_string()).await;
605
+ } else {
606
+ for m in matches {
607
+ let _ = log_tx.send(m).await;
608
+ }
609
+ }
610
+ }
611
+ AppCommand::RunCommand(cmd_line) => {
612
+ let parts: Vec<&str> = cmd_line.split_whitespace().collect();
613
+ if parts.is_empty() {
614
+ let _ = log_tx.send("Usage: /run <command> [args...]".to_string()).await;
615
+ return Ok(());
616
+ }
617
+ let cmd = parts[0];
618
+ let args = &parts[1..];
619
+ let cfg = config.read().await;
620
+ let cwd = cfg.root_dir.join("data");
621
+ drop(cfg);
622
+ let _ = log_tx.send(format!("πŸ–₯️ Running: {} {}", cmd, args.join(" "))).await;
623
+ match run_safe_command(cmd, args, &cwd).await {
624
+ Ok(output) => {
625
+ for line in output.lines() {
626
+ let _ = log_tx.send(line.to_string()).await;
627
+ }
628
+ }
629
+ Err(e) => {
630
+ let _ = log_tx.send(format!("❌ Command failed: {e}")).await;
631
+ }
632
+ }
633
+ }
634
+ AppCommand::GitStatus => {
635
+ let cfg = config.read().await;
636
+ let cwd = cfg.root_dir.join("data");
637
+ drop(cfg);
638
+ match run_safe_command("git", &["status", "--short"], &cwd).await {
639
+ Ok(output) => {
640
+ if output.is_empty() {
641
+ let _ = log_tx.send(" Working tree clean".to_string()).await;
642
+ } else {
643
+ for line in output.lines() {
644
+ let _ = log_tx.send(line.to_string()).await;
645
+ }
646
+ }
647
+ }
648
+ Err(e) => {
649
+ let _ = log_tx.send(format!("❌ Git status failed: {e}")).await;
650
+ }
651
+ }
652
+ }
653
+ AppCommand::GitLog(n) => {
654
+ let cfg = config.read().await;
655
+ let cwd = cfg.root_dir.join("data");
656
+ drop(cfg);
657
+ match run_safe_command("git", &["log", "-n", &n.to_string(), "--oneline"], &cwd).await {
658
+ Ok(output) => {
659
+ if output.is_empty() {
660
+ let _ = log_tx.send(" No commits yet".to_string()).await;
661
+ } else {
662
+ for line in output.lines() {
663
+ let _ = log_tx.send(line.to_string()).await;
664
+ }
665
+ }
666
+ }
667
+ Err(e) => {
668
+ let _ = log_tx.send(format!("❌ Git log failed: {e}")).await;
669
+ }
670
+ }
671
+ }
672
+ AppCommand::GitCommit(msg) => {
673
+ let cfg = config.read().await;
674
+ let cwd = cfg.root_dir.join("data");
675
+ drop(cfg);
676
+ let _ = log_tx.send(format!("πŸ“¦ Committing all changes: {}", msg)).await;
677
+ match run_safe_command("git", &["add", "-A"], &cwd).await {
678
+ Ok(_) => {
679
+ match run_safe_command("git", &["commit", "-m", &msg], &cwd).await {
680
+ Ok(output) => {
681
+ let _ = log_tx.send(format!("βœ… {}", output.trim())).await;
682
+ }
683
+ Err(e) => {
684
+ let _ = log_tx.send(format!("❌ Commit failed: {e}")).await;
685
+ }
686
+ }
687
+ }
688
+ Err(e) => {
689
+ let _ = log_tx.send(format!("❌ Add failed: {e}")).await;
690
+ }
691
+ }
692
+ }
693
+ AppCommand::Quit => {}
694
+ }
695
+ Ok(())
696
+ }
697
+
698
+ // ---------- AppState (TUI – no blocking) ----------
699
+ struct AppState {
700
+ input: String,
701
+ logs: VecDeque<String>,
702
+ config: Arc<RwLock<Config>>,
703
+ bio_path: PathBuf,
704
+ model_name: String,
705
+ max_log_lines: usize,
706
+ cmd_tx: mpsc::Sender<AppCommand>,
707
+ log_rx: mpsc::Receiver<String>,
708
+ }
709
+
710
+ impl AppState {
711
+ fn new(
712
+ config: Arc<RwLock<Config>>,
713
+ cmd_tx: mpsc::Sender<AppCommand>,
714
+ log_rx: mpsc::Receiver<String>,
715
+ bio_path: PathBuf,
716
+ initial_model: String,
717
+ ) -> Self {
718
+ Self {
719
+ input: String::new(),
720
+ logs: VecDeque::new(),
721
+ config,
722
+ bio_path,
723
+ model_name: initial_model,
724
+ max_log_lines: 200,
725
+ cmd_tx,
726
+ log_rx,
727
+ }
728
+ }
729
+
730
+ fn push_log(&mut self, line: String) {
731
+ if self.logs.len() >= self.max_log_lines {
732
+ self.logs.pop_front();
733
+ }
734
+ self.logs.push_back(line);
735
+ }
736
+
737
+ fn visible_logs(&self, height: usize) -> Vec<String> {
738
+ let skip = if self.logs.len() > height {
739
+ self.logs.len() - height
740
+ } else {
741
+ 0
742
+ };
743
+ self.logs.iter().skip(skip).cloned().collect()
744
+ }
745
+
746
+ fn drain_logs(&mut self) {
747
+ while let Ok(line) = self.log_rx.try_recv() {
748
+ self.push_log(line);
749
+ }
750
+ }
751
+
752
+ // Non‑blocking cache update – ignore any lock error
753
+ fn refresh_model_cache(&mut self) {
754
+ if let Ok(cfg) = self.config.try_read() {
755
+ self.model_name = cfg.ollama_model.clone();
756
+ }
757
+ }
758
+
759
+ fn handle_command(&mut self, cmd: &str) -> Option<AppCommand> {
760
+ let parts: Vec<&str> = cmd.trim().split_whitespace().collect();
761
+ if parts.is_empty() {
762
+ return None;
763
+ }
764
+ match parts[0] {
765
+ "/help" => {
766
+ let help_text = r#"Commands:
767
+ /help – Show this help
768
+ /bio – Display current bio.md
769
+ /consolidate – Force memory consolidation
770
+ /write_file <path> <content> – Write content to a file in data/ folder
771
+ /read_file <path> – Read a file from data/ folder
772
+ /model list – List all Ollama models
773
+ /model select <name> – Switch to another model
774
+ /list_dir [path] – List contents of data/ or subfolder
775
+ /search <query> – Search for text in all files under data/
776
+ /run <command> – Run a safe command (whitelisted: ls, cat, echo, git, pwd)
777
+ /git status – Show git status of data/ folder
778
+ /git log [n] – Show last n commits (default 10)
779
+ /git commit <msg> – Commit all changes in data/ folder
780
+ /quit or /exit – Exit RustyClaw"#;
781
+ for line in help_text.lines() {
782
+ self.push_log(line.to_string());
783
+ }
784
+ None
785
+ }
786
+ "/bio" => {
787
+ match std::fs::read_to_string(&self.bio_path) {
788
+ Ok(content) => {
789
+ for line in content.lines() {
790
+ self.push_log(line.to_string());
791
+ }
792
+ }
793
+ Err(e) => self.push_log(format!("❌ Error reading bio.md: {e}")),
794
+ }
795
+ None
796
+ }
797
+ "/consolidate" => {
798
+ self.push_log("Consolidating memory...".into());
799
+ Some(AppCommand::ConsolidateMemory)
800
+ }
801
+ "/write_file" => {
802
+ if parts.len() < 3 {
803
+ self.push_log("Usage: /write_file <path> <content>".into());
804
+ return None;
805
+ }
806
+ let path = parts[1].to_string();
807
+ let content = parts[2..].join(" ");
808
+ Some(AppCommand::WriteFile { path, content })
809
+ }
810
+ "/read_file" => {
811
+ if parts.len() < 2 {
812
+ self.push_log("Usage: /read_file <path>".into());
813
+ return None;
814
+ }
815
+ let path = parts[1].to_string();
816
+ Some(AppCommand::ReadFile { path })
817
+ }
818
+ "/model" => {
819
+ if parts.len() < 2 {
820
+ self.push_log("Usage: /model list | /model select <name>".into());
821
+ return None;
822
+ }
823
+ match parts[1] {
824
+ "list" => Some(AppCommand::ListModels),
825
+ "select" => {
826
+ if parts.len() < 3 {
827
+ self.push_log("Usage: /model select <model_name>".into());
828
+ None
829
+ } else {
830
+ Some(AppCommand::SelectModel(parts[2].to_string()))
831
+ }
832
+ }
833
+ _ => {
834
+ self.push_log("Unknown /model subcommand".into());
835
+ None
836
+ }
837
+ }
838
+ }
839
+ "/list_dir" => {
840
+ let path = if parts.len() > 1 { parts[1] } else { "" };
841
+ Some(AppCommand::ListDir(path.to_string()))
842
+ }
843
+ "/search" => {
844
+ if parts.len() < 2 {
845
+ self.push_log("Usage: /search <query>".into());
846
+ None
847
+ } else {
848
+ let query = parts[1..].join(" ");
849
+ Some(AppCommand::SearchFiles(query))
850
+ }
851
+ }
852
+ "/run" => {
853
+ if parts.len() < 2 {
854
+ self.push_log("Usage: /run <command> [args...]".into());
855
+ None
856
+ } else {
857
+ let full_cmd = parts[1..].join(" ");
858
+ Some(AppCommand::RunCommand(full_cmd))
859
+ }
860
+ }
861
+ "/git" => {
862
+ if parts.len() < 2 {
863
+ self.push_log("Usage: /git status | /git log [n] | /git commit <msg>".into());
864
+ return None;
865
+ }
866
+ match parts[1] {
867
+ "status" => Some(AppCommand::GitStatus),
868
+ "log" => {
869
+ let n = if parts.len() > 2 {
870
+ parts[2].parse::<usize>().unwrap_or(10)
871
+ } else {
872
+ 10
873
+ };
874
+ Some(AppCommand::GitLog(n))
875
+ }
876
+ "commit" => {
877
+ if parts.len() < 3 {
878
+ self.push_log("Usage: /git commit <message>".into());
879
+ None
880
+ } else {
881
+ let msg = parts[2..].join(" ");
882
+ Some(AppCommand::GitCommit(msg))
883
+ }
884
+ }
885
+ _ => {
886
+ self.push_log("Unknown /git subcommand".into());
887
+ None
888
+ }
889
+ }
890
+ }
891
+ "/quit" | "/exit" => Some(AppCommand::Quit),
892
+ _ => Some(AppCommand::Chat(cmd.to_string())),
893
+ }
894
+ }
895
+ }
896
+
897
+ // ---------- Worker ----------
898
+ async fn worker(
899
+ agent: Arc<Agent>,
900
+ config: Arc<RwLock<Config>>,
901
+ mut cmd_rx: mpsc::Receiver<AppCommand>,
902
+ log_tx: mpsc::Sender<String>,
903
+ ) {
904
+ while let Some(cmd) = cmd_rx.recv().await {
905
+ let result = run_command(cmd, agent.clone(), config.clone(), log_tx.clone()).await;
906
+ if let Err(e) = result {
907
+ let _ = log_tx.send(format!("❌ Command error: {e}")).await;
908
+ }
909
+ }
910
+ }
911
+
912
+ // ---------- REST API ----------
913
+ fn build_api(agent: Arc<Agent>) -> impl warp::Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
914
+ let bio_get = warp::path!("api" / "bio")
915
+ .and(warp::get())
916
+ .and_then(move || {
917
+ let agent = agent.clone();
918
+ async move {
919
+ match agent.read_bio().await {
920
+ Ok(content) => Ok::<_, warp::Rejection>(warp::reply::json(&serde_json::json!({"bio": content}))),
921
+ Err(e) => Ok(warp::reply::json(&serde_json::json!({"error": e.to_string()}))),
922
+ }
923
+ }
924
+ });
925
+
926
+ let health = warp::path!("health")
927
+ .and(warp::get())
928
+ .map(|| warp::reply::json(&serde_json::json!({"status": "ok"})));
929
+
930
+ bio_get.or(health)
931
+ }
932
+
933
+ // ---------- TUI rendering (permanent logo) ----------
934
+ fn ui(frame: &mut Frame, app: &AppState) {
935
+ let chunks = Layout::default()
936
+ .direction(Direction::Vertical)
937
+ .margin(1)
938
+ .constraints([
939
+ Constraint::Length(5),
940
+ Constraint::Length(3),
941
+ Constraint::Min(0),
942
+ Constraint::Length(3),
943
+ ])
944
+ .split(frame.size());
945
+
946
+ let logo_text = r#"
947
+ β–„β–– β–— β–œ
948
+ β–™β–˜β–Œβ–Œβ–›β–˜β–œβ–˜β–Œβ–Œβ–›β–˜β– β–€β–Œβ–Œβ–Œβ–Œ
949
+ β–Œβ–Œβ–™β–Œβ–„β–Œβ–β––β–™β–Œβ–™β––β–β––β–ˆβ–Œβ–šβ–šβ–˜
950
+ β–„β–Œ
951
+ 🦞 RustyClaw v0.6.0"#;
952
+ let logo = Paragraph::new(logo_text)
953
+ .block(Block::default().borders(Borders::NONE))
954
+ .style(Style::default().fg(Color::Rgb(205, 127, 50)).add_modifier(Modifier::BOLD))
955
+ .alignment(Alignment::Center);
956
+ frame.render_widget(logo, chunks[0]);
957
+
958
+ let header = Paragraph::new(Line::from(vec![
959
+ Span::styled("πŸ“„ bio.md active ", Style::default().fg(Color::Rgb(205, 127, 50)).add_modifier(Modifier::BOLD)),
960
+ Span::styled(format!("Model: {}", app.model_name), Style::default().fg(Color::Cyan)),
961
+ ]))
962
+ .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Rgb(205, 127, 50))))
963
+ .alignment(Alignment::Left);
964
+ frame.render_widget(header, chunks[1]);
965
+
966
+ let log_items: Vec<ListItem> = app
967
+ .visible_logs(chunks[2].height.saturating_sub(2) as usize)
968
+ .into_iter()
969
+ .map(|s| ListItem::new(Line::from(s)))
970
+ .collect();
971
+ let logs = List::new(log_items).block(
972
+ Block::default()
973
+ .borders(Borders::ALL)
974
+ .title("Logs (ESC to quit Β· /help for commands)")
975
+ .border_style(Style::default().fg(Color::Rgb(205, 127, 50))),
976
+ );
977
+ frame.render_widget(logs, chunks[2]);
978
+
979
+ let input = Paragraph::new(app.input.as_str())
980
+ .block(
981
+ Block::default()
982
+ .borders(Borders::ALL)
983
+ .title("Input (Enter to send)")
984
+ .border_style(Style::default().fg(Color::Rgb(205, 127, 50))),
985
+ )
986
+ .style(Style::default().fg(Color::White));
987
+ frame.render_widget(input, chunks[3]);
988
+
989
+ frame.set_cursor(
990
+ chunks[3].x + app.input.len() as u16 + 1,
991
+ chunks[3].y + 1,
992
+ );
993
+ }
994
+
995
+ // ---------- Main ----------
996
+ #[tokio::main]
997
+ async fn main() -> Result<()> {
998
+ let ollama_ok = reqwest::get("http://localhost:11434/api/tags")
999
+ .await
1000
+ .is_ok();
1001
+ if !ollama_ok {
1002
+ eprintln!("⚠️ Ollama not detected at http://localhost:11434");
1003
+ eprintln!(" Start Ollama with: ollama serve");
1004
+ eprintln!(" Pull a model: ollama pull qwen2.5:0.5b");
1005
+ }
1006
+
1007
+ let git_ok = tokio::process::Command::new("git")
1008
+ .arg("--version")
1009
+ .output()
1010
+ .await
1011
+ .is_ok();
1012
+ if !git_ok {
1013
+ eprintln!("⚠️ Git not found in PATH. Git commands will fail.");
1014
+ }
1015
+
1016
+ let config = Config::load(Path::new("config.yaml")).await.unwrap_or_default();
1017
+ let config = Arc::new(RwLock::new(config));
1018
+
1019
+ let log_file = {
1020
+ let cfg = config.read().await;
1021
+ cfg.root_dir.join("data/logs/app.log")
1022
+ };
1023
+ let logger = JsonLogger::new(log_file);
1024
+ logger.init_global()?;
1025
+ info!("RustyClaw v0.6.0 starting up");
1026
+
1027
+ let agent = Arc::new(Agent::new(config.clone()).await?);
1028
+ info!("Agent initialized, bio.md at {}", agent.bio_path().display());
1029
+
1030
+ let data_repo_path = {
1031
+ let cfg = config.read().await;
1032
+ cfg.root_dir.join("data")
1033
+ };
1034
+ if git_ok && !data_repo_path.join(".git").exists() {
1035
+ let result = run_safe_command("git", &["init"], &data_repo_path).await;
1036
+ if let Err(e) = result {
1037
+ warn!("Git init failed: {}", e);
1038
+ } else {
1039
+ info!("Git repo initialized at {}", data_repo_path.display());
1040
+ }
1041
+ }
1042
+
1043
+ let (cmd_tx, cmd_rx) = mpsc::channel::<AppCommand>(32);
1044
+ let (log_tx, log_rx) = mpsc::channel::<String>(256);
1045
+
1046
+ let worker_agent = agent.clone();
1047
+ let worker_config = config.clone();
1048
+ tokio::spawn(async move {
1049
+ worker(worker_agent, worker_config, cmd_rx, log_tx).await;
1050
+ });
1051
+
1052
+ let interval = {
1053
+ let cfg = config.read().await;
1054
+ Duration::from_secs(cfg.memory_sync_interval_secs)
1055
+ };
1056
+ let timer_cmd_tx = cmd_tx.clone();
1057
+ tokio::spawn(async move {
1058
+ let mut interval = time::interval(interval);
1059
+ loop {
1060
+ interval.tick().await;
1061
+ let _ = timer_cmd_tx.send(AppCommand::ConsolidateMemory).await;
1062
+ }
1063
+ });
1064
+
1065
+ let api_agent = agent.clone();
1066
+ let api_port = {
1067
+ let cfg = config.read().await;
1068
+ cfg.api_port
1069
+ };
1070
+ tokio::spawn(async move {
1071
+ let api = build_api(api_agent);
1072
+ info!("REST API listening on :{}", api_port);
1073
+ warp::serve(api).run(([127, 0, 0, 1], api_port)).await;
1074
+ });
1075
+
1076
+ crossterm::terminal::enable_raw_mode().unwrap();
1077
+ let backend = ratatui::backend::CrosstermBackend::new(std::io::stdout());
1078
+ let mut terminal = ratatui::Terminal::new(backend).unwrap();
1079
+ crossterm::execute!(
1080
+ std::io::stderr(),
1081
+ crossterm::terminal::EnterAlternateScreen,
1082
+ crossterm::event::EnableMouseCapture,
1083
+ )
1084
+ .ok();
1085
+
1086
+ let (bio_path, initial_model) = {
1087
+ let cfg = config.read().await;
1088
+ (cfg.bio_file.clone(), cfg.ollama_model.clone())
1089
+ };
1090
+ let mut app = AppState::new(config.clone(), cmd_tx.clone(), log_rx, bio_path, initial_model);
1091
+ app.push_log("πŸ¦€ Welcome to RustyClaw v0.6.0!".into());
1092
+ app.push_log("πŸ“„ bio.md loaded as persistent memory.".into());
1093
+ if !ollama_ok {
1094
+ app.push_log("⚠️ Ollama not running! Please start it: ollama serve".into());
1095
+ } else {
1096
+ app.push_log("βœ… Ollama detected.".into());
1097
+ }
1098
+ if !git_ok {
1099
+ app.push_log("⚠️ Git not found. Git commands will fail.".into());
1100
+ } else {
1101
+ app.push_log("βœ… Git detected.".into());
1102
+ }
1103
+ app.push_log(format!("🌐 REST API: http://127.0.0.1:{}/api/bio", api_port));
1104
+ app.push_log("πŸ“ Data folder is a Git repo (auto‑commits after writes)".into());
1105
+ app.push_log("πŸ’‘ Try /help to see all commands.".into());
1106
+
1107
+ loop {
1108
+ app.drain_logs();
1109
+ app.refresh_model_cache();
1110
+
1111
+ terminal.draw(|f| ui(f, &app))?;
1112
+
1113
+ if event::poll(Duration::from_millis(100))? {
1114
+ if let Event::Key(key) = event::read()? {
1115
+ if key.kind == KeyEventKind::Press {
1116
+ match key.code {
1117
+ KeyCode::Esc => {
1118
+ let _ = cmd_tx.send(AppCommand::Quit).await;
1119
+ break;
1120
+ }
1121
+ KeyCode::Enter => {
1122
+ let cmd = app.input.trim().to_string();
1123
+ app.input.clear();
1124
+ if cmd.is_empty() {
1125
+ continue;
1126
+ }
1127
+ app.push_log(format!("> {}", cmd));
1128
+ if let Some(worker_cmd) = app.handle_command(&cmd) {
1129
+ match worker_cmd {
1130
+ AppCommand::Quit => {
1131
+ let _ = cmd_tx.send(AppCommand::Quit).await;
1132
+ break;
1133
+ }
1134
+ other => {
1135
+ let _ = cmd_tx.send(other).await;
1136
+ }
1137
+ }
1138
+ }
1139
+ }
1140
+ KeyCode::Char(c) => app.input.push(c),
1141
+ KeyCode::Backspace => {
1142
+ app.input.pop();
1143
+ }
1144
+ _ => {}
1145
+ }
1146
+ }
1147
+ }
1148
+ }
1149
+ }
1150
+
1151
+ crossterm::terminal::disable_raw_mode().unwrap();
1152
+ crossterm::execute!(
1153
+ std::io::stderr(),
1154
+ crossterm::terminal::LeaveAlternateScreen,
1155
+ crossterm::event::DisableMouseCapture,
1156
+ )
1157
+ .ok();
1158
+
1159
+ info!("RustyClaw shut down cleanly");
1160
+ Ok(())
1161
+ }
start.sh ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # start.sh – RustyClaw v0.6.0 launcher
3
+
4
+ set -e
5
+
6
+ RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
7
+ BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
8
+
9
+ echo -e "${BOLD}${CYAN}"
10
+ cat << 'EOF'
11
+ β–„β–– β–— β–œ
12
+ β–™β–˜β–Œβ–Œβ–›β–˜β–œβ–˜β–Œβ–Œβ–›β–˜β– β–€β–Œβ–Œβ–Œβ–Œ
13
+ β–Œβ–Œβ–™β–Œβ–„β–Œβ–β––β–™β–Œβ–™β––β–β––β–ˆβ–Œβ–šβ–šβ–˜
14
+ β–„β–Œ
15
+ EOF
16
+ echo -e "${NC}"
17
+
18
+ # Check Cargo
19
+ if ! command -v cargo &>/dev/null; then
20
+ echo -e "${RED}❌ Cargo not found. Install Rust: https://rustup.rs/${NC}"
21
+ exit 1
22
+ fi
23
+ echo -e "${GREEN}βœ… $(cargo --version)${NC}"
24
+
25
+ # Ensure directories
26
+ mkdir -p src data logs
27
+
28
+ # Move main.rs if needed
29
+ if [ -f "main.rs" ] && [ ! -f "src/main.rs" ]; then
30
+ echo -e "${YELLOW}⚠️ Moving main.rs β†’ src/main.rs${NC}"
31
+ mv main.rs src/main.rs
32
+ fi
33
+
34
+ if [ ! -f "src/main.rs" ]; then
35
+ echo -e "${RED}❌ src/main.rs not found.${NC}"
36
+ exit 1
37
+ fi
38
+
39
+ # Ollama check
40
+ if curl -sf http://localhost:11434/api/tags >/dev/null 2>&1; then
41
+ MODEL_COUNT=$(curl -s http://localhost:11434/api/tags | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('models',[])))" 2>/dev/null || echo "?")
42
+ echo -e "${GREEN}βœ… Ollama running β€” ${MODEL_COUNT} model(s) available${NC}"
43
+ else
44
+ echo -e "${YELLOW}⚠️ Ollama not detected at localhost:11434${NC}"
45
+ echo -e "${YELLOW} Start with: ollama serve${NC}"
46
+ echo -e "${YELLOW} Pull model: ollama pull qwen2.5:0.5b${NC}"
47
+ echo ""
48
+ read -rp " Continue anyway? (y/N) " reply; echo
49
+ [[ "$reply" =~ ^[Yy]$ ]] || exit 1
50
+ fi
51
+
52
+ # Build
53
+ BINARY="./target/release/rustyclaw"
54
+ FORCE_REBUILD="${1:-}"
55
+
56
+ needs_build=false
57
+ [ ! -f "$BINARY" ] && needs_build=true
58
+ [ "src/main.rs" -nt "$BINARY" ] 2>/dev/null && needs_build=true
59
+ [ "Cargo.toml" -nt "$BINARY" ] 2>/dev/null && needs_build=true
60
+ [ "$FORCE_REBUILD" = "--rebuild" ] && needs_build=true
61
+
62
+ if $needs_build; then
63
+ echo -e "${BLUE}πŸ”¨ Building RustyClaw (release)…${NC}"
64
+ cargo build --release
65
+ echo -e "${GREEN}βœ… Build complete${NC}"
66
+ else
67
+ echo -e "${CYAN}⚑ Binary up-to-date (./start.sh --rebuild to force)${NC}"
68
+ fi
69
+
70
+ # Launch
71
+ echo ""
72
+ echo -e "${GREEN}βš™οΈ Launching RustyClaw…${NC}"
73
+ echo -e "${CYAN} ESC or /quit to exit β”‚ /help for commands${NC}"
74
+ echo -e "${CYAN} REST API: http://127.0.0.1:3030/health${NC}"
75
+ echo -e "${CYAN} Persona: ~/.rustyclaw/bio.md${NC}"
76
+ echo -e "${CYAN} Files: ~/.rustyclaw/data/ (Git repo)${NC}"
77
+ echo ""
78
+ exec "$BINARY"