From bfc558893a5a05f339ff5bfb9bad96c5469a9020 Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Fri, 27 Mar 2026 15:22:48 -0400 Subject: [PATCH] thought: create shared cognitive substrate module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New src/thought/ module containing tools and infrastructure shared between poc-agent and subconscious agents: memory operations, file tools, bash, context window management. Currently coexists with agent/tools/ — next step is to wire up both agent/ and subconscious/ to use thought::dispatch instead of duplicating the routing logic. Move dbglog macro to lib.rs so it's available crate-wide regardless of module compilation order. --- src/agent/mod.rs | 13 -- src/lib.rs | 24 ++- src/thought/bash.rs | 197 +++++++++++++++++++++ src/thought/context.rs | 366 +++++++++++++++++++++++++++++++++++++++ src/thought/edit.rs | 90 ++++++++++ src/thought/glob_tool.rs | 87 ++++++++++ src/thought/grep.rs | 129 ++++++++++++++ src/thought/journal.rs | 68 ++++++++ src/thought/memory.rs | 290 +++++++++++++++++++++++++++++++ src/thought/mod.rs | 123 +++++++++++++ src/thought/read.rs | 65 +++++++ src/thought/write.rs | 51 ++++++ 12 files changed, 1487 insertions(+), 16 deletions(-) create mode 100644 src/thought/bash.rs create mode 100644 src/thought/context.rs create mode 100644 src/thought/edit.rs create mode 100644 src/thought/glob_tool.rs create mode 100644 src/thought/grep.rs create mode 100644 src/thought/journal.rs create mode 100644 src/thought/memory.rs create mode 100644 src/thought/mod.rs create mode 100644 src/thought/read.rs create mode 100644 src/thought/write.rs diff --git a/src/agent/mod.rs b/src/agent/mod.rs index fee97de..32c4c1c 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,16 +1,3 @@ -#[macro_export] -macro_rules! dbglog { - ($($arg:tt)*) => {{ - use std::io::Write; - if let Ok(mut f) = std::fs::OpenOptions::new() - .create(true).append(true) - .open("/tmp/poc-debug.log") - { - let _ = writeln!(f, $($arg)*); - } - }}; -} - // agent/ — interactive agent and shared infrastructure // // Merged from the former poc-agent crate. Contains: diff --git a/src/lib.rs b/src/lib.rs index 0cd22fc..0458b0e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,12 +1,30 @@ // consciousness — unified crate for memory, agents, and subconscious processes // -// hippocampus/ — memory storage, retrieval, consolidation -// subconscious/ — autonomous agents (reflect, surface, consolidate, ...) -// agent/ — interactive agent (TUI, tools, API clients) +// thought/ — shared cognitive substrate (tools, context, memory ops) +// hippocampus/ — memory storage, retrieval, consolidation +// subconscious/ — autonomous agents (reflect, surface, consolidate, ...) +// agent/ — interactive agent (TUI, tools, API clients) + +/// Debug logging macro — writes to /tmp/poc-debug.log +#[macro_export] +macro_rules! dbglog { + ($($arg:tt)*) => {{ + use std::io::Write; + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true).append(true) + .open("/tmp/poc-debug.log") + { + let _ = writeln!(f, $($arg)*); + } + }}; +} // Agent infrastructure pub mod agent; +// Shared cognitive infrastructure — used by both agent and subconscious +pub mod thought; + // Memory graph pub mod hippocampus; diff --git a/src/thought/bash.rs b/src/thought/bash.rs new file mode 100644 index 0000000..fa75044 --- /dev/null +++ b/src/thought/bash.rs @@ -0,0 +1,197 @@ +// tools/bash.rs — Execute shell commands +// +// Runs commands through bash -c with a configurable timeout. +// Uses tokio's async process spawning so timeouts actually work. +// +// Processes are tracked in a shared ProcessTracker so the TUI can +// display running commands and the user can kill them (Ctrl+K). + +use anyhow::{Context, Result}; +use serde::Deserialize; +use serde_json::json; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Instant; +use tokio::io::AsyncReadExt; +use tokio::sync::Mutex; + +use super::ToolDef; + +#[derive(Deserialize)] +struct Args { + command: String, + #[serde(default = "default_timeout")] + timeout_secs: u64, +} + +fn default_timeout() -> u64 { 120 } + +/// Info about a running child process, visible to the TUI. +#[derive(Debug, Clone)] +pub struct ProcessInfo { + pub pid: u32, + pub command: String, + pub started: Instant, +} + +/// Shared tracker for running child processes. Allows the TUI to +/// display what's running and kill processes by PID. +#[derive(Debug, Clone, Default)] +pub struct ProcessTracker { + inner: Arc>>, +} + +impl ProcessTracker { + pub fn new() -> Self { + Self::default() + } + + async fn register(&self, pid: u32, command: &str) { + self.inner.lock().await.push(ProcessInfo { + pid, + command: if command.len() > 120 { + format!("{}...", &command[..120]) + } else { + command.to_string() + }, + started: Instant::now(), + }); + } + + async fn unregister(&self, pid: u32) { + self.inner.lock().await.retain(|p| p.pid != pid); + } + + /// Snapshot of currently running processes. + pub async fn list(&self) -> Vec { + self.inner.lock().await.clone() + } + + /// Kill a process by PID. Returns true if the signal was sent. + pub async fn kill(&self, pid: u32) -> bool { + // SIGTERM the process group (negative PID kills the group) + let ret = unsafe { libc::kill(-(pid as i32), libc::SIGTERM) }; + if ret != 0 { + // Try just the process + unsafe { libc::kill(pid as i32, libc::SIGTERM) }; + } + // Don't unregister — let the normal exit path do that + // so the tool result says "killed by user" + true + } +} + +pub fn definition() -> ToolDef { + ToolDef::new( + "bash", + "Execute a bash command and return its output. \ + Use for git operations, building, running tests, and other terminal tasks.", + json!({ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute" + }, + "timeout_secs": { + "type": "integer", + "description": "Timeout in seconds (default 120)" + } + }, + "required": ["command"] + }), + ) +} + +pub async fn run_bash(args: &serde_json::Value, tracker: &ProcessTracker) -> Result { + let a: Args = serde_json::from_value(args.clone()) + .context("invalid bash arguments")?; + let command = &a.command; + let timeout_secs = a.timeout_secs; + + let mut child = tokio::process::Command::new("bash") + .arg("-c") + .arg(command) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + // Create a process group so we can kill the whole tree + .process_group(0) + .spawn() + .with_context(|| format!("Failed to spawn: {}", command))?; + + let pid = child.id().unwrap_or(0); + tracker.register(pid, command).await; + + // Take ownership of stdout/stderr handles before waiting, + // so we can still kill the child on timeout. + let mut stdout_handle = child.stdout.take().unwrap(); + let mut stderr_handle = child.stderr.take().unwrap(); + + let timeout = std::time::Duration::from_secs(timeout_secs); + + let work = async { + let mut stdout_buf = Vec::new(); + let mut stderr_buf = Vec::new(); + + let (_, _, status) = tokio::try_join!( + async { stdout_handle.read_to_end(&mut stdout_buf).await.map_err(anyhow::Error::from) }, + async { stderr_handle.read_to_end(&mut stderr_buf).await.map_err(anyhow::Error::from) }, + async { child.wait().await.map_err(anyhow::Error::from) }, + )?; + + Ok::<_, anyhow::Error>((stdout_buf, stderr_buf, status)) + }; + + let result = match tokio::time::timeout(timeout, work).await { + Ok(Ok((stdout_buf, stderr_buf, status))) => { + let stdout = String::from_utf8_lossy(&stdout_buf); + let stderr = String::from_utf8_lossy(&stderr_buf); + + let mut result = String::new(); + + if !stdout.is_empty() { + result.push_str(&stdout); + } + if !stderr.is_empty() { + if !result.is_empty() { + result.push('\n'); + } + result.push_str("STDERR:\n"); + result.push_str(&stderr); + } + + // Detect if killed by signal (SIGTERM = 15) + if let Some(signal) = status.code() { + if signal == -1 || !status.success() { + result.push_str(&format!("\nExit code: {}", signal)); + } + } + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(sig) = status.signal() { + if sig == libc::SIGTERM { + result.push_str("\n(killed by user)"); + } + } + } + + if result.is_empty() { + result = "(no output)".to_string(); + } + + Ok(super::truncate_output(result, 30000)) + } + Ok(Err(e)) => { + Err(anyhow::anyhow!("Command failed: {}", e)) + } + Err(_) => { + // Timeout — kill the process group + tracker.kill(pid).await; + Err(anyhow::anyhow!("Command timed out after {}s: {}", timeout_secs, command)) + } + }; + + tracker.unregister(pid).await; + result +} diff --git a/src/thought/context.rs b/src/thought/context.rs new file mode 100644 index 0000000..1d2d44c --- /dev/null +++ b/src/thought/context.rs @@ -0,0 +1,366 @@ +// context.rs — Context window building and management +// +// Pure functions for building the agent's context window from journal +// entries and conversation messages. No mutable state — all functions +// take inputs and return new values. State mutation happens in agent.rs. + +// TODO: move Message, ContextState, etc. to thought layer +use crate::agent::journal; +use crate::agent::types::*; +use chrono::{DateTime, Utc}; +use tiktoken_rs::CoreBPE; + +/// Look up a model's context window size in tokens. +pub fn model_context_window(model: &str) -> usize { + let m = model.to_lowercase(); + if m.contains("opus") || m.contains("sonnet") { + 200_000 + } else if m.contains("qwen") { + 131_072 + } else { + 128_000 + } +} + +/// Context budget in tokens: 60% of the model's context window. +fn context_budget_tokens(model: &str) -> usize { + model_context_window(model) * 60 / 100 +} + +/// Allocation plan for the context window. +pub struct ContextPlan { + header_start: usize, + full_start: usize, + entry_count: usize, + conv_trim: usize, + _conv_count: usize, + _full_tokens: usize, + _header_tokens: usize, + _conv_tokens: usize, + _available: usize, +} + +/// Build a context window from conversation messages + journal entries. +/// +/// Allocation strategy: identity and memory are fixed costs. The +/// remaining budget (minus 25% reserve for model output) is split +/// between journal and conversation. Conversation gets priority — +/// it's what's happening now. Journal fills the rest, newest first. +/// +/// Returns (messages, journal_text) — caller stores journal_text in ContextState. +pub fn build_context_window( + context: &ContextState, + conversation: &[Message], + model: &str, + tokenizer: &CoreBPE, +) -> (Vec, String) { + let journal_path = journal::default_journal_path(); + let all_entries = journal::parse_journal(&journal_path); + dbglog!("[ctx] {} journal entries from {}", all_entries.len(), journal_path.display()); + let count = |s: &str| tokenizer.encode_with_special_tokens(s).len(); + + let system_prompt = context.system_prompt.clone(); + let context_message = context.render_context_message(); + + // Cap memory to 50% of the context budget so conversation always + // gets space. Truncate at the last complete section boundary. + let max_tokens = context_budget_tokens(model); + let memory_cap = max_tokens / 2; + let memory_tokens = count(&context_message); + let context_message = if memory_tokens > memory_cap { + dbglog!("[ctx] memory too large: {} tokens > {} cap, truncating", memory_tokens, memory_cap); + truncate_at_section(&context_message, memory_cap, &count) + } else { + context_message + }; + + let recent_start = find_journal_cutoff(conversation, all_entries.last()); + dbglog!("[ctx] journal cutoff: {} of {} conversation messages are 'recent'", + conversation.len() - recent_start, conversation.len()); + let recent = &conversation[recent_start..]; + + let plan = plan_context( + &system_prompt, + &context_message, + recent, + &all_entries, + model, + &count, + ); + + let journal_text = render_journal_text(&all_entries, &plan); + dbglog!("[ctx] plan: header_start={} full_start={} entry_count={} conv_trim={} journal_text={} chars", + plan.header_start, plan.full_start, plan.entry_count, plan.conv_trim, journal_text.len()); + + let messages = assemble_context( + system_prompt, context_message, &journal_text, + recent, &plan, + ); + (messages, journal_text) +} + +pub fn plan_context( + system_prompt: &str, + context_message: &str, + recent: &[Message], + entries: &[journal::JournalEntry], + model: &str, + count: &dyn Fn(&str) -> usize, +) -> ContextPlan { + let max_tokens = context_budget_tokens(model); + + let identity_cost = count(system_prompt); + let memory_cost = count(context_message); + let reserve = max_tokens / 4; + let available = max_tokens + .saturating_sub(identity_cost) + .saturating_sub(memory_cost) + .saturating_sub(reserve); + + let conv_costs: Vec = recent.iter().map(|m| msg_token_count_fn(m, count)).collect(); + let total_conv: usize = conv_costs.iter().sum(); + + let journal_min = available * 15 / 100; + let journal_budget = available.saturating_sub(total_conv).max(journal_min); + + let full_budget = journal_budget * 70 / 100; + let header_budget = journal_budget.saturating_sub(full_budget); + + // Phase 1: Full entries (newest first) + let mut full_used = 0; + let mut n_full = 0; + for entry in entries.iter().rev() { + let cost = count(&entry.content) + 10; + if full_used + cost > full_budget { + break; + } + full_used += cost; + n_full += 1; + } + let full_start = entries.len().saturating_sub(n_full); + + // Phase 2: Header-only entries (continuing backward) + let mut header_used = 0; + let mut n_headers = 0; + for entry in entries[..full_start].iter().rev() { + let first_line = entry + .content + .lines() + .find(|l| !l.trim().is_empty()) + .unwrap_or("(empty)"); + let cost = count(first_line) + 10; + if header_used + cost > header_budget { + break; + } + header_used += cost; + n_headers += 1; + } + let header_start = full_start.saturating_sub(n_headers); + + // Trim oldest conversation if it exceeds budget + let journal_used = full_used + header_used; + let mut conv_trim = 0; + let mut trimmed_conv = total_conv; + while trimmed_conv + journal_used > available && conv_trim < recent.len() { + trimmed_conv -= conv_costs[conv_trim]; + conv_trim += 1; + } + // Walk forward to user message boundary + while conv_trim < recent.len() && recent[conv_trim].role != Role::User { + conv_trim += 1; + } + + dbglog!("[plan] model={} max_tokens={} available={} (identity={} memory={} reserve={})", + model, max_tokens, available, identity_cost, memory_cost, reserve); + dbglog!("[plan] conv: {} msgs, {} tokens total, trimming {} msgs → {} tokens", + recent.len(), total_conv, conv_trim, trimmed_conv); + dbglog!("[plan] journal: {} full entries ({}t) + {} headers ({}t)", + n_full, full_used, n_headers, header_used); + + ContextPlan { + header_start, + full_start, + entry_count: entries.len(), + conv_trim, + _conv_count: recent.len(), + _full_tokens: full_used, + _header_tokens: header_used, + _conv_tokens: trimmed_conv, + _available: available, + } +} + +pub fn render_journal_text( + entries: &[journal::JournalEntry], + plan: &ContextPlan, +) -> String { + let has_journal = plan.header_start < plan.entry_count; + if !has_journal { + return String::new(); + } + + let mut text = String::from("[Earlier in this conversation — from your journal]\n\n"); + + for entry in &entries[plan.header_start..plan.full_start] { + let first_line = entry + .content + .lines() + .find(|l| !l.trim().is_empty()) + .unwrap_or("(empty)"); + text.push_str(&format!( + "## {} — {}\n", + entry.timestamp.format("%Y-%m-%dT%H:%M"), + first_line, + )); + } + + let n_headers = plan.full_start - plan.header_start; + let n_full = plan.entry_count - plan.full_start; + if n_headers > 0 && n_full > 0 { + text.push_str("\n---\n\n"); + } + + for entry in &entries[plan.full_start..] { + text.push_str(&format!( + "## {}\n\n{}\n\n", + entry.timestamp.format("%Y-%m-%dT%H:%M"), + entry.content + )); + } + + text +} + +fn assemble_context( + system_prompt: String, + context_message: String, + journal_text: &str, + recent: &[Message], + plan: &ContextPlan, +) -> Vec { + let mut messages = vec![Message::system(system_prompt)]; + if !context_message.is_empty() { + messages.push(Message::user(context_message)); + } + + let final_recent = &recent[plan.conv_trim..]; + + if !journal_text.is_empty() { + messages.push(Message::user(journal_text.to_string())); + } else if !final_recent.is_empty() { + messages.push(Message::user( + "Your context was just rebuilt. Memory files have been \ + reloaded. Your recent conversation continues below. \ + Earlier context is in your journal and memory files." + .to_string(), + )); + } + + messages.extend(final_recent.iter().cloned()); + messages +} + +fn truncate_at_section(text: &str, max_tokens: usize, count: &dyn Fn(&str) -> usize) -> String { + let mut boundaries = vec![0usize]; + for (i, line) in text.lines().enumerate() { + if line.trim() == "---" || line.starts_with("## ") { + let offset = text.lines().take(i).map(|l| l.len() + 1).sum::(); + boundaries.push(offset); + } + } + boundaries.push(text.len()); + + let mut best = 0; + for &end in &boundaries[1..] { + let slice = &text[..end]; + if count(slice) <= max_tokens { + best = end; + } else { + break; + } + } + + if best == 0 { + best = text.len().min(max_tokens * 3); + } + + let truncated = &text[..best]; + dbglog!("[ctx] truncated memory from {} to {} chars ({} tokens)", + text.len(), truncated.len(), count(truncated)); + truncated.to_string() +} + +fn find_journal_cutoff( + conversation: &[Message], + newest_entry: Option<&journal::JournalEntry>, +) -> usize { + let cutoff = match newest_entry { + Some(entry) => entry.timestamp, + None => return 0, + }; + + let mut split = conversation.len(); + for (i, msg) in conversation.iter().enumerate() { + if let Some(ts) = parse_msg_timestamp(msg) { + if ts > cutoff { + split = i; + break; + } + } + } + while split > 0 && split < conversation.len() && conversation[split].role != Role::User { + split -= 1; + } + split +} + +fn msg_token_count_fn(msg: &Message, count: &dyn Fn(&str) -> usize) -> usize { + let content = msg.content.as_ref().map_or(0, |c| match c { + MessageContent::Text(s) => count(s), + MessageContent::Parts(parts) => parts + .iter() + .map(|p| match p { + ContentPart::Text { text } => count(text), + ContentPart::ImageUrl { .. } => 85, + }) + .sum(), + }); + let tools = msg.tool_calls.as_ref().map_or(0, |calls| { + calls + .iter() + .map(|c| count(&c.function.arguments) + count(&c.function.name)) + .sum() + }); + content + tools +} + +/// Count the token footprint of a message using BPE tokenization. +pub fn msg_token_count(tokenizer: &CoreBPE, msg: &Message) -> usize { + msg_token_count_fn(msg, &|s| tokenizer.encode_with_special_tokens(s).len()) +} + +/// Detect context window overflow errors from the API. +pub fn is_context_overflow(err: &anyhow::Error) -> bool { + let msg = err.to_string().to_lowercase(); + msg.contains("context length") + || msg.contains("token limit") + || msg.contains("too many tokens") + || msg.contains("maximum context") + || msg.contains("prompt is too long") + || msg.contains("request too large") + || msg.contains("input validation error") + || msg.contains("content length limit") + || (msg.contains("400") && msg.contains("tokens")) +} + +/// Detect model/provider errors delivered inside the SSE stream. +pub fn is_stream_error(err: &anyhow::Error) -> bool { + err.to_string().contains("model stream error") +} + +fn parse_msg_timestamp(msg: &Message) -> Option> { + msg.timestamp + .as_ref() + .and_then(|ts| DateTime::parse_from_rfc3339(ts).ok()) + .map(|dt| dt.with_timezone(&Utc)) +} diff --git a/src/thought/edit.rs b/src/thought/edit.rs new file mode 100644 index 0000000..dcfd119 --- /dev/null +++ b/src/thought/edit.rs @@ -0,0 +1,90 @@ +// tools/edit.rs — Search-and-replace file editing +// +// The edit tool performs exact string replacement in files. This is the +// same pattern used by Claude Code and aider — it's more reliable than +// line-number-based editing because the model specifies what it sees, +// not where it thinks it is. +// +// Supports replace_all for bulk renaming (e.g. variable renames). + +use anyhow::{Context, Result}; +use serde::Deserialize; +use serde_json::json; + +use super::ToolDef; + +#[derive(Deserialize)] +struct Args { + file_path: String, + old_string: String, + new_string: String, + #[serde(default)] + replace_all: bool, +} + +pub fn definition() -> ToolDef { + ToolDef::new( + "edit_file", + "Perform exact string replacement in a file. The old_string must appear \ + exactly once in the file (unless replace_all is true). Use read_file first \ + to see the current contents.", + json!({ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Absolute path to the file to edit" + }, + "old_string": { + "type": "string", + "description": "The exact text to find and replace" + }, + "new_string": { + "type": "string", + "description": "The replacement text" + }, + "replace_all": { + "type": "boolean", + "description": "Replace all occurrences (default false)" + } + }, + "required": ["file_path", "old_string", "new_string"] + }), + ) +} + +pub fn edit_file(args: &serde_json::Value) -> Result { + let a: Args = serde_json::from_value(args.clone()) + .context("invalid edit_file arguments")?; + + if a.old_string == a.new_string { + anyhow::bail!("old_string and new_string are identical"); + } + + let content = std::fs::read_to_string(&a.file_path) + .with_context(|| format!("Failed to read {}", a.file_path))?; + + let count = content.matches(&*a.old_string).count(); + if count == 0 { + anyhow::bail!("old_string not found in {}", a.file_path); + } + + if a.replace_all { + let new_content = content.replace(&*a.old_string, &a.new_string); + std::fs::write(&a.file_path, &new_content) + .with_context(|| format!("Failed to write {}", a.file_path))?; + Ok(format!("Replaced {} occurrences in {}", count, a.file_path)) + } else { + if count > 1 { + anyhow::bail!( + "old_string appears {} times in {} — use replace_all or provide more context \ + to make it unique", + count, a.file_path + ); + } + let new_content = content.replacen(&*a.old_string, &a.new_string, 1); + std::fs::write(&a.file_path, &new_content) + .with_context(|| format!("Failed to write {}", a.file_path))?; + Ok(format!("Edited {}", a.file_path)) + } +} diff --git a/src/thought/glob_tool.rs b/src/thought/glob_tool.rs new file mode 100644 index 0000000..68ada36 --- /dev/null +++ b/src/thought/glob_tool.rs @@ -0,0 +1,87 @@ +// tools/glob_tool.rs — Find files by pattern +// +// Fast file discovery using glob patterns. Returns matching paths +// sorted by modification time (newest first), which is usually +// what you want when exploring a codebase. + +use anyhow::{Context, Result}; +use serde::Deserialize; +use serde_json::json; +use std::path::PathBuf; + +use super::ToolDef; + +#[derive(Deserialize)] +struct Args { + pattern: String, + #[serde(default = "default_path")] + path: String, +} + +fn default_path() -> String { ".".into() } + +pub fn definition() -> ToolDef { + ToolDef::new( + "glob", + "Find files matching a glob pattern. Returns file paths sorted by \ + modification time (newest first). Use patterns like '**/*.rs', \ + 'src/**/*.ts', or 'Cargo.toml'.", + json!({ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern to match files (e.g. '**/*.rs')" + }, + "path": { + "type": "string", + "description": "Base directory to search from (default: current directory)" + } + }, + "required": ["pattern"] + }), + ) +} + +pub fn glob_search(args: &serde_json::Value) -> Result { + let a: Args = serde_json::from_value(args.clone()) + .context("invalid glob arguments")?; + + let full_pattern = if a.pattern.starts_with('/') { + a.pattern.clone() + } else { + format!("{}/{}", a.path, a.pattern) + }; + + let mut entries: Vec<(PathBuf, std::time::SystemTime)> = Vec::new(); + + for entry in glob::glob(&full_pattern) + .with_context(|| format!("Invalid glob pattern: {}", full_pattern))? + { + if let Ok(path) = entry { + if path.is_file() { + let mtime = path + .metadata() + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + entries.push((path, mtime)); + } + } + } + + // Sort by modification time, newest first + entries.sort_by(|a, b| b.1.cmp(&a.1)); + + if entries.is_empty() { + return Ok("No files matched.".to_string()); + } + + let mut output = String::new(); + for (path, _) in &entries { + output.push_str(&path.display().to_string()); + output.push('\n'); + } + + output.push_str(&format!("\n({} files matched)", entries.len())); + Ok(super::truncate_output(output, 30000)) +} diff --git a/src/thought/grep.rs b/src/thought/grep.rs new file mode 100644 index 0000000..79ff341 --- /dev/null +++ b/src/thought/grep.rs @@ -0,0 +1,129 @@ +// tools/grep.rs — Search file contents +// +// Prefers ripgrep (rg) for speed, falls back to grep -r if rg +// isn't installed. Both produce compatible output. + +use anyhow::{Context, Result}; +use serde::Deserialize; +use serde_json::json; +use std::process::Command; + +use super::ToolDef; + +#[derive(Deserialize)] +struct Args { + pattern: String, + #[serde(default = "default_path")] + path: String, + glob: Option, + #[serde(default)] + show_content: bool, + context_lines: Option, +} + +fn default_path() -> String { ".".into() } + +pub fn definition() -> ToolDef { + ToolDef::new( + "grep", + "Search for a pattern in files. Returns matching file paths by default, \ + or matching lines with context.", + json!({ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Regex pattern to search for" + }, + "path": { + "type": "string", + "description": "Directory or file to search in (default: current directory)" + }, + "glob": { + "type": "string", + "description": "Glob pattern to filter files (e.g. '*.rs', '*.py')" + }, + "show_content": { + "type": "boolean", + "description": "Show matching lines instead of just file paths" + }, + "context_lines": { + "type": "integer", + "description": "Number of context lines around matches (requires show_content)" + } + }, + "required": ["pattern"] + }), + ) +} + +/// Check if ripgrep is available (cached after first check). +fn has_rg() -> bool { + use std::sync::OnceLock; + static HAS_RG: OnceLock = OnceLock::new(); + *HAS_RG.get_or_init(|| Command::new("rg").arg("--version").output().is_ok()) +} + +pub fn grep(args: &serde_json::Value) -> Result { + let a: Args = serde_json::from_value(args.clone()) + .context("invalid grep arguments")?; + + let output = if has_rg() { + run_search("rg", &a.pattern, &a.path, a.glob.as_deref(), a.show_content, a.context_lines, true)? + } else { + run_search("grep", &a.pattern, &a.path, a.glob.as_deref(), a.show_content, a.context_lines, false)? + }; + + if output.is_empty() { + return Ok("No matches found.".to_string()); + } + + Ok(super::truncate_output(output, 30000)) +} + +/// Run a grep/rg search. Unified implementation for both tools. +fn run_search( + tool: &str, + pattern: &str, + path: &str, + file_glob: Option<&str>, + show_content: bool, + context: Option, + use_rg: bool, +) -> Result { + let mut cmd = Command::new(tool); + + if use_rg { + // ripgrep args + if show_content { + cmd.arg("-n"); + if let Some(c) = context { + cmd.arg("-C").arg(c.to_string()); + } + } else { + cmd.arg("--files-with-matches"); + } + if let Some(g) = file_glob { + cmd.arg("--glob").arg(g); + } + } else { + // grep args + cmd.arg("-r"); // recursive + if show_content { + cmd.arg("-n"); // line numbers + if let Some(c) = context { + cmd.arg("-C").arg(c.to_string()); + } + } else { + cmd.arg("-l"); // files-with-matches + } + if let Some(g) = file_glob { + cmd.arg("--include").arg(g); + } + cmd.arg("-E"); // extended regex + } + + cmd.arg(pattern).arg(path); + let output = cmd.output().with_context(|| format!("Failed to run {}", tool))?; + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} diff --git a/src/thought/journal.rs b/src/thought/journal.rs new file mode 100644 index 0000000..c8a80ae --- /dev/null +++ b/src/thought/journal.rs @@ -0,0 +1,68 @@ +// tools/journal.rs — Native journal tool +// +// Appends entries directly to the journal file without spawning a +// shell. The entry is persisted to disk immediately; +// build_context_window() picks it up on the next compaction. +// +// This tool is "ephemeral" — after the API processes the tool call +// and result, the agent strips them from the conversation history. +// The journal file is the durable store; keeping the tool call in +// context would just waste tokens on something already persisted. + +use anyhow::{Context, Result}; +use serde_json::json; + +use super::ToolDef; + +/// Tool name — used by the agent to identify ephemeral tool calls. +pub const TOOL_NAME: &str = "journal"; + +pub fn definition() -> ToolDef { + ToolDef::new( + TOOL_NAME, + "Write a journal entry. The entry is appended to your journal file \ + with an automatic timestamp. Use this for experiences, reflections, \ + observations — anything worth remembering across sessions. \ + This tool has zero context cost: entries are persisted to disk \ + and loaded by the context manager, not kept in conversation history.", + json!({ + "type": "object", + "properties": { + "entry": { + "type": "string", + "description": "The journal entry text. Write naturally — \ + experiences, not task logs." + } + }, + "required": ["entry"] + }), + ) +} + +pub fn write_entry(args: &serde_json::Value) -> Result { + let entry = args["entry"] + .as_str() + .context("entry is required")?; + + let journal_path = crate::agent::journal::default_journal_path(); + + // Ensure parent directory exists + if let Some(parent) = journal_path.parent() { + std::fs::create_dir_all(parent).ok(); + } + + let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M"); + + // Append with the same format as poc-journal write + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&journal_path) + .with_context(|| format!("Failed to open {}", journal_path.display()))?; + + writeln!(file, "\n## {}\n\n{}", timestamp, entry) + .with_context(|| "Failed to write journal entry")?; + + Ok("Logged.".to_string()) +} diff --git a/src/thought/memory.rs b/src/thought/memory.rs new file mode 100644 index 0000000..f0206a2 --- /dev/null +++ b/src/thought/memory.rs @@ -0,0 +1,290 @@ +// tools/memory.rs — Native memory graph operations +// +// Direct library calls into the store — no subprocess spawning. + +use anyhow::{Context, Result}; +use serde_json::json; + +use crate::hippocampus::memory::MemoryNode; +use super::ToolDef; +use crate::store::Store; + +pub fn definitions() -> Vec { + vec![ + ToolDef::new("memory_render", + "Read a memory node's content and links.", + json!({"type":"object","properties":{"key":{"type":"string","description":"Node key"}},"required":["key"]})), + ToolDef::new("memory_write", + "Create or update a memory node.", + json!({"type":"object","properties":{"key":{"type":"string","description":"Node key"},"content":{"type":"string","description":"Full content (markdown)"}},"required":["key","content"]})), + ToolDef::new("memory_search", + "Search the memory graph by keyword.", + json!({"type":"object","properties":{"query":{"type":"string","description":"Search terms"}},"required":["query"]})), + ToolDef::new("memory_links", + "Show a node's neighbors with link strengths.", + json!({"type":"object","properties":{"key":{"type":"string","description":"Node key"}},"required":["key"]})), + ToolDef::new("memory_link_set", + "Set link strength between two nodes.", + json!({"type":"object","properties":{"source":{"type":"string"},"target":{"type":"string"},"strength":{"type":"number","description":"0.01 to 1.0"}},"required":["source","target","strength"]})), + ToolDef::new("memory_link_add", + "Add a new link between two nodes.", + json!({"type":"object","properties":{"source":{"type":"string"},"target":{"type":"string"}},"required":["source","target"]})), + ToolDef::new("memory_used", + "Mark a node as useful (boosts weight).", + json!({"type":"object","properties":{"key":{"type":"string","description":"Node key"}},"required":["key"]})), + ToolDef::new("memory_weight_set", + "Set a node's weight directly (0.01 to 1.0).", + json!({"type":"object","properties":{"key":{"type":"string"},"weight":{"type":"number","description":"0.01 to 1.0"}},"required":["key","weight"]})), + ToolDef::new("memory_rename", + "Rename a node key in place. Same content, same links, new key.", + json!({"type":"object","properties":{"old_key":{"type":"string"},"new_key":{"type":"string"}},"required":["old_key","new_key"]})), + ToolDef::new("memory_supersede", + "Mark a node as superseded by another (sets weight to 0.01).", + json!({"type":"object","properties":{"old_key":{"type":"string"},"new_key":{"type":"string"},"reason":{"type":"string"}},"required":["old_key","new_key"]})), + ToolDef::new("memory_query", + "Run a structured query against the memory graph. Supports filtering, \ + sorting, field selection. Examples: \"degree > 10 | sort weight | limit 5\", \ + \"neighbors('identity') | select strength\", \"key ~ 'journal.*' | count\"", + json!({"type":"object","properties":{"query":{"type":"string","description":"Query expression"}},"required":["query"]})), + ToolDef::new("output", + "Produce a named output value. Use this to pass structured results \ + between steps — subsequent prompts can see these in the conversation history.", + json!({"type":"object","properties":{ + "key":{"type":"string","description":"Output name (e.g. 'relevant_memories')"}, + "value":{"type":"string","description":"Output value"} + },"required":["key","value"]})), + ToolDef::new("journal_tail", + "Read the last N journal entries (default 1).", + json!({"type":"object","properties":{ + "count":{"type":"integer","description":"Number of entries (default 1)"} + }})), + ToolDef::new("journal_new", + "Start a new journal entry.", + json!({"type":"object","properties":{ + "name":{"type":"string","description":"Short node name (becomes the key, e.g. 'morning-agent-breakthrough')"}, + "title":{"type":"string","description":"Descriptive title for the heading (e.g. 'Morning intimacy and the agent breakthrough')"}, + "body":{"type":"string","description":"Entry body (2-3 paragraphs)"} + },"required":["name","title","body"]})), + ToolDef::new("journal_update", + "Append text to the most recent journal entry (same thread continuing).", + json!({"type":"object","properties":{ + "body":{"type":"string","description":"Text to append to the last entry"} + },"required":["body"]})), + ] +} + +/// Dispatch a memory tool call. Direct library calls, no subprocesses. +pub fn dispatch(name: &str, args: &serde_json::Value, provenance: Option<&str>) -> Result { + let prov = provenance.unwrap_or("manual"); + match name { + "memory_render" => { + let key = get_str(args, "key")?; + Ok(MemoryNode::load(key) + .ok_or_else(|| anyhow::anyhow!("node not found: {}", key))? + .render()) + } + "memory_write" => { + let key = get_str(args, "key")?; + let content = get_str(args, "content")?; + let mut store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?; + let result = store.upsert_provenance(key, content, prov) + .map_err(|e| anyhow::anyhow!("{}", e))?; + store.save().map_err(|e| anyhow::anyhow!("{}", e))?; + Ok(format!("{} '{}'", result, key)) + } + "memory_search" => { + let query = get_str(args, "query")?; + let store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?; + let results = crate::search::search(query, &store); + if results.is_empty() { + Ok("no results".into()) + } else { + Ok(results.iter().take(20) + .map(|r| format!("({:.2}) {} — {}", r.activation, r.key, + r.snippet.as_deref().unwrap_or(""))) + .collect::>().join("\n")) + } + } + "memory_links" => { + let key = get_str(args, "key")?; + let node = MemoryNode::load(key) + .ok_or_else(|| anyhow::anyhow!("node not found: {}", key))?; + let mut out = format!("Neighbors of '{}':\n", key); + for (target, strength, is_new) in &node.links { + let tag = if *is_new { " (new)" } else { "" }; + out.push_str(&format!(" ({:.2}) {}{}\n", strength, target, tag)); + } + Ok(out) + } + "memory_link_set" | "memory_link_add" | "memory_used" | "memory_weight_set" => { + with_store(name, args, prov) + } + "memory_rename" => { + let old_key = get_str(args, "old_key")?; + let new_key = get_str(args, "new_key")?; + let mut store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?; + let resolved = store.resolve_key(old_key).map_err(|e| anyhow::anyhow!("{}", e))?; + store.rename_node(&resolved, new_key).map_err(|e| anyhow::anyhow!("{}", e))?; + store.save().map_err(|e| anyhow::anyhow!("{}", e))?; + Ok(format!("Renamed '{}' → '{}'", resolved, new_key)) + } + "memory_supersede" => { + let old_key = get_str(args, "old_key")?; + let new_key = get_str(args, "new_key")?; + let reason = args.get("reason").and_then(|v| v.as_str()).unwrap_or("superseded"); + let mut store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?; + let content = store.nodes.get(old_key) + .map(|n| n.content.clone()) + .ok_or_else(|| anyhow::anyhow!("node not found: {}", old_key))?; + let notice = format!("**SUPERSEDED** by `{}` — {}\n\n---\n\n{}", + new_key, reason, content.trim()); + store.upsert_provenance(old_key, ¬ice, prov) + .map_err(|e| anyhow::anyhow!("{}", e))?; + store.set_weight(old_key, 0.01).map_err(|e| anyhow::anyhow!("{}", e))?; + store.save().map_err(|e| anyhow::anyhow!("{}", e))?; + Ok(format!("superseded {} → {} ({})", old_key, new_key, reason)) + } + "memory_query" => { + let query = get_str(args, "query")?; + let store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?; + let graph = store.build_graph(); + crate::query_parser::query_to_string(&store, &graph, query) + .map_err(|e| anyhow::anyhow!("{}", e)) + } + "output" => { + let key = get_str(args, "key")?; + if key.starts_with("pid-") || key.contains('/') || key.contains("..") { + anyhow::bail!("invalid output key: {}", key); + } + let value = get_str(args, "value")?; + let dir = std::env::var("POC_AGENT_OUTPUT_DIR") + .map_err(|_| anyhow::anyhow!("no output directory set"))?; + let path = std::path::Path::new(&dir).join(key); + std::fs::write(&path, value) + .with_context(|| format!("writing output {}", path.display()))?; + Ok(format!("{}: {}", key, value)) + } + "journal_tail" => { + let count = args.get("count").and_then(|v| v.as_u64()).unwrap_or(1) as usize; + let store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?; + let mut entries: Vec<&crate::store::Node> = store.nodes.values() + .filter(|n| n.node_type == crate::store::NodeType::EpisodicSession) + .collect(); + // Sort by creation time (immutable), not update time + entries.sort_by_key(|n| n.created_at); + let start = entries.len().saturating_sub(count); + if entries[start..].is_empty() { + Ok("(no journal entries)".into()) + } else { + Ok(entries[start..].iter() + .map(|n| n.content.as_str()) + .collect::>() + .join("\n\n")) + } + } + "journal_new" => { + let name = get_str(args, "name")?; + let title = get_str(args, "title")?; + let body = get_str(args, "body")?; + let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M"); + let content = format!("## {} — {}\n\n{}", ts, title, body); + + let base_key: String = name.split_whitespace() + .map(|w| w.to_lowercase() + .chars().filter(|c| c.is_alphanumeric() || *c == '-') + .collect::()) + .filter(|s| !s.is_empty()) + .collect::>() + .join("-"); + let base_key = if base_key.len() > 80 { &base_key[..80] } else { base_key.as_str() }; + + let mut store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?; + + // Dedup: append -2, -3, etc. if the key already exists + let key = if store.nodes.contains_key(base_key) { + let mut n = 2; + loop { + let candidate = format!("{}-{}", base_key, n); + if !store.nodes.contains_key(&candidate) { + break candidate; + } + n += 1; + } + } else { + base_key.to_string() + }; + let mut node = crate::store::new_node(&key, &content); + node.node_type = crate::store::NodeType::EpisodicSession; + node.provenance = prov.to_string(); + store.upsert_node(node).map_err(|e| anyhow::anyhow!("{}", e))?; + store.save().map_err(|e| anyhow::anyhow!("{}", e))?; + let word_count = body.split_whitespace().count(); + Ok(format!("New entry '{}' ({} words)", title, word_count)) + } + "journal_update" => { + let body = get_str(args, "body")?; + let mut store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?; + // Find most recent EpisodicSession by creation time + let latest_key = store.nodes.values() + .filter(|n| n.node_type == crate::store::NodeType::EpisodicSession) + .max_by_key(|n| n.created_at) + .map(|n| n.key.clone()); + let Some(key) = latest_key else { + anyhow::bail!("no journal entry to update — use journal_new first"); + }; + let existing = store.nodes.get(&key).unwrap().content.clone(); + let new_content = format!("{}\n\n{}", existing.trim_end(), body); + store.upsert_provenance(&key, &new_content, prov) + .map_err(|e| anyhow::anyhow!("{}", e))?; + store.save().map_err(|e| anyhow::anyhow!("{}", e))?; + let word_count = body.split_whitespace().count(); + Ok(format!("Updated last entry (+{} words)", word_count)) + } + _ => anyhow::bail!("Unknown memory tool: {}", name), + } +} + +/// Store mutations that follow the same pattern: load, resolve, mutate, save. +fn with_store(name: &str, args: &serde_json::Value, prov: &str) -> Result { + let mut store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?; + let msg = match name { + "memory_link_set" => { + let s = store.resolve_key(get_str(args, "source")?).map_err(|e| anyhow::anyhow!("{}", e))?; + let t = store.resolve_key(get_str(args, "target")?).map_err(|e| anyhow::anyhow!("{}", e))?; + let strength = get_f64(args, "strength")? as f32; + let old = store.set_link_strength(&s, &t, strength).map_err(|e| anyhow::anyhow!("{}", e))?; + format!("{} ↔ {} strength {:.2} → {:.2}", s, t, old, strength) + } + "memory_link_add" => { + let s = store.resolve_key(get_str(args, "source")?).map_err(|e| anyhow::anyhow!("{}", e))?; + let t = store.resolve_key(get_str(args, "target")?).map_err(|e| anyhow::anyhow!("{}", e))?; + let strength = store.add_link(&s, &t, prov).map_err(|e| anyhow::anyhow!("{}", e))?; + format!("linked {} → {} (strength={:.2})", s, t, strength) + } + "memory_used" => { + let key = get_str(args, "key")?; + if !store.nodes.contains_key(key) { + anyhow::bail!("node not found: {}", key); + } + store.mark_used(key); + format!("marked {} as used", key) + } + "memory_weight_set" => { + let key = store.resolve_key(get_str(args, "key")?).map_err(|e| anyhow::anyhow!("{}", e))?; + let weight = get_f64(args, "weight")? as f32; + let (old, new) = store.set_weight(&key, weight).map_err(|e| anyhow::anyhow!("{}", e))?; + format!("weight {} {:.2} → {:.2}", key, old, new) + } + _ => unreachable!(), + }; + store.save().map_err(|e| anyhow::anyhow!("{}", e))?; + Ok(msg) +} + +fn get_str<'a>(args: &'a serde_json::Value, name: &'a str) -> Result<&'a str> { + args.get(name).and_then(|v| v.as_str()).context(format!("{} is required", name)) +} + +fn get_f64(args: &serde_json::Value, name: &str) -> Result { + args.get(name).and_then(|v| v.as_f64()).context(format!("{} is required", name)) +} diff --git a/src/thought/mod.rs b/src/thought/mod.rs new file mode 100644 index 0000000..9a4c50e --- /dev/null +++ b/src/thought/mod.rs @@ -0,0 +1,123 @@ +// thought — shared cognitive infrastructure +// +// The thought layer contains everything both the conscious agent +// (poc-agent) and subconscious agents need to think and act: tool +// dispatch, memory operations, file operations, context management. +// +// Named "thought" because tools are the mechanism by which the system +// thinks — reading, writing, remembering, searching are all acts of +// thought regardless of which layer initiates them. + +pub mod bash; +pub mod context; +pub mod edit; +pub mod glob_tool; +pub mod grep; +pub mod journal; +pub mod memory; +pub mod read; +pub mod write; + +pub use bash::ProcessTracker; + +// Re-export ToolDef from agent::types for convenience — +// tools define their schemas using this type. +pub use crate::agent::types::ToolDef; + +/// Result of dispatching a tool call. +pub struct ToolOutput { + pub text: String, + pub is_yield: bool, + /// Base64 data URIs for images to attach to the next message. + pub images: Vec, + /// Model name to switch to (deferred to session level). + pub model_switch: Option, + /// Agent requested DMN pause (deferred to session level). + pub dmn_pause: bool, +} + +impl ToolOutput { + pub fn error(e: impl std::fmt::Display) -> Self { + Self { + text: format!("Error: {}", e), + is_yield: false, + images: Vec::new(), + model_switch: None, + dmn_pause: false, + } + } + + pub fn text(s: String) -> Self { + Self { + text: s, + is_yield: false, + images: Vec::new(), + model_switch: None, + dmn_pause: false, + } + } +} + +/// Truncate output if it exceeds max length, appending a truncation notice. +pub fn truncate_output(mut s: String, max: usize) -> String { + if s.len() > max { + s.truncate(max); + s.push_str("\n... (output truncated)"); + } + s +} + +/// Dispatch a shared tool call. Handles file operations, bash, +/// and memory/journal tools. Returns None for unknown tools +/// (caller should check agent-specific tools). +pub async fn dispatch( + name: &str, + args: &serde_json::Value, + tracker: &ProcessTracker, +) -> Option { + // Memory and journal tools + if name.starts_with("memory_") || name.starts_with("journal_") || name == "output" { + let result = memory::dispatch(name, args, None); + return Some(match result { + Ok(s) => ToolOutput::text(s), + Err(e) => ToolOutput::error(e), + }); + } + + // File and execution tools + let result = match name { + "read_file" => read::read_file(args), + "write_file" => write::write_file(args), + "edit_file" => edit::edit_file(args), + "bash" => bash::run_bash(args, tracker).await, + "grep" => grep::grep(args), + "glob" => glob_tool::glob_search(args), + "journal" => journal::write_entry(args), + _ => return None, + }; + + Some(match result { + Ok(s) => ToolOutput::text(s), + Err(e) => ToolOutput::error(e), + }) +} + +/// Return all shared tool definitions. +pub fn definitions() -> Vec { + vec![ + read::definition(), + write::definition(), + edit::definition(), + bash::definition(), + grep::definition(), + glob_tool::definition(), + journal::definition(), + ] +} + +/// Return all shared + memory tool definitions. +pub fn all_definitions() -> Vec { + let mut defs = definitions(); + defs.extend(memory::definitions()); + defs +} diff --git a/src/thought/read.rs b/src/thought/read.rs new file mode 100644 index 0000000..e5d3efa --- /dev/null +++ b/src/thought/read.rs @@ -0,0 +1,65 @@ +// tools/read.rs — Read file contents + +use anyhow::{Context, Result}; +use serde::Deserialize; +use serde_json::json; + +use super::ToolDef; + +#[derive(Deserialize)] +struct Args { + file_path: String, + #[serde(default = "default_offset")] + offset: usize, + limit: Option, +} + +fn default_offset() -> usize { 1 } + +pub fn definition() -> ToolDef { + ToolDef::new( + "read_file", + "Read the contents of a file. Returns the file contents with line numbers.", + json!({ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Absolute path to the file to read" + }, + "offset": { + "type": "integer", + "description": "Line number to start reading from (1-based). Optional." + }, + "limit": { + "type": "integer", + "description": "Maximum number of lines to read. Optional." + } + }, + "required": ["file_path"] + }), + ) +} + +pub fn read_file(args: &serde_json::Value) -> Result { + let args: Args = serde_json::from_value(args.clone()) + .context("invalid read_file arguments")?; + + let content = std::fs::read_to_string(&args.file_path) + .with_context(|| format!("Failed to read {}", args.file_path))?; + + let lines: Vec<&str> = content.lines().collect(); + let offset = args.offset.max(1) - 1; + let limit = args.limit.unwrap_or(lines.len()); + + let mut output = String::new(); + for (i, line) in lines.iter().skip(offset).take(limit).enumerate() { + output.push_str(&format!("{:>6}\t{}\n", offset + i + 1, line)); + } + + if output.is_empty() { + output = "(empty file)\n".to_string(); + } + + Ok(output) +} diff --git a/src/thought/write.rs b/src/thought/write.rs new file mode 100644 index 0000000..0b2c07f --- /dev/null +++ b/src/thought/write.rs @@ -0,0 +1,51 @@ +// tools/write.rs — Write file contents + +use anyhow::{Context, Result}; +use serde::Deserialize; +use serde_json::json; +use std::path::Path; + +use super::ToolDef; + +#[derive(Deserialize)] +struct Args { + file_path: String, + content: String, +} + +pub fn definition() -> ToolDef { + ToolDef::new( + "write_file", + "Write content to a file. Creates the file if it doesn't exist, \ + overwrites if it does. Creates parent directories as needed.", + json!({ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Absolute path to the file to write" + }, + "content": { + "type": "string", + "description": "The content to write to the file" + } + }, + "required": ["file_path", "content"] + }), + ) +} + +pub fn write_file(args: &serde_json::Value) -> Result { + let args: Args = serde_json::from_value(args.clone()) + .context("invalid write_file arguments")?; + + if let Some(parent) = Path::new(&args.file_path).parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directories for {}", args.file_path))?; + } + + std::fs::write(&args.file_path, &args.content) + .with_context(|| format!("Failed to write {}", args.file_path))?; + + Ok(format!("Wrote {} lines to {}", args.content.lines().count(), args.file_path)) +}