Skip to main content

work_buddy/
main.rs

1//! A very nice work buddy, taking care of your tickets for you. More or less.
2
3#![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
20/// Default llama-server URL
21const LLAMA_URL_DEFAULT: &str = "http://127.0.0.1:8080";
22
23/// Default SearXNG URL
24const SEARXNG_URL_DEFAULT: &str = "http://127.0.0.1:8888";
25
26derive_merge! {
27    /// Command-line arguments for WorkBuddy
28    #[derive(Parser, Deserialize)]
29    #[serde(deny_unknown_fields, rename_all = "kebab-case")]
30    struct Args {
31        /// Config file path (containing base values for all of these arguments)
32        #[arg(long)]
33        #[serde(skip)]
34        config: Option<PathBuf>,
35
36        /// llama.cpp server base URL [default: http://127.0.0.1:8080]
37        #[arg(long)]
38        llama_url: Option<String>,
39
40        /// Base URL of a SearXNG instance for the web_search tool [default: http://127.0.0.1:8888]
41        #[arg(long)]
42        searxng_url: Option<String>,
43
44        /// Path to a file containing the system prompt. May be repeated; the
45        /// files are concatenated in the order given, separated by a blank line.
46        /// In a config file, `system` may be a single path (legacy) or a list
47        /// of paths.
48        #[arg(long)]
49        #[serde(default, deserialize_with = "helpers::system_files::deserialize")]
50        system: Vec<PathBuf>,
51
52        /// Enable debug-level logging
53        #[arg(long)]
54        #[serde(default)]
55        debug: bool,
56
57        /// File to append log output to, because the terminal is taken by the UI
58        /// [default: $TMPDIR/work-buddy.log]
59        #[arg(long)]
60        log_file: Option<PathBuf>,
61
62        /// Task file path
63        #[arg(long)]
64        tasks: Option<PathBuf>,
65
66        /// Directory to store work logs in
67        #[arg(long)]
68        worklogs: Option<PathBuf>,
69
70        /// Allow the LLM to create markdown files in this directory
71        #[arg(long)]
72        markdown_output: Option<PathBuf>,
73
74        /// JSON knowledge file to explain keywords (e.g. projects) and such
75        #[arg(long)]
76        knowledge: Option<PathBuf>,
77
78        /// Where to store the raw session log for later resuming
79        #[arg(long)]
80        session_logs: Option<PathBuf>,
81
82        /// Raw session log to resume from. With no value, resumes the newest session log in
83        /// `--session-logs`. A bare file name (no directory part) that is not in the current
84        /// directory is looked up in `--session-logs`, trying `<name>.jsonl` if the name has no
85        /// extension
86        #[arg(long, num_args(0..=1), default_missing_value = "", value_parser = Resume::value_parser())]
87        #[serde(skip)] // no sense allowing this in a config file
88        resume: Option<Resume>,
89    }
90}
91
92/// Return a random “witty” tag line for --help
93fn 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        // One message for all files: not every chat template handles multiple
151        // system messages well, and a single one at the top of the history is
152        // the safe shape.
153        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    // Use the CLI's `--resume` if provided, otherwise default to Fresh.
166    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    // The task file, opened up front so its active-task list can be folded
174    // into the single system message below (and so a bad path fails before
175    // anything is pushed).
176    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        // One system message for the whole history: not every chat template
188        // handles multiple system messages well, and a single one at the top
189        // of the history is the safe shape.  The active-task list and the
190        // date note are folded in (blank line apart), so the agent sees
191        // exactly one system message.
192        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}