1#![warn(missing_docs)]
4#![warn(clippy::missing_docs_in_private_items)]
5
6mod tools;
7
8use anyhow::{Context, Result, anyhow};
9use chrono::format::SecondsFormat;
10use chrono::{Datelike, Local};
11use clap::{CommandFactory, FromArgMatches, Parser};
12use helpers::{Mergeable, derive_merge};
13use llimorse_chat::log::{Resume, SessionManager};
14use serde::Deserialize;
15use std::path::PathBuf;
16use std::sync::Arc;
17use std::{env, fs};
18use term_ui::TermUi;
19
20const LLAMA_URL_DEFAULT: &str = "http://127.0.0.1:8080";
22
23const SEARXNG_URL_DEFAULT: &str = "http://127.0.0.1:8888";
25
26derive_merge! {
27 #[derive(Parser, Deserialize)]
29 #[serde(deny_unknown_fields, rename_all = "kebab-case")]
30 struct Args {
31 #[arg(long)]
33 #[serde(skip)]
34 config: Option<PathBuf>,
35
36 #[arg(long)]
38 llama_url: Option<String>,
39
40 #[arg(long)]
42 searxng_url: Option<String>,
43
44 #[arg(long)]
49 #[serde(default, deserialize_with = "helpers::system_files::deserialize")]
50 system: Vec<PathBuf>,
51
52 #[arg(long)]
54 #[serde(default)]
55 debug: bool,
56
57 #[arg(long)]
60 log_file: Option<PathBuf>,
61
62 #[arg(long)]
64 tasks: Option<PathBuf>,
65
66 #[arg(long)]
68 worklogs: Option<PathBuf>,
69
70 #[arg(long)]
72 markdown_output: Option<PathBuf>,
73
74 #[arg(long)]
76 knowledge: Option<PathBuf>,
77
78 #[arg(long)]
80 session_logs: Option<PathBuf>,
81
82 #[arg(long, num_args(0..=1), default_missing_value = "", value_parser = Resume::value_parser())]
87 #[serde(skip)] resume: Option<Resume>,
89 }
90}
91
92fn tagline() -> &'static str {
94 const TAGLINES: [&str; 4] = [
95 "because someone has to care about your tickets, and it won’t be you",
96 "turning existential dread into well-formatted Jira tickets since 2026",
97 "proof of work for proof of employment",
98 "comprehensive documentation for the comprehensively unmotivated",
99 ];
100
101 TAGLINES[fastrand::usize(..TAGLINES.len())]
102}
103
104#[tokio::main]
105async fn main() -> Result<()> {
106 let mut args = Args::from_arg_matches(
107 &Args::command()
108 .about(format!("WorkBuddy! …{}", tagline()))
109 .get_matches(),
110 )
111 .unwrap();
112
113 if let Some(file) = &args.config {
114 let config =
115 fs::read_to_string(file).map_err(|err| anyhow!("{}: {err}", file.display()))?;
116 let cfg_args =
117 toml::from_str(&config).map_err(|err| anyhow!("{}: {err}", file.display()))?;
118
119 args.merge_weak(cfg_args);
120 }
121
122 let log_path = args
123 .log_file
124 .clone()
125 .unwrap_or_else(|| env::temp_dir().join("work-buddy.log"));
126 let log_file = fs::OpenOptions::new()
127 .create(true)
128 .append(true)
129 .open(&log_path)
130 .map_err(|err| anyhow!("{}: {err}", log_path.display()))?;
131
132 tracing_subscriber::fmt()
133 .with_env_filter(
134 tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
135 if args.debug {
136 "info,work_buddy=debug"
137 } else {
138 "info"
139 }
140 .into()
141 }),
142 )
143 .with_writer(Arc::new(log_file))
144 .with_ansi(false)
145 .init();
146
147 let system_prompt: Option<String> = if args.system.is_empty() {
148 None
149 } else {
150 let mut prompt = String::new();
154 for file in &args.system {
155 let content =
156 fs::read_to_string(file).with_context(|| format!("{}", file.display()))?;
157 if !prompt.is_empty() {
158 prompt.push_str("\n\n");
159 }
160 prompt.push_str(content.trim_end_matches(['\n', '\r']));
161 }
162 Some(prompt)
163 };
164
165 let resume = args.resume.unwrap_or(Resume::Fresh);
167 let manager = SessionManager::new(args.session_logs.as_deref(), &resume)?;
168
169 let llm =
170 llimorse::Client::new(args.llama_url.as_deref().unwrap_or(LLAMA_URL_DEFAULT), None).await?;
171 let mut agent = llimorse::Agent::new_with_listener(llm, manager.log);
172
173 let task_file = match args.tasks {
177 Some(path) => Some(
178 tools::tasks::TaskFile::open(path.clone())
179 .with_context(|| format!("{}", path.display()))?,
180 ),
181 None => None,
182 };
183
184 if !manager.history.is_empty() {
185 agent.push_history(manager.history.clone());
186 } else {
187 let now = Local::now();
193 let date_note = format!(
194 "The current date and time is {}, {}",
195 now.weekday(),
196 now.to_rfc3339_opts(SecondsFormat::Secs, false)
197 );
198 let mut system = system_prompt;
199 if let Some(task_file) = &task_file {
200 let tasks = task_file.active_tasks_message();
201 system = Some(match system {
202 Some(prompt) => format!("{prompt}\n\n{tasks}"),
203 None => tasks,
204 });
205 }
206 let system = match system {
207 Some(prompt) => format!("{prompt}\n\n{date_note}"),
208 None => date_note,
209 };
210 agent.push_system(system);
211 }
212
213 agent.add_tool(llimorse_tools::WebSearch::new(
214 args.searxng_url.as_deref().unwrap_or(SEARXNG_URL_DEFAULT),
215 ));
216
217 if let Some(task_file) = task_file {
218 task_file.add_tools(&mut agent);
219 }
220
221 if let Some(worklog_dir) = args.worklogs {
222 let worklog_dir = tools::worklog::WorklogDirectory::new(worklog_dir);
223 worklog_dir.add_tools(&mut agent);
224 }
225
226 if let Some(markdown_dir) = args.markdown_output {
227 agent.add_tool(tools::write_md::WriteMarkdown::new(markdown_dir));
228 }
229
230 if let Some(knowledge_file) = args.knowledge {
231 let knowledge_file = tools::knowledge::KnowledgeFile::open(knowledge_file.clone())
232 .with_context(|| format!("{}", knowledge_file.display()))?;
233
234 knowledge_file.add_tools(&mut agent);
235 }
236
237 let mut app = llimorse_chat::App::new_with_history(
238 agent,
239 &manager.history,
240 llimorse_chat::ui::NotificationChannel::new(),
241 |agent, history| Ok(TermUi::new("WorkBuddy", agent, history)),
242 )?;
243
244 app.run().await
245}