Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions docs/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
69 changes: 59 additions & 10 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand All @@ -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,
Expand Down Expand Up @@ -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<std::path::PathBuf> {
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<std::path::PathBuf> {
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() {
Expand Down Expand Up @@ -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::path::PathBuf> {
std::env::var("HOME").ok().map(|h| {
std::path::PathBuf::from(h)
Expand Down
169 changes: 169 additions & 0 deletions src/orchestrator/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppEvent>) -> OrchestratorHandle {
cfg.ensure_agent_files();
let (tx, mut rx) = mpsc::channel::<OrchestratorMsg>(64);
let (interrupt_tx, interrupt_rx) = tokio::sync::watch::channel(0u64);
let name = cfg.name.clone();
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -652,13 +702,77 @@ 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);
}
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<String> {
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<String> {
let dir = cfg.skills_path()?;
Expand Down Expand Up @@ -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::<AppEvent>(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 {
Expand Down
Loading