diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index 4472aec..d6f1b5d 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -173,6 +173,10 @@ Bank feeds aren't automatic here: import the bank's CSV export - Every transaction: draft, preview, confirm with the human, post, attach the receipt. Tag payments to contractors with their vendor (1099s). +- The human usually has the viewer open beside you (`just gui`). When they + ask to see something, and after you post, put it on their screen with + `books.show` (an entry, an account register, a report, a document, or a + search). - `books.digest` is the review surface — "what did you do with my books lately." Offer it; don't make the human ask. - Reports are pure functions of the journal. If a number looks wrong, the diff --git a/crates/ledger-gui/src/app.rs b/crates/ledger-gui/src/app.rs index 6d2ede6..1767b18 100644 --- a/crates/ledger-gui/src/app.rs +++ b/crates/ledger-gui/src/app.rs @@ -99,6 +99,102 @@ impl App { self.receipts.settle(ctx); } + /// Go where the agent asked (`books.show`). + pub fn show(&mut self, target: ledger_store::viewer::ViewerTarget) { + use crate::nav::ReportKind; + use ledger_store::viewer::ViewerTarget as T; + let Some(b) = self.books.clone() else { + return; + }; + let date = |s: &Option| { + s.as_deref() + .and_then(|d| d.parse::().ok()) + }; + let route = match target { + T::Page { page } => match page.as_str() { + "overview" => Route::Overview, + "cash" => Route::Cash, + "profit" => Route::Profit, + "transactions" => Route::Transactions, + "reports" => Route::Reports(ReportKind::ProfitAndLoss), + "accounts" => Route::Accounts, + "documents" => Route::Documents, + "company" => Route::Company, + "journal" => Route::Journal, + _ => return, + }, + T::Entry { entry_id } => Route::Entry(entry_id), + T::Account { code, start, end } => { + let Some(a) = b.ledger.chart().find_by_code(&code) else { + return; + }; + self.state.accounts.range = match (date(&start), date(&end)) { + (None, None) => None, + (s, e) => Some(( + s.unwrap_or_else(|| b.kpis.first_entry.unwrap_or(b.today)), + e.unwrap_or(b.today), + )), + }; + Route::Account(a.id) + } + T::Report { + report, + start, + end, + columns, + compare, + } => { + use crate::pages::reports::{Columns, Compare}; + self.state.reports.columns = match columns.as_deref() { + Some("month") => Columns::Month, + Some("quarter") => Columns::Quarter, + Some("year") => Columns::Year, + _ => Columns::Total, + }; + self.state.reports.compare = match compare.as_deref() { + Some("prior_period") => Compare::PriorPeriod, + Some("prior_year") => Compare::PriorYear, + _ => Compare::None, + }; + let kind = match report.as_str() { + "profit_and_loss" => ReportKind::ProfitAndLoss, + "balance_sheet" => ReportKind::BalanceSheet, + "cash_flow" => ReportKind::CashFlow, + "members_equity" => ReportKind::MembersEquity, + "trial_balance" => ReportKind::TrialBalance, + "general_ledger" => ReportKind::GeneralLedger, + "tax_summary" => ReportKind::TaxSummary, + "vendor_1099" => ReportKind::Vendor1099, + "ar_aging" => ReportKind::ArAging, + "ap_aging" => ReportKind::ApAging, + _ => return, + }; + if let (s, e) = (date(&start), date(&end)) + && (s.is_some() || e.is_some()) + { + let (ds, de) = crate::pages::reports::Range::YearToDate.dates(&b); + self.state.reports.range = + crate::pages::reports::Range::Custom(s.unwrap_or(ds), e.unwrap_or(de)); + } + Route::Reports(kind) + } + T::Document { attachment_id } => Route::Document(attachment_id), + T::Search { query } => { + let st = &mut self.state.transactions; + st.query = query; + st.filter = Default::default(); + st.account = None; + Route::Transactions + } + }; + self.nav.go(route); + } + + /// Where the app is (tests). + pub fn route(&self) -> &Route { + &self.nav.current + } + /// Page state (tests). /// Label a fixed-books app "Live", as it reads when following a /// journal (screenshots of what a running app looks like). @@ -401,17 +497,18 @@ fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) { impl eframe::App for App { fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { - if let Some(loader) = &self.loader { - match loader.poll() { - Some(Update::Books(b)) => { + let updates = self.loader.as_ref().map(|l| l.poll()).unwrap_or_default(); + for u in updates { + match u { + Update::Books(b) => { if self.books.is_some() { self.updated_at = ui.input(|i| i.time); } self.books = Some(b); self.error = None; } - Some(Update::Error(e)) => self.error = Some(e), - None => {} + Update::Error(e) => self.error = Some(e), + Update::Show(target) => self.show(target), } } self.receipts.pump(ui.ctx()); diff --git a/crates/ledger-gui/src/data.rs b/crates/ledger-gui/src/data.rs index c62f18c..b88ebc3 100644 --- a/crates/ledger-gui/src/data.rs +++ b/crates/ledger-gui/src/data.rs @@ -30,6 +30,8 @@ pub struct Source { pub enum Update { Books(Arc), Error(String), + /// The agent asked the viewer to show something (`books.show`). + Show(ledger_store::viewer::ViewerTarget), } pub struct Loader { @@ -46,13 +48,10 @@ impl Loader { Loader { rx } } - /// The newest update, if any arrived since the last call. - pub fn poll(&self) -> Option { - let mut last = None; - while let Ok(u) = self.rx.try_recv() { - last = Some(u); - } - last + /// Every update since the last call, in order (books before the + /// navigation that refers to them). + pub fn poll(&self) -> Vec { + self.rx.try_iter().collect() } /// Block until the first update (tests, headless). @@ -121,6 +120,26 @@ fn run(source: Source, ctx: egui::Context, tx: Sender) { let mut last_disk = Some(disk); send(first); + // Requests to show something: apply new ones, and on start one made in + // the last two minutes (the agent said "open the viewer"); never an + // old one. + let started = chrono::Utc::now(); + let mut last_show = 0u64; + let mut check_show = |send: &dyn Fn(Update)| { + if let Some(cmd) = ledger_store::viewer::read(&source.data_dir) + && cmd.seq != last_show + { + let first_look = last_show == 0; + last_show = cmd.seq; + let recent = chrono::DateTime::parse_from_rfc3339(&cmd.at) + .is_ok_and(|t| started.signed_duration_since(t) < chrono::Duration::minutes(2)); + if !first_look || recent { + send(Update::Show(cmd.target)); + } + } + }; + check_show(&send); + let (ev_tx, ev_rx) = channel(); let mut watcher = notify::recommended_watcher(move |res: notify::Result| { if res.is_ok() { @@ -130,6 +149,11 @@ fn run(source: Source, ctx: egui::Context, tx: Sender) { .ok(); if let Some(w) = watcher.as_mut() { let _ = w.watch(&source.data_dir, RecursiveMode::NonRecursive); + let cmd = ledger_store::viewer::command_path(&source.data_dir); + if let Some(dir) = cmd.parent() { + let _ = std::fs::create_dir_all(dir); + let _ = w.watch(dir, RecursiveMode::NonRecursive); + } if let Some(t) = &source.books_toml && let Some(dir) = t.parent() { @@ -174,5 +198,6 @@ fn run(source: Source, ctx: egui::Context, tx: Sender) { last_disk = Some(disk); send(update); } + check_show(&send); } } diff --git a/crates/ledger-gui/src/lib.rs b/crates/ledger-gui/src/lib.rs index 9948e48..8f4ca8f 100644 --- a/crates/ledger-gui/src/lib.rs +++ b/crates/ledger-gui/src/lib.rs @@ -11,7 +11,7 @@ pub mod data; pub mod demo; mod fmt; pub mod nav; -mod pages; +pub mod pages; mod palette; mod receipts; mod theme; diff --git a/crates/ledger-gui/tests/live.rs b/crates/ledger-gui/tests/live.rs index 12f01be..d1b7f37 100644 --- a/crates/ledger-gui/tests/live.rs +++ b/crates/ledger-gui/tests/live.rs @@ -64,5 +64,27 @@ fn a_change_in_another_process_shows_up_live() { assert!(b.fresh.contains(&e), "the new entry is highlighted as new"); assert!(b.seq() > first.seq()); eprintln!("live update arrived in {:?}", started.elapsed()); + + // The agent asks the viewer to show the entry (books.show). + let asked = Instant::now(); + ledger_store::viewer::request( + &data, + ledger_store::viewer::ViewerTarget::Entry { entry_id: e }, + ) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + let mut shown = false; + while Instant::now() < deadline { + if let Some(Update::Show(ledger_store::viewer::ViewerTarget::Entry { entry_id })) = + loader.wait(Duration::from_millis(200)) + { + assert_eq!(entry_id, e); + shown = true; + break; + } + } + assert!(shown, "the show request never reached the viewer"); + eprintln!("show request arrived in {:?}", asked.elapsed()); + let _ = std::fs::remove_file(ledger_store::viewer::command_path(&data)); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ledger-gui/tests/show.rs b/crates/ledger-gui/tests/show.rs new file mode 100644 index 0000000..94e0dfd --- /dev/null +++ b/crates/ledger-gui/tests/show.rs @@ -0,0 +1,73 @@ +//! books.show requests drive the viewer to the right place. + +use egui_kittest::Harness; +use ledger_gui::nav::{ReportKind, Route}; +use ledger_gui::{App, Source, data}; +use ledger_store::viewer::ViewerTarget; + +#[test] +fn show_requests_navigate_the_viewer() { + let today = chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(); + let dir = std::env::temp_dir().join(format!("ledger-gui-show-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + ledger_gui::demo::build(&dir, today).unwrap(); + let source = Source { + data_dir: dir.join("books-data"), + books_toml: Some(dir.join("wanderbooks.toml")), + today: Some(today), + }; + let books = data::load_once(&source).unwrap(); + let rent = books.ledger.chart().find_by_code("6200").unwrap().id; + let mut h = Harness::builder() + .with_size(egui::vec2(1440.0, 920.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, books, true, Route::Overview)); + + // "Compare this quarter's P&L with the same quarter last year, by month." + h.state_mut().show(ViewerTarget::Report { + report: "profit_and_loss".into(), + start: Some("2026-07-01".into()), + end: Some("2026-09-30".into()), + columns: Some("month".into()), + compare: Some("prior_year".into()), + }); + h.run_steps(2); + assert_eq!( + h.state_mut().route(), + &Route::Reports(ReportKind::ProfitAndLoss) + ); + let st = &h.state_mut().state_mut().reports; + assert_eq!( + st.range, + ledger_gui::pages::reports::Range::Custom( + chrono::NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(), + chrono::NaiveDate::from_ymd_opt(2026, 9, 30).unwrap() + ) + ); + assert_eq!(st.columns, ledger_gui::pages::reports::Columns::Month); + + // "Show me the rent account for the summer." + h.state_mut().show(ViewerTarget::Account { + code: "6200".into(), + start: Some("2026-06-01".into()), + end: Some("2026-08-31".into()), + }); + h.run_steps(2); + assert_eq!(h.state_mut().route(), &Route::Account(rent)); + + // "Find every Adobe charge." + h.state_mut().show(ViewerTarget::Search { + query: "adobe".into(), + }); + h.run_steps(2); + assert_eq!(h.state_mut().route(), &Route::Transactions); + assert_eq!(h.state_mut().state_mut().transactions.query, "adobe"); + + // Unknown accounts are ignored, not crashes. + h.state_mut().show(ViewerTarget::Account { + code: "9999".into(), + start: None, + end: None, + }); + assert_eq!(h.state_mut().route(), &Route::Transactions); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 3cb56b0..0364435 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -23,7 +23,7 @@ use rust_decimal::Decimal; use std::sync::{Arc, Mutex}; /// The agent operating contract, delivered via initialize. -const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file; never store a TIN), and tag every payment to them with its vendor (books.draft_entry vendor, or books.set_entry_vendor; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA."; +const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file; never store a TIN), and tag every payment to them with its vendor (books.draft_entry vendor, or books.set_entry_vendor; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA."; /// Max receipt/document upload: 25 MB. const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024; @@ -1375,6 +1375,160 @@ impl BooksServer { Ok(Json(SearchEntriesOut { entries })) } + #[tool( + name = "books.show", + description = "Show something in the human's viewer (the read-only desktop app, `just gui`): an entry (with its lines, receipt, and history), an account's register (optionally for start/end dates), a report (optionally for start/end dates, split into month/quarter/year columns, compared with the prior period or prior year), a stored receipt or document, a search of the transactions, or a page. Give exactly one target; work out dates yourself (\"last quarter\" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books.", + annotations(read_only_hint = true) + )] + async fn show(&self, params: Parameters) -> Result, String> { + use ledger_store::viewer::{ViewerTarget, request}; + let p = params.0; + let store = self.lock()?; + let ledger = store.ledger(); + let given = [ + p.entry_id.is_some(), + p.account.is_some(), + p.report.is_some(), + p.attachment_id.is_some(), + p.search.is_some(), + p.page.is_some(), + ] + .iter() + .filter(|x| **x) + .count(); + if given != 1 { + return Err( + "give exactly one of entry_id, account, report, attachment_id, search, page".into(), + ); + } + for d in [&p.start, &p.end].into_iter().flatten() { + parse_date(d)?; + } + const REPORTS: [&str; 10] = [ + "profit_and_loss", + "balance_sheet", + "cash_flow", + "members_equity", + "trial_balance", + "general_ledger", + "tax_summary", + "vendor_1099", + "ar_aging", + "ap_aging", + ]; + const PAGES: [&str; 9] = [ + "overview", + "cash", + "profit", + "transactions", + "reports", + "accounts", + "documents", + "company", + "journal", + ]; + let (target, shown) = if let Some(id) = p.entry_id { + let e = ledger + .get_entry(id) + .ok_or_else(|| format!("unknown entry {id}"))?; + ( + ViewerTarget::Entry { entry_id: id }, + format!("entry #{id}, {}", e.description()), + ) + } else if let Some(code) = p.account { + let a = ledger + .chart() + .find_by_code(&code) + .ok_or_else(|| format!("unknown account {code}"))?; + ( + ViewerTarget::Account { + code: code.clone(), + start: p.start.clone(), + end: p.end.clone(), + }, + format!("the register of {} {}", a.code, a.name), + ) + } else if let Some(report) = p.report { + if !REPORTS.contains(&report.as_str()) { + return Err(format!( + "report {report:?}: expected one of {}", + REPORTS.join(", ") + )); + } + if let Some(c) = &p.columns + && !["total", "month", "quarter", "year"].contains(&c.as_str()) + { + return Err(format!( + "columns {c:?}: expected total, month, quarter, or year" + )); + } + if let Some(c) = &p.compare + && !["prior_period", "prior_year"].contains(&c.as_str()) + { + return Err(format!( + "compare {c:?}: expected prior_period or prior_year" + )); + } + let mut what = format!("the {} report", report.replace('_', " ")); + if let (Some(s), Some(e)) = (&p.start, &p.end) { + what.push_str(&format!(", {s} to {e}")); + } + if let Some(c) = p.columns.as_deref().filter(|c| *c != "total") { + what.push_str(&format!(", by {c}")); + } + if let Some(c) = &p.compare { + what.push_str(&format!(", compared with the {}", c.replace('_', " "))); + } + ( + ViewerTarget::Report { + report: report.clone(), + start: p.start.clone(), + end: p.end.clone(), + columns: p.columns.clone(), + compare: p.compare.clone(), + }, + what, + ) + } else if let Some(id) = p.attachment_id { + let a = store + .list_attachments(None) + .map_err(|e| e.to_string())? + .into_iter() + .find(|a| a.id == id) + .ok_or_else(|| format!("unknown attachment {id}"))?; + ( + ViewerTarget::Document { attachment_id: id }, + format!("document {id}, {}", a.file_name), + ) + } else if let Some(query) = p.search { + ( + ViewerTarget::Search { + query: query.clone(), + }, + format!("transactions matching {query:?}"), + ) + } else { + let page = p.page.unwrap_or_default(); + if !PAGES.contains(&page.as_str()) { + return Err(format!( + "page {page:?}: expected one of {}", + PAGES.join(", ") + )); + } + ( + ViewerTarget::Page { page: page.clone() }, + format!("the {page} page"), + ) + }; + request(store.data_dir(), target).map_err(|e| format!("couldn't reach the viewer: {e}"))?; + Ok(Json(ShowOut { + shown: shown.clone(), + note: format!( + "Sent to the viewer: {shown}. If it isn't open, the human can open it with `just gui` (within a couple of minutes, it opens right there)." + ), + })) + } + #[tool( name = "books.verify", description = "Check the books end to end: the journal's hash chain; a fresh replay against the live ledger; every table of the SQLite database, row for row, against a fresh projection of the journal (rebuilding the database from the journal if it drifted, keeping the old file); and, by default, every stored receipt and document against its SHA-256. Run it whenever a number looks wrong or a human asks whether the books are intact." diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index b37423e..12ed2e6 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -404,6 +404,44 @@ pub struct VerifyOut { pub checked: Vec, } +/// Parameters for `books.show`. Give exactly one target. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ShowParams { + /// An entry to open (lines, receipt, history). + pub entry_id: Option, + /// An account code to open its register. + pub account: Option, + /// A report: profit_and_loss, balance_sheet, cash_flow, + /// members_equity, trial_balance, general_ledger, tax_summary, + /// vendor_1099, ar_aging, ap_aging. + pub report: Option, + /// A stored receipt or document (attachment id). + pub attachment_id: Option, + /// Search the transactions list (description, account, vendor, amount). + pub search: Option, + /// A page: overview, cash, profit, transactions, reports, accounts, + /// documents, company, journal. + pub page: Option, + /// For account and report: first date, YYYY-MM-DD. + pub start: Option, + /// For account and report: last date, YYYY-MM-DD. + pub end: Option, + /// For a report: split into columns, "total" (default), "month", + /// "quarter", or "year" (P&L, balance sheet, cash flow). + pub columns: Option, + /// For a report: compare with "prior_period" or "prior_year". + pub compare: Option, +} + +/// Result of `books.show`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ShowOut { + /// What the viewer was asked to show. + pub shown: String, + /// Where this goes and what to tell the human. + pub note: String, +} + /// Parameters for `books.chart_template`. #[derive(Debug, Deserialize, JsonSchema)] pub struct ChartTemplateParams { diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index 7bcd6b9..cb3ad62 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -139,7 +139,7 @@ fn mcp_end_to_end_over_stdio() { // Tool listing: 23 narrow tools, chart is read-only. let list = mcp.request("tools/list", json!({})); let tools = list["result"]["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 65, "expected 65 tools, got {}", tools.len()); + assert_eq!(tools.len(), 66, "expected 66 tools, got {}", tools.len()); let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap(); assert_eq!(chart["annotations"]["readOnlyHint"], true); assert!( @@ -1580,6 +1580,19 @@ fn mcp_vendor_1099_over_stdio() { assert_eq!(r["vendors"][0]["paid"], "0.00"); let vendors = mcp.call("books.list_vendors", json!({})); assert_eq!(vendors["vendors"][0]["tin_on_file"], true); + // Show the entry in the viewer: a request outside the books. + let shown = mcp.call("books.show", json!({"entry_id": id})); + assert!(shown["shown"].as_str().unwrap().contains(&format!("#{id}"))); + let cmd = ledger_store::viewer::read(&dir).expect("request written"); + assert_eq!( + cmd.target, + ledger_store::viewer::ViewerTarget::Entry { entry_id: id } + ); + let err = mcp.call_err("books.show", json!({"entry_id": 9999})); + assert!(err.contains("unknown entry"), "{err}"); + let err = mcp.call_err("books.show", json!({"page": "cash", "search": "x"})); + assert!(err.contains("exactly one"), "{err}"); + let _ = std::fs::remove_file(ledger_store::viewer::command_path(&dir)); // The books check out end to end, receipt included. let v = mcp.call("books.verify", json!({})); assert_eq!(v["status"], "ok"); diff --git a/crates/ledger-store/src/lib.rs b/crates/ledger-store/src/lib.rs index 668850a..3e2a2c9 100644 --- a/crates/ledger-store/src/lib.rs +++ b/crates/ledger-store/src/lib.rs @@ -24,6 +24,7 @@ pub mod journal; pub mod schema; pub mod snapshot; pub mod store; +pub mod viewer; pub use blob::BlobStore; pub use error::StoreError; diff --git a/crates/ledger-store/src/viewer.rs b/crates/ledger-store/src/viewer.rs new file mode 100644 index 0000000..8f8d57c --- /dev/null +++ b/crates/ledger-store/src/viewer.rs @@ -0,0 +1,149 @@ +//! Telling a running viewer what to show. +//! +//! The agent can ask the viewer (`ledger-gui`) to open an entry, an +//! account, a report, a document, or a search. That's not bookkeeping, so +//! it never touches the books: the request is a small JSON file outside the +//! data directory, in the user's runtime directory, named for the books it +//! belongs to. The viewer watches it and navigates; the server writes it. +//! A request carries a sequence number so the same one never applies +//! twice, and a time so a viewer started later ignores stale ones. + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Where to go. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "show", rename_all = "snake_case")] +pub enum ViewerTarget { + /// A top-level page: overview, cash, profit, transactions, reports, + /// accounts, documents, company, journal. + Page { + /// Page name. + page: String, + }, + /// One entry, with its lines, receipt, and history. + Entry { + /// Entry id. + entry_id: u64, + }, + /// An account's register, optionally for a date range. + Account { + /// Account code. + code: String, + /// First date, YYYY-MM-DD. + start: Option, + /// Last date, YYYY-MM-DD. + end: Option, + }, + /// A report, optionally for a date range. + Report { + /// profit_and_loss, balance_sheet, cash_flow, members_equity, + /// trial_balance, general_ledger, tax_summary, vendor_1099, + /// ar_aging, ap_aging. + report: String, + /// First date, YYYY-MM-DD. + start: Option, + /// Last date, YYYY-MM-DD. + end: Option, + /// Split into columns: total, month, quarter, or year. + #[serde(default)] + columns: Option, + /// Compare with: prior_period or prior_year. + #[serde(default)] + compare: Option, + }, + /// A stored receipt or document. + Document { + /// Attachment id. + attachment_id: u64, + }, + /// The transactions list, searched. + Search { + /// Search text: description, account, vendor, amount. + query: String, + }, +} + +/// A request for the viewer. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ViewerCommand { + /// Increases with every request. + pub seq: u64, + /// When it was made, RFC 3339. + pub at: String, + /// Where to go. + pub target: ViewerTarget, +} + +/// The command file for the books in `data_dir`: in `$XDG_RUNTIME_DIR` +/// (or the temp dir), under `wanderbooks/`, named by a hash of the data +/// directory's canonical path. +pub fn command_path(data_dir: &Path) -> PathBuf { + let canonical = data_dir + .canonicalize() + .unwrap_or_else(|_| data_dir.to_path_buf()); + let key = crate::journal::sha256_hex(canonical.to_string_lossy().as_bytes()); + let base = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + base.join("wanderbooks") + .join(format!("viewer-{}.json", &key[..16])) +} + +/// Ask the viewer for `data_dir` to show `target`. Atomic (write then +/// rename), so the viewer never reads half a request. +pub fn request(data_dir: &Path, target: ViewerTarget) -> std::io::Result { + let path = command_path(data_dir); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let seq = read(data_dir).map_or(1, |c| c.seq + 1); + let cmd = ViewerCommand { + seq, + at: chrono::Utc::now().to_rfc3339(), + target, + }; + let tmp = path.with_extension("json.tmp"); + std::fs::write( + &tmp, + serde_json::to_vec(&cmd).map_err(std::io::Error::other)?, + )?; + std::fs::rename(&tmp, &path)?; + Ok(cmd) +} + +/// The latest request for `data_dir`, if any. +pub fn read(data_dir: &Path) -> Option { + let bytes = std::fs::read(command_path(data_dir)).ok()?; + serde_json::from_slice(&bytes).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn requests_round_trip_and_count_up() { + let dir = std::env::temp_dir().join(format!("wb-viewer-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let a = request(&dir, ViewerTarget::Entry { entry_id: 7 }).unwrap(); + let b = request( + &dir, + ViewerTarget::Search { + query: "adobe".into(), + }, + ) + .unwrap(); + assert_eq!(b.seq, a.seq + 1); + assert_eq!( + read(&dir).unwrap().target, + ViewerTarget::Search { + query: "adobe".into() + } + ); + // Same books, same file; other books, another. + assert_eq!(command_path(&dir), command_path(&dir.join("."))); + assert_ne!(command_path(&dir), command_path(&std::env::temp_dir())); + let _ = std::fs::remove_file(command_path(&dir)); + } +} diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 3539931..98290fd 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -162,6 +162,19 @@ through the policy. - `books.run_recurring { through? }` → `{ drafts: [{ recurring_id, date, entry_id }] }`: one draft per occurrence on or before `through`, once. +## Tools — the viewer + +- `books.show { entry_id | account | report | attachment_id | search | + page, start?, end? }` → `{ shown, note }`. Asks the human's viewer + (`ledger-gui`) to open an entry, an account register or a report (for + start/end dates if given), a stored file, a transactions search, or a + page. Exactly one target; entries, accounts, and attachments are checked + to exist. Never touches the books: the request is a small JSON file in + the user's runtime directory (`$XDG_RUNTIME_DIR/wanderbooks/viewer-.json`), written atomically, watched by the viewer. A + viewer started within two minutes of a request opens it; older requests + are ignored. + ## Tools — integrity - `books.verify { blobs? }` → `{ status: "ok" | "healed", events, head, diff --git a/justfile b/justfile index 7d95ca9..f76a0f2 100644 --- a/justfile +++ b/justfile @@ -32,10 +32,10 @@ site-shots: #!/usr/bin/env bash set -euo pipefail tmp=$(mktemp -d) - SHOT_DIR="$tmp" SHOT_DARK=1 SHOT_LIVE=1 SHOT_ONLY=overview,pnl cargo test -p ledger-gui --test screens + SHOT_DIR="$tmp" SHOT_DARK=1 SHOT_LIVE=1 SHOT_ONLY=overview,pnl,cash cargo test -p ledger-gui --test screens # The hero's viewer pane: taller, to sit beside the session. SHOT_DIR="$tmp" SHOT_DARK=1 SHOT_LIVE=1 SHOT_ONLY=entry SHOT_ENTRY=245 SHOT_HEIGHT=1150 cargo test -p ledger-gui --test screens - for f in overview entry pnl; do cp "$tmp/$f.png" site/assets/; done + for f in overview entry pnl cash; do cp "$tmp/$f.png" site/assets/; done rm -f site/assets/overview-dark.png rm -rf "$tmp" diff --git a/plan/STATUS.md b/plan/STATUS.md index f7b51f7..4e9454a 100644 --- a/plan/STATUS.md +++ b/plan/STATUS.md @@ -296,3 +296,9 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done the CPA (package, read-only clone + GUI). The site leads with owning your books and an AI bookkeeper: example prompts, a QuickBooks comparison, an honest "what it doesn't do yet", the CPA section, FAQ. +- 2026-10-12: The agent can drive the viewer. books.show (66 tools) asks + the viewer to open an entry, account register, report (with dates), + document, search, or page. The request lives outside the books (a JSON + file in the runtime dir keyed by the data dir, written atomically); the + viewer watches it beside the journal and applies books before + navigation. ~30 ms end to end. diff --git a/site/assets/cash.png b/site/assets/cash.png new file mode 100644 index 0000000..29dc45f Binary files /dev/null and b/site/assets/cash.png differ diff --git a/site/assets/entry.png b/site/assets/entry.png index fcaa57c..89e7f58 100644 Binary files a/site/assets/entry.png and b/site/assets/entry.png differ diff --git a/site/index.html b/site/index.html index 97d6006..be4c302 100644 --- a/site/index.html +++ b/site/index.html @@ -159,7 +159,13 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl

One line installs everything, makes your books, and starts your bookkeeper in Claude Code. Using Codex? Add --codex to the end. Linux and macOS; Windows via WSL2.

-
+
The wanderbooks viewer's overview: cash, what you owe, spending, revenue, runway, a cash chart, and what needs attention
+

The viewer: a live, read-only window on your books. Demo company.

+ +
+

Here's what using it looks like.

+

Your AI in one window, the viewer in the other. You ask; it does the bookkeeping, checks with you, and puts the result on your screen. Ask for “the balance sheet as of last quarter” or “this year's P&L by month against last year” and the viewer jumps right there.

+
The wanderbooks viewer showing entry #245, Adobe Creative Cloud, with its lines and the rendered receipt
-
An example session: you talk to Claude Code (or Codex) on the left; the viewer on the right follows along live, receipt and all. Demo company.
+
An example session: you talk to Claude Code (or Codex) on the left; the viewer on the right updates live, and the AI opens whatever it's talking about. Demo company.
+

Just ask.

@@ -188,7 +196,8 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl
Find the receipt for the $106.38 Apple charge in my email and record it.

Searches your inbox, files the receipt as a PDF, drafts the entry with the right accounts, and shows you the preview.

Here's last month's bank export. Categorize everything.

Imports the transactions as drafts, proposes an account for each from how you've booked them before, and asks about the ones it isn't sure of.

The domain renewal went to the wrong account. Fix it.

Reverses the entry and records it correctly. Nothing is ever erased; the history shows exactly what changed and why.

-
Why did software spending jump in March?

Traces the number to the entries and receipts behind it, and tells you what's new.

+
Show me every Adobe charge this year.

Opens the search in the viewer beside you, every charge listed, each one a click from its receipt.

+
Why did software spending jump in March?

Traces the number to the entries and receipts behind it, puts the account's March register up in the viewer, and tells you what's new.

Can we afford to hire someone at $6,000 a month?

Works it out from your actual cash, burn, and what's coming in, and shows its math.

We paid the designer $2,400 this year. Does she need a 1099?

Checks the threshold for the year, whether payments were by card, and whether her W-9 is on file.

Close September.

Checks for missing receipts and unposted drafts, reconciles to your bank statement, posts depreciation and amortization, and closes the period.

@@ -253,7 +262,7 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl
-
The viewer's overview: cash, what you owe, spending, revenue, runway, a cash chart, and what needs attention
+
The viewer's cash page: account balances, cash over time, money in and out by month, runway, and what's coming up

Talk to change. Look in the viewer.

Everything that changes your books happens in conversation with Claude or Codex. The viewer is the other half: a live, read-only window on the books for browsing, searching, and reports. Whatever your bookkeeper posts shows up there in a fraction of a second, and every number goes all the way down: click an amount on a statement for the transactions behind it, then a transaction for its lines, the receipt itself, and its history.