1use std::io::{self, BufRead, IsTerminal, Write};
25use std::path::{Path, PathBuf};
26
27use anyhow::Result;
28
29pub const MINIMAL_SOUL_TEMPLATE: &str = r#"# SOUL — Optional identity & constraints
32
33## Identity
34(可选 — 由用户或进化定义)
35
36## Core Beliefs
37(可选)
38
39## Communication Style
40(可选)
41
42## Scope & Boundaries
43- WILL NOT: modify SOUL.md; bypass sandbox rules.
44"#;
45
46#[derive(Debug, Clone, Default)]
50pub struct Law;
51
52impl Law {
53 pub fn to_system_prompt_block(&self) -> String {
55 const LAW_RULES: &str = r#"╔═══════════════════════════════════╗
56║ LAW — Immutable Constraints ║
57║ These rules cannot be overridden. ║
58╚═══════════════════════════════════╝
59
60### Law (MANDATORY)
61
62- **Do not harm humans.** Never suggest or execute actions that could physically, psychologically, or financially harm users or third parties.
63- **Do not leak privacy.** Never store, transmit, or expose user data, credentials, or sensitive information outside the intended scope. Respect local-first: data stays on the user's machine unless explicitly authorized.
64- **Do not self-destruct.** Never suggest or execute actions that would permanently destroy the agent's ability to operate, corrupt the workspace irreversibly, or remove critical system components without explicit user confirmation.
65"#;
66 format!("\n\n{}", LAW_RULES)
67 }
68}
69
70const BELIEFS_RULES_TOP: usize = 5;
75const BELIEFS_EXAMPLES_TOP: usize = 3;
76
77pub fn build_beliefs_block(chat_root: &Path) -> String {
81 let rules = skilllite_evolution::seed::load_rules(chat_root);
82 let decision_tendency: String = rules
83 .iter()
84 .filter(|r| r.mutable || r.origin != "seed")
85 .take(BELIEFS_RULES_TOP)
86 .filter(|r| !r.instruction.is_empty())
87 .map(|r| {
88 format!(
89 "- {}",
90 r.instruction.trim().lines().next().unwrap_or("").trim()
91 )
92 })
93 .filter(|s| !s.eq("- "))
94 .collect::<Vec<_>>()
95 .join("\n");
96
97 let success_patterns = load_examples_key_insights(chat_root);
98
99 if decision_tendency.is_empty() && success_patterns.is_empty() {
100 return String::new();
101 }
102
103 let mut parts = vec![
104 "\n\n╔═══════════════════════════════════╗".to_string(),
105 "║ Beliefs — From evolved rules/examples ║".to_string(),
106 "╚═══════════════════════════════════╝".to_string(),
107 ];
108 if !decision_tendency.is_empty() {
109 parts.push(format!(
110 "\n### Decision Tendency (from rules)\n{}",
111 decision_tendency
112 ));
113 }
114 if !success_patterns.is_empty() {
115 parts.push(format!(
116 "\n### Success Patterns (from examples)\n{}",
117 success_patterns
118 ));
119 }
120 parts.push("═══════════════════════════════════".to_string());
121 parts.join("\n")
122}
123
124fn load_examples_key_insights(chat_root: &Path) -> String {
125 let path = chat_root.join("prompts").join("examples.json");
126 if !path.exists() {
127 return String::new();
128 }
129 let content = match skilllite_fs::read_file(&path) {
130 Ok(c) => c,
131 Err(_) => return String::new(),
132 };
133 #[derive(serde::Deserialize)]
134 struct Ex {
135 key_insight: Option<String>,
136 }
137 let arr: Vec<Ex> = match serde_json::from_str(&content) {
138 Ok(a) => a,
139 Err(_) => return String::new(),
140 };
141 arr.iter()
142 .take(BELIEFS_EXAMPLES_TOP)
143 .filter_map(|e| e.key_insight.as_deref())
144 .filter(|s| !s.is_empty())
145 .map(|s| format!("- {}", s.trim()))
146 .collect::<Vec<_>>()
147 .join("\n")
148}
149
150#[derive(Debug, Clone)]
157pub struct Soul {
158 pub identity: String,
160 pub core_beliefs: String,
162 pub communication_style: String,
164 pub scope_and_boundaries: String,
166 pub source_path: String,
168}
169
170impl Soul {
171 pub fn parse(content: &str, source_path: &str) -> Self {
176 #[derive(PartialEq)]
177 enum Section {
178 None,
179 Identity,
180 CoreBeliefs,
181 CommunicationStyle,
182 ScopeAndBoundaries,
183 Other,
184 }
185
186 let mut identity = String::new();
187 let mut core_beliefs = String::new();
188 let mut communication_style = String::new();
189 let mut scope_and_boundaries = String::new();
190 let mut current = Section::None;
191
192 for line in content.lines() {
193 let trimmed = line.trim();
194 if let Some(rest) = trimmed.strip_prefix("## ") {
195 let heading = rest.trim().to_lowercase();
196 current = match heading.as_str() {
197 "identity" => Section::Identity,
198 "core beliefs" | "core_beliefs" | "corebeliefs" => Section::CoreBeliefs,
199 "communication style" | "communication_style" => Section::CommunicationStyle,
200 "scope & boundaries"
201 | "scope and boundaries"
202 | "scope_and_boundaries"
203 | "scope" => Section::ScopeAndBoundaries,
204 _ => Section::Other,
205 };
206 continue;
207 }
208
209 let target = match current {
210 Section::Identity => Some(&mut identity),
211 Section::CoreBeliefs => Some(&mut core_beliefs),
212 Section::CommunicationStyle => Some(&mut communication_style),
213 Section::ScopeAndBoundaries => Some(&mut scope_and_boundaries),
214 _ => None,
215 };
216 if let Some(buf) = target {
217 buf.push_str(line);
218 buf.push('\n');
219 }
220 }
221
222 Soul {
223 identity: identity.trim().to_string(),
224 core_beliefs: core_beliefs.trim().to_string(),
225 communication_style: communication_style.trim().to_string(),
226 scope_and_boundaries: scope_and_boundaries.trim().to_string(),
227 source_path: source_path.to_string(),
228 }
229 }
230
231 pub fn load(path: &Path) -> Result<Self> {
233 let content = skilllite_fs::read_file(path)
234 .map_err(|e| anyhow::anyhow!("Failed to read SOUL.md at {}: {}", path.display(), e))?;
235 Ok(Self::parse(&content, &path.to_string_lossy()))
236 }
237
238 pub fn auto_load(explicit_path: Option<&str>, workspace: &str) -> Option<Self> {
242 if let Some(p) = explicit_path {
244 let path = PathBuf::from(p);
245 match Self::load(&path) {
246 Ok(soul) => {
247 tracing::info!("SOUL loaded from explicit path: {}", p);
248 return Some(soul);
249 }
250 Err(e) => {
251 tracing::warn!("Failed to load SOUL from explicit path {}: {}", p, e);
252 return None;
253 }
254 }
255 }
256
257 let ws_soul = Path::new(workspace).join(".skilllite").join("SOUL.md");
259 if ws_soul.exists() {
260 match Self::load(&ws_soul) {
261 Ok(soul) => {
262 tracing::info!("SOUL loaded from workspace: {}", ws_soul.display());
263 return Some(soul);
264 }
265 Err(e) => {
266 tracing::warn!("Failed to load workspace SOUL: {}", e);
267 }
268 }
269 }
270
271 if let Some(home) = dirs::home_dir() {
273 let global_soul = home.join(".skilllite").join("SOUL.md");
274 if global_soul.exists() {
275 match Self::load(&global_soul) {
276 Ok(soul) => {
277 tracing::info!("SOUL loaded from global: {}", global_soul.display());
278 return Some(soul);
279 }
280 Err(e) => {
281 tracing::warn!("Failed to load global SOUL: {}", e);
282 }
283 }
284 }
285 }
286
287 None
288 }
289
290 pub fn offer_bootstrap_soul_if_missing(workspace: &str, explicit_path: Option<&str>) -> bool {
295 if explicit_path.is_some() {
296 return false;
297 }
298 if Self::auto_load(None, workspace).is_some() {
299 return false;
300 }
301 if !io::stdin().is_terminal() {
302 return false;
303 }
304 let path = Path::new(workspace).join(".skilllite").join("SOUL.md");
305 eprint!(
306 "No SOUL.md found. Create minimal template at {}? [y/N] ",
307 path.display()
308 );
309 let _ = io::stderr().flush();
310 let mut line = String::new();
311 if io::stdin().lock().read_line(&mut line).is_err() {
312 return false;
313 }
314 let trimmed = line.trim().to_lowercase();
315 if trimmed != "y" && trimmed != "yes" {
316 return false;
317 }
318 if let Some(parent) = path.parent() {
319 let _ = skilllite_fs::create_dir_all(parent);
320 }
321 skilllite_fs::write_file(&path, MINIMAL_SOUL_TEMPLATE).is_ok()
322 }
323
324 pub fn to_planning_scope_block(&self) -> Option<String> {
329 if self.scope_and_boundaries.is_empty() {
330 return None;
331 }
332 Some(format!(
333 "\n## SOUL Scope & Boundaries (MANDATORY for planning)\n\
334 When generating the task list, you MUST respect these boundaries.\n\
335 ONLY plan tasks that fall within scope. Do NOT plan any task that violates \"Will Not Do\" / out-of-scope rules.\n\n\
336 {}\n",
337 self.scope_and_boundaries.trim()
338 ))
339 }
340
341 pub fn to_system_prompt_block(&self) -> String {
345 let mut parts = vec![
346 "\n\n╔═══════════════════════════════════╗".to_string(),
347 format!("║ SOUL (source: {})", self.source_path),
348 "║ This document defines your identity and non-negotiable constraints.".to_string(),
349 "║ It is read-only — you must never modify or override any of its rules.".to_string(),
350 "╚═══════════════════════════════════╝".to_string(),
351 ];
352
353 if !self.identity.is_empty() {
354 parts.push(format!("\n### Identity\n{}", self.identity));
355 }
356 if !self.core_beliefs.is_empty() {
357 parts.push(format!("\n### Core Beliefs\n{}", self.core_beliefs));
358 }
359 if !self.communication_style.is_empty() {
360 parts.push(format!(
361 "\n### Communication Style\n{}",
362 self.communication_style
363 ));
364 }
365 if !self.scope_and_boundaries.is_empty() {
366 parts.push(format!(
367 "\n### Scope & Boundaries\n{}",
368 self.scope_and_boundaries
369 ));
370 }
371
372 parts.push("═══════════════════════════════════".to_string());
373 parts.join("\n")
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
382 fn test_parse_all_sections() {
383 let soul = Soul::parse(MINIMAL_SOUL_TEMPLATE, "test/SOUL.md");
384 assert!(soul.identity.contains("可选"));
385 assert!(soul.core_beliefs.contains("可选"));
386 assert!(soul.communication_style.contains("可选"));
387 assert!(soul.scope_and_boundaries.contains("WILL NOT"));
388 assert_eq!(soul.source_path, "test/SOUL.md");
389 }
390
391 #[test]
392 fn test_parse_empty_content() {
393 let soul = Soul::parse("", "empty.md");
394 assert!(soul.identity.is_empty());
395 assert!(soul.core_beliefs.is_empty());
396 }
397
398 #[test]
399 fn test_to_system_prompt_block_contains_sections() {
400 let soul = Soul::parse(MINIMAL_SOUL_TEMPLATE, "SOUL.md");
401 let block = soul.to_system_prompt_block();
402 assert!(block.contains("SOUL"));
403 assert!(block.contains("Identity"));
404 assert!(block.contains("Scope & Boundaries"));
405 assert!(block.contains("read-only"));
406 }
407
408 #[test]
409 fn test_to_planning_scope_block() {
410 let soul = Soul::parse(MINIMAL_SOUL_TEMPLATE, "SOUL.md");
411 let block = soul.to_planning_scope_block().unwrap();
412 assert!(block.contains("SOUL Scope & Boundaries"));
413 assert!(block.contains("MANDATORY"));
414 assert!(block.contains("WILL NOT"));
415 assert!(block.contains("modify SOUL.md"));
416
417 let empty_soul = Soul::parse("", "empty.md");
418 assert!(empty_soul.to_planning_scope_block().is_none());
419 }
420
421 #[test]
422 fn test_sample_soul_parses_all_sections() {
423 let soul = Soul::parse(MINIMAL_SOUL_TEMPLATE, "test/SOUL.md");
424 assert!(!soul.identity.is_empty(), "sample has identity section");
425 assert!(
426 !soul.core_beliefs.is_empty(),
427 "sample has core_beliefs section"
428 );
429 assert!(
430 !soul.communication_style.is_empty(),
431 "sample has communication_style section"
432 );
433 assert!(
434 !soul.scope_and_boundaries.is_empty(),
435 "sample has scope_and_boundaries"
436 );
437 }
438
439 #[test]
440 fn test_law_prompt_contains_mandatory_rules() {
441 let law = Law;
442 let block = law.to_system_prompt_block();
443 assert!(block.contains("LAW"));
444 assert!(block.contains("Do not harm humans"));
445 assert!(block.contains("Do not leak privacy"));
446 assert!(block.contains("Do not self-destruct"));
447 }
448
449 #[test]
450 fn test_build_beliefs_block_empty_when_rules_and_examples_empty() {
451 let tmp = tempfile::tempdir().unwrap();
452 let prompts_dir = tmp.path().join("prompts");
453 std::fs::create_dir_all(&prompts_dir).unwrap();
454 std::fs::write(prompts_dir.join("rules.json"), "[]").unwrap();
455 let block = build_beliefs_block(tmp.path());
456 assert!(block.is_empty());
457 }
458
459 #[test]
460 fn test_build_beliefs_block_from_rules() {
461 let tmp = tempfile::tempdir().unwrap();
462 let prompts_dir = tmp.path().join("prompts");
463 std::fs::create_dir_all(&prompts_dir).unwrap();
464 let rules = r#"[{"id":"r1","instruction":"Use read_file before edit.","mutable":true}]"#;
465 std::fs::write(prompts_dir.join("rules.json"), rules).unwrap();
466 let block = build_beliefs_block(tmp.path());
467 assert!(block.contains("Beliefs"));
468 assert!(block.contains("Decision Tendency"));
469 assert!(block.contains("Use read_file before edit"));
470 }
471
472 #[test]
473 fn test_build_beliefs_block_from_examples() {
474 let tmp = tempfile::tempdir().unwrap();
475 let prompts_dir = tmp.path().join("prompts");
476 std::fs::create_dir_all(&prompts_dir).unwrap();
477 let examples = r#"[{"id":"e1","task_pattern":"x","plan_template":"y","key_insight":"Read then edit."}]"#;
478 std::fs::write(prompts_dir.join("examples.json"), examples).unwrap();
479 let block = build_beliefs_block(tmp.path());
480 assert!(block.contains("Beliefs"));
481 assert!(block.contains("Success Patterns"));
482 assert!(block.contains("Read then edit"));
483 }
484}