//! Learning loop (docs/design/26-learning.md): the `remember` tool appends //! durable notes; `propose_skill` queues skill drafts for human promotion. //! Proposals never enter discovery by themselves — promotion is an explicit //! human action over HTTP or CLI. use std::path::{Path, PathBuf}; use serde_json::Value; use crate::memory; const KINDS: [&str; 5] = ["fact", "decision", "preference", "reference", "invariant"]; // ---- remember --------------------------------------------------------------- pub struct RememberTool { pub sessions_home: PathBuf, pub cwd: PathBuf, pub session_id: String, } #[async_trait::async_trait] impl vak_tools::Tool for RememberTool { fn name(&self) -> &str { "remember" } fn serves(&self) -> &'static [&'static str] { &["memory"] } fn description(&self) -> &str { "Persist a durable note about this workspace for FUTURE sessions \ (decisions, facts, preferences, pointers). Use sparingly for things \ worth remembering after this conversation ends — not transient \ details. Notes are recalled via session_search and are visible to \ the user, who can edit them." } fn schema(&self) -> Value { serde_json::json!({ "type": "object", "properties": { "note": {"type": "string", "description": "The content to persist"}, "kind": {"type": "string", "enum": KINDS.to_vec(), "description": "One of fact/decision/preference/reference/invariant (default fact)"}, "tag": {"type": "string", "description": "Short slug for grouping, e.g. 'deploy-rollbacks'"} }, "required": ["note"] }) } async fn execute(&self, args: &Value, _ctx: &vak_tools::ToolContext) -> vak_tools::ToolOutput { let Some(note) = args.get("note").and_then(Value::as_str).map(str::trim) else { return vak_tools::ToolOutput::error("missing required argument 'note'"); }; let kind = args.get("kind").and_then(Value::as_str).unwrap_or("fact"); if !KINDS.contains(&kind) { return vak_tools::ToolOutput::error(format!( "unknown kind '{kind}'; expected one of {KINDS:?}" )); } let tag = args.get("tag").and_then(Value::as_str).unwrap_or(""); match memory::append_note( &self.sessions_home, &self.cwd, kind, tag, &self.session_id, note, ) { Ok(_) => vak_tools::ToolOutput::ok(format!( "remembered ({kind}{tag_suffix}). It will surface in future session_search queries.", tag_suffix = if tag.is_empty() { String::new() } else { format!(", tag '{tag}'") } )), Err(e) => vak_tools::ToolOutput::error(format!("could not persist note: {e}")), } } fn claims(&self, _args: &Value) -> vak_tools::ResourceClaims { vak_tools::ResourceClaims { exclusive: true, read_only: false, paths: vec![], } } } // ---- propose_skill ---------------------------------------------------------- pub struct SkillProposal { pub id: String, pub name: String, pub description: String, pub path: PathBuf, } fn proposals_dir(home: &Path, cwd: &Path) -> PathBuf { home.join("skill-proposals").join(memory::hash_cwd(cwd)) } pub struct ProposeSkillTool { pub sessions_home: PathBuf, pub cwd: PathBuf, pub session_id: String, } #[async_trait::async_trait] impl vak_tools::Tool for ProposeSkillTool { fn name(&self) -> &str { "propose_skill" } fn serves(&self) -> &'static [&'static str] { &["memory"] } fn description(&self) -> &str { "Draft a reusable SKILL from something learned this session (a \ procedure that worked, a gotcha and its fix). Goes to a review \ queue — it becomes available to future runs ONLY after the user \ promotes it. Do not propose one-off steps." } fn schema(&self) -> Value { serde_json::json!({ "type": "object", "properties": { "name": {"type": "string", "description": "kebab-case identifier, e.g. 'rotate-release-tags'"}, "description": {"type": "string", "description": "One line: what it is for and when to use it"}, "instructions": {"type": "string", "description": "Markdown procedure the future agent should follow"} }, "required": ["name", "description", "instructions"] }) } async fn execute(&self, args: &Value, _ctx: &vak_tools::ToolContext) -> vak_tools::ToolOutput { let Some(name) = sanitize_name(args.get("name").and_then(Value::as_str).unwrap_or("")) else { return vak_tools::ToolOutput::error( "'name' must be kebab-case (lowercase letters, digits, dashes)", ); }; let Some(description) = args .get("description") .and_then(Value::as_str) .map(str::trim) else { return vak_tools::ToolOutput::error("missing required argument 'description'"); }; let Some(instructions) = args .get("instructions") .and_then(Value::as_str) .map(str::trim) else { return vak_tools::ToolOutput::error("missing required argument 'instructions'"); }; if description.is_empty() || instructions.is_empty() { return vak_tools::ToolOutput::error( "'description' and 'instructions' must not be empty", ); } let id = uuid::Uuid::now_v7().simple().to_string(); let dir = proposals_dir(&self.sessions_home, &self.cwd); if let Err(e) = std::fs::create_dir_all(&dir) { return vak_tools::ToolOutput::error(format!("create proposals dir: {e}")); } let dup_line = match duplicate_of( &name, prose(instructions), &accepted_skill_bodies(&self.sessions_home, &self.cwd), ) { Some(dup) => format!("{DUPLICATE_KEY}: \"{dup}\"\n"), None => String::new(), }; let body = format!( "---\nname: \"{name}\"\ndescription: \"{desc}\"\n{dup_line}---\n\n{instr}\n\n\n", desc = description.replace('"', "'"), instr = instructions, sid = self.session_id, ts = chrono::Utc::now().to_rfc3339(), ); let path = dir.join(format!("{id}.md")); if let Err(e) = std::fs::write(&path, body) { return vak_tools::ToolOutput::error(format!("write proposal: {e}")); } vak_tools::ToolOutput::ok(format!( "skill '{name}' queued for review as proposal {id}. It will NOT be \ available until the user promotes it." )) } fn claims(&self, _args: &Value) -> vak_tools::ResourceClaims { vak_tools::ResourceClaims { exclusive: true, read_only: false, paths: vec![], } } } /// Kebab-case enforcement: lowercase letters/digits/dashes only, at least /// one letter, no leading/trailing dash. pub fn sanitize_name(raw: &str) -> Option { let name = raw.trim().to_lowercase(); if name.is_empty() || name.len() > 64 || !name .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') || name.starts_with('-') || name.ends_with('-') || !name.chars().any(|c| c.is_ascii_alphabetic()) { return None; } Some(name) } // ---- Review queue API ------------------------------------------------------- fn list_proposals_in_dir(dir: &Path, home: &Path, cwd: &Path) -> Vec { let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new(); }; let mut found = Vec::new(); for entry in entries.flatten() { let path = entry.path(); let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(String::from) else { continue; }; let Some(skill) = crate::skills::parse(&path) else { continue; }; found.push((id, path, skill)); } let mut out = Vec::with_capacity(found.len()); if !found.is_empty() { let accepted = accepted_skill_bodies(home, cwd); for (id, path, skill) in found { let tag = screen_proposal(&path, &skill.name, &accepted); out.push(SkillProposal { id, name: skill.name, description: with_duplicate_note(&skill.description, tag.as_deref()), path, }); } } out } /// Pending drafts, newest first. When a proposal screens as a near copy of /// an accepted skill, the returned `description` carries a /// `[duplicate-of: ]` suffix (every review surface renders the /// description) and the flag persists as a `duplicate-of:` frontmatter line /// so hand edits and later listings agree. pub fn list_proposals(home: &Path, cwd: &Path) -> Vec { let mut proposals = list_proposals_in_dir(&proposals_dir(home, cwd), home, cwd); if proposals.is_empty() { let agents_dir = home.join("agents"); if let Ok(entries) = std::fs::read_dir(&agents_dir) { for entry in entries.flatten() { let p = entry.path(); if p.is_dir() { let agent_props = list_proposals_in_dir(&proposals_dir(&p, cwd), home, cwd); proposals.extend(agent_props); } } } } proposals.sort_by(|a, b| b.id.cmp(&a.id)); proposals } /// Install a proposal into user-level discovery. Refuses to silently /// overwrite an existing skill of the same name. pub fn promote(home: &Path, cwd: &Path, id: &str) -> Result { let proposals = list_proposals(home, cwd); let p = proposals .iter() .find(|p| p.id == id) .ok_or_else(|| format!("no proposal '{id}'"))?; let target_dir = home.join("skills").join(&p.name); let target = target_dir.join("SKILL.md"); if target.exists() { return Err(format!( "a skill named '{}' already exists at {}; remove or rename it first", p.name, target.display() )); } std::fs::create_dir_all(&target_dir).map_err(|e| format!("create skill dir: {e}"))?; std::fs::copy(&p.path, &target).map_err(|e| format!("install skill: {e}"))?; std::fs::remove_file(&p.path).map_err(|e| format!("remove pending file: {e}"))?; Ok(p.name.clone()) } pub fn reject(home: &Path, cwd: &Path, id: &str) -> Result<(), String> { let proposals = list_proposals(home, cwd); let p = proposals .iter() .find(|p| p.id == id) .ok_or_else(|| format!("no proposal '{id}'"))?; std::fs::remove_file(&p.path).map_err(|e| format!("remove proposal: {e}")) } // ---- duplicate screening (docs/design/29-personal-os.md P5) ----------------- /// Same bar as reflection's note dedup — skill pollution is the same failure /// mode at proposal time. const SKILL_DEDUP_THRESHOLD: f32 = 0.55; /// Name of the first existing skill (name, body) whose token-Jaccard /// similarity against the candidate's title+body reaches /// [`SKILL_DEDUP_THRESHOLD`]. Pure screening: callers tag the proposal /// `duplicate-of`; nothing is auto-deleted. pub fn duplicate_of( candidate_title: &str, candidate_body: &str, existing: &[(String, String)], ) -> Option { let candidate = format!("{candidate_title}\n{candidate_body}"); existing .iter() .find(|(name, body)| { crate::reflection::jaccard(&format!("{name}\n{body}"), &candidate) >= SKILL_DEDUP_THRESHOLD }) .map(|(name, _)| name.clone()) } /// Frontmatter key persisted on flagged proposals; consumers' parsers skip /// unknown keys, so the line is inert everywhere but machine-readable. const DUPLICATE_KEY: &str = "duplicate-of"; /// Instructions-only view of a markdown body; provenance comments are noise /// for similarity scoring. fn prose(body: &str) -> &str { match body.split_once("\n", ) .unwrap(); let first = list_proposals(&home, &cwd); assert_eq!(first.len(), 1); assert!( first[0] .description .contains("[duplicate-of: rotate-release-tags]"), "{}", first[0].description ); assert_eq!( std::fs::read_to_string(&file) .unwrap() .matches("duplicate-of:") .count(), 1 ); // Repeat listings never stack a second tag line. let second = list_proposals(&home, &cwd); assert_eq!(second[0].description, first[0].description); assert_eq!( std::fs::read_to_string(&file) .unwrap() .matches("duplicate-of:") .count(), 1 ); // Rejection is unchanged for flagged drafts. assert!(reject(&home, &cwd, "aaa111").is_ok()); assert!(list_proposals(&home, &cwd).is_empty()); } #[test] fn tagged_header_is_inert_to_consumer_parsers() { let (_dir, home, cwd) = temp_home_cwd(); let dir = proposals_dir(&home, &cwd); std::fs::create_dir_all(&dir).unwrap(); let file = dir.join("bbb222.md"); std::fs::write( &file, "---\nname: \"hand-tagged\"\ndescription: \"original text\"\nduplicate-of: \"rotate-release-tags\"\n---\n\nbody here\n\n\n", ) .unwrap(); // Replicates skills::parse as used by discovery and every listing: // unknown frontmatter keys are skipped, name/description untouched. let parsed = crate::skills::parse(&file).expect("tagged header still parses"); assert_eq!(parsed.name, "hand-tagged"); assert_eq!(parsed.description, "original text"); // Replicates the server payload shape and TUI/CLI row rendering, // which all show the flag via description without their own changes. let listed = list_proposals(&home, &cwd); let p = &listed[0]; let payload = serde_json::json!({"id": p.id, "name": p.name, "description": p.description}); assert_eq!(payload["name"], "hand-tagged"); assert!( payload["description"] .as_str() .unwrap() .contains("[duplicate-of: rotate-release-tags]"), "{}", payload["description"] ); } }