diff --git a/docs/config-reference.md b/docs/config-reference.md index 5a59a87..b8e0ff0 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -51,6 +51,23 @@ Methods are `auto`, `osc9`, `notify-send`, `bell`, and `none`. - `events` (default `["ready", "waiting", "error", "dead"]`) — session state changes forwarded to the orchestrator agent as `[linkshell event]` messages. `ready` fires when a session finishes (goes quiet); remove it if completion events are too chatty for your workflow. - `event_cooldown_secs` (default `30`) — minimum seconds between events for the same (session, state) pair. +## `[orchestrator]` agent memory and skills + +Both live under `~/.config/linkshell/` by default and are created +automatically the first time an orchestrator starts — no configuration +needed. Override the locations with `skills_dir` and `memory_file` (both +accept `~`). + +- **Skills** (`~/.config/linkshell/skills/`) — one `.md` file per skill; + the name + description go in the prompt, the body loads on demand via + `use_skill` (API class) or by path (CLI class). +- **Memory** (`~/.config/linkshell/memory.md`) — durable notes injected + into the orchestrator's prompt verbatim every turn. The agent appends + dated bullets with its `remember` tool; you curate the file by hand. + Keeping it concise is on you: past 8 KiB it is truncated in the prompt + and the agent is told to ask you to prune. `remember` is in the default + `auto_approve` set (it writes only to this file). + ## `[orchestrator]` approval (propose mode) - `approval` (default `"auto"`) — `"auto"` runs the orchestrator's tool calls diff --git a/docs/recipes.md b/docs/recipes.md index 92362d0..e7f7e81 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -70,3 +70,13 @@ reasons go back to the model as the tool result, so "wrong session, use 3" steers it mid-turn. Your approval history is the eval: once the proposals are consistently sensible, flip `approval = "auto"` — or keep the gate and grow `auto_approve` tool by tool. + +## Agent memory + +The orchestrator remembers across restarts through +`~/.config/linkshell/memory.md` (created automatically). Tell it things worth +keeping — "remember that the UCC build needs `--with-ucx=$HOME/ucx`" — and it +files a dated bullet via its `remember` tool; every future turn starts with +the file's contents in its prompt. It works best kept short: prune stale +bullets by hand, or `/deny` nothing — just edit the file, it is plain +markdown. diff --git a/src/config.rs b/src/config.rs index 7be583e..cd083bc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -63,6 +63,12 @@ pub struct OrchestratorConfig { /// the file paths in their briefing. Empty = ~/.config/linkshell/skills /// when that directory exists. pub skills_dir: String, + /// Persistent agent memory: one markdown file injected into the + /// orchestrator's prompt every turn and appended to by its `remember` + /// tool. The whole file goes in verbatim — keep it concise; entries the + /// agent adds are dated bullets you can prune by hand. Empty = + /// ~/.config/linkshell/memory.md (created on first orchestrator start). + pub memory_file: String, /// CLI class: working directory for the orchestrator session. pub cwd: String, /// CLI class: keep the orchestrator session out of the session bar and @@ -109,6 +115,7 @@ impl Default for OrchestratorConfig { auth_token: String::new(), system: String::new(), skills_dir: String::new(), + memory_file: String::new(), cwd: String::new(), hidden: true, permission_mode: "accept-edits".to_string(), @@ -124,6 +131,8 @@ impl Default for OrchestratorConfig { "list_sessions".into(), "read_output".into(), "use_skill".into(), + // Writes only to the agent's own memory file. + "remember".into(), ], approval_timeout_secs: 600, max_history_turns: 40, @@ -212,23 +221,53 @@ impl OrchestratorConfig { } /// Effective skills directory: the configured path (with `~` expansion), - /// or the default ~/.config/linkshell/skills when it exists. + /// or the default ~/.config/linkshell/skills when it exists + /// (ensure_agent_files creates it when the orchestrator starts). pub fn skills_path(&self) -> Option { if !self.skills_dir.is_empty() { - let dir = if self.skills_dir == "~" || self.skills_dir.starts_with("~/") { - match std::env::var("HOME") { - Ok(home) => format!("{}{}", home, &self.skills_dir[1..]), - Err(_) => self.skills_dir.clone(), - } - } else { - self.skills_dir.clone() - }; - return Some(std::path::PathBuf::from(dir)); + return Some(std::path::PathBuf::from(expand_tilde(&self.skills_dir))); } let default = config_path()?.parent()?.join("skills"); default.is_dir().then_some(default) } + /// Effective memory file: the configured path (with `~` expansion), or + /// the default ~/.config/linkshell/memory.md. Unlike skills_path this + /// returns the default even before the file exists, so the `remember` + /// tool and the scaffolding know where to write. + pub fn memory_path(&self) -> Option { + if !self.memory_file.is_empty() { + return Some(std::path::PathBuf::from(expand_tilde(&self.memory_file))); + } + Some(config_path()?.parent()?.join("memory.md")) + } + + /// Create the default agent locations so they work out of the box: + /// the skills directory, and a memory.md seeded with a short template + /// explaining the contract. Idempotent and best-effort; called when an + /// orchestrator starts. + pub fn ensure_agent_files(&self) { + if self.skills_dir.is_empty() { + if let Some(dir) = config_path().and_then(|p| p.parent().map(|d| d.join("skills"))) { + let _ = std::fs::create_dir_all(dir); + } + } + if let Some(path) = self.memory_path() { + if !path.exists() { + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write( + &path, + "# Agent memory\n\n\ + Durable notes the orchestrator carries between sessions. The whole file\n\ + is injected into its prompt every turn, so keep it concise — prune freely.\n\ + The agent appends dated bullets below via its `remember` tool.\n", + ); + } + } + } + /// Effective model id (API class). pub fn model_id(&self) -> String { if !self.model.is_empty() { @@ -746,6 +785,16 @@ pub struct KeybindingsConfig { // ── Load ────────────────────────────────────────────────────────────────── /// Location of the user config file: ~/.config/linkshell/config.toml +/// Expand a leading `~`/`~/` to $HOME; other paths pass through unchanged. +fn expand_tilde(path: &str) -> String { + if path == "~" || path.starts_with("~/") { + if let Ok(home) = std::env::var("HOME") { + return format!("{}{}", home, &path[1..]); + } + } + path.to_string() +} + pub fn config_path() -> Option { std::env::var("HOME").ok().map(|h| { std::path::PathBuf::from(h) diff --git a/src/orchestrator/mod.rs b/src/orchestrator/mod.rs index 000ebe2..b838c17 100644 --- a/src/orchestrator/mod.rs +++ b/src/orchestrator/mod.rs @@ -153,6 +153,7 @@ fn coalesce_batch( /// Spawn the orchestrator task. Only valid for API-class providers. pub fn spawn(cfg: OrchestratorConfig, event_tx: mpsc::Sender) -> OrchestratorHandle { + cfg.ensure_agent_files(); let (tx, mut rx) = mpsc::channel::(64); let (interrupt_tx, interrupt_rx) = tokio::sync::watch::channel(0u64); let name = cfg.name.clone(); @@ -319,6 +320,17 @@ fn tool_specs() -> Vec<(&'static str, &'static str, serde_json::Value)> { "required": ["name"] }), ), + ( + "remember", + "Append one short durable note to your persistent memory file (shown in your instructions each turn). Use it for facts worth carrying across sessions: project layout, user preferences, recurring commands. One sentence per note; the user prunes the file by hand.", + serde_json::json!({ + "type": "object", + "properties": { + "text": {"type": "string"} + }, + "required": ["text"] + }), + ), ( "pause_session", "Pause a session's process (SIGSTOP). The session stays alive with its full context but uses no CPU until resumed — use this when sessions compete for limited system resources and one should yield.", @@ -492,6 +504,36 @@ async fn exec_tool( Err(e) => serde_json::json!({"error": e.to_string()}).to_string(), }; } + // remember appends to the memory file directly — no main-loop trip. + if name == "remember" { + let Some(text) = args["text"].as_str().filter(|t| !t.trim().is_empty()) else { + return "{\"error\": \"missing required arguments\"}".to_string(); + }; + let Some(path) = cfg.memory_path() else { + return "{\"error\": \"no memory file available\"}".to_string(); + }; + cfg.ensure_agent_files(); + let entry = format!("- ({}) {}\n", today_utc(), text.trim().replace('\n', " ")); + use std::io::Write; + let result = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .and_then(|mut f| f.write_all(entry.as_bytes())); + return match result { + Ok(()) => { + let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + let mut reply = serde_json::json!({"remembered": text.trim(), "file_bytes": size}); + if size > 8 * 1024 { + reply["warning"] = serde_json::json!( + "memory file exceeds 8 KiB and will be truncated in your prompt — suggest the user prune it" + ); + } + reply.to_string() + } + Err(e) => serde_json::json!({"error": e.to_string()}).to_string(), + }; + } let sid = |key: &str| args[key].as_u64().map(|v| v as usize); let req = match name { @@ -620,11 +662,19 @@ and continue; otherwise report what you wanted to do and why.\n", p.push_str("\n\n"); p.push_str(&cfg.system); } + // Memory goes last: it is the only part of the system prompt that + // mutates mid-conversation (remember writes), so keeping everything + // above it byte-stable lets prompt prefix caches (Anthropic caching, + // llama.cpp prefix cache) re-serve the static bulk after each write. + if let Some(memory) = memory_section(cfg) { + p.push_str(&memory); + } p } /// Briefing typed into a CLI-class orchestrator session once it is READY. pub fn cli_briefing(cfg: &OrchestratorConfig) -> String { + cfg.ensure_agent_files(); let mut p = String::from( "You are the resident orchestrator agent for this linkshell instance, a terminal \ multiplexer running AI coding sessions. Your job: keep track of every session, act on \ @@ -652,6 +702,15 @@ description, read the file and follow it:\n", ); p.push_str(&list); } + if let Some(path) = cfg.memory_path() { + p.push_str(&format!( + "\nPersistent memory: {} — read it now; it carries durable notes from \ +previous sessions. When you learn something durable (project layout, user \ +preferences, recurring commands), append a short dated bullet there. Keep it \ +concise; the user prunes it by hand.\n", + path.display() + )); + } if !cfg.system.is_empty() { p.push_str("\n\n"); p.push_str(&cfg.system); @@ -659,6 +718,61 @@ description, read the file and follow it:\n", p } +/// Memory block for the API-class system prompt: the memory file verbatim, +/// or None when it is missing/empty. Injected every turn — the size guard +/// keeps a bloated file from eating the context and makes the bloat visible +/// so the user knows to prune. +fn memory_section(cfg: &OrchestratorConfig) -> Option { + const MAX_BYTES: usize = 8 * 1024; + let path = cfg.memory_path()?; + let content = std::fs::read_to_string(&path).ok()?; + if content.trim().is_empty() { + return None; + } + let (body, truncated) = if content.len() > MAX_BYTES { + let mut end = MAX_BYTES; + while !content.is_char_boundary(end) { + end -= 1; + } + (&content[..end], true) + } else { + (content.as_str(), false) + }; + let mut section = format!( + "\n\nMemory — durable notes from previous sessions, kept in {} (the user \ +curates this file; treat entries as possibly stale). Use the `remember` tool \ +to add short dated notes when you learn something durable:\n{}", + path.display(), + body + ); + if truncated { + section.push_str( + "\n[memory truncated at 8 KiB — tell the user their memory.md needs pruning]\n", + ); + } + Some(section) +} + +/// UTC date as YYYY-MM-DD without a date dependency (civil-from-days, +/// Howard Hinnant's algorithm). +fn today_utc() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let z = (secs / 86_400) as i64 + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + format!("{:04}-{:02}-{:02}", y, m, d) +} + /// Skills list for a prompt, or None when no skills are configured/present. fn skills_section(cfg: &OrchestratorConfig, with_paths: bool) -> Option { let dir = cfg.skills_path()?; @@ -720,6 +834,61 @@ mod tests { assert!(o[0]["function"]["parameters"].is_object()); } + #[test] + fn today_utc_is_a_plausible_iso_date() { + let d = today_utc(); + assert_eq!(d.len(), 10, "{}", d); + assert_eq!(&d[4..5], "-"); + assert_eq!(&d[7..8], "-"); + let year: i32 = d[..4].parse().unwrap(); + assert!((2024..2100).contains(&year), "{}", d); + } + + #[tokio::test] + async fn remember_appends_dated_note_and_memory_section_injects_it() { + let dir = std::env::temp_dir().join(format!("ls-mem-test-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let file = dir.join("memory.md"); + let _ = std::fs::remove_file(&file); + + let cfg = OrchestratorConfig { + memory_file: file.to_string_lossy().to_string(), + approval: "propose".to_string(), + ..Default::default() + }; + let (tx, _rx) = mpsc::channel::(8); + + // remember is auto-approved even in propose mode. + assert!(!cfg.approval_required("remember")); + + let out = exec_tool( + &cfg, + &tx, + "remember", + &serde_json::json!({"text": "user prefers rebase\nover merge"}), + ) + .await; + assert!(out.contains("remembered"), "{}", out); + + let content = std::fs::read_to_string(&file).unwrap(); + assert!( + content.contains("user prefers rebase over merge"), + "newlines collapse to one line: {}", + content + ); + assert!(content.contains(&format!("({})", today_utc()))); + + let section = memory_section(&cfg).expect("non-empty memory injects"); + assert!(section.contains("user prefers rebase over merge")); + assert!(section.contains("remember")); + + // Empty file: no section. + std::fs::write(&file, " \n").unwrap(); + assert!(memory_section(&cfg).is_none()); + + let _ = std::fs::remove_dir_all(&dir); + } + #[tokio::test] async fn propose_mode_gates_tools_and_returns_deny_reason() { let cfg = OrchestratorConfig {