feat: books.show — the agent puts things on the viewer's screen
books.show asks the viewer to open an entry, an account register, a report (dates, month/quarter/year columns, prior-period or prior-year comparison), a document, a search, or a page. The request lives outside the books (runtime dir, keyed by data dir, written atomically); the viewer watches it beside the journal and applies books before navigating. 66 tools. Site: the dashboard is the hero again; 'Here's what using it looks like' shows the session beside the viewer, ending in books.show; the cash page replaces the repeated overview lower down. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
17 files changed
+626
-22
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<String>| {
|
||||
s.as_deref()
|
||||
.and_then(|d| d.parse::<chrono::NaiveDate>().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());
|
||||
|
||||
@@ -30,6 +30,8 @@ pub struct Source {
|
||||
pub enum Update {
|
||||
Books(Arc<Books>),
|
||||
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<Update> {
|
||||
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<Update> {
|
||||
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<Update>) {
|
||||
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<notify::Event>| {
|
||||
if res.is_ok() {
|
||||
@@ -130,6 +149,11 @@ fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
|
||||
.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<Update>) {
|
||||
last_disk = Some(disk);
|
||||
send(update);
|
||||
}
|
||||
check_show(&send);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<ShowParams>) -> Result<Json<ShowOut>, 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."
|
||||
|
||||
@@ -404,6 +404,44 @@ pub struct VerifyOut {
|
||||
pub checked: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<u64>,
|
||||
/// An account code to open its register.
|
||||
pub account: Option<String>,
|
||||
/// 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<String>,
|
||||
/// A stored receipt or document (attachment id).
|
||||
pub attachment_id: Option<u64>,
|
||||
/// Search the transactions list (description, account, vendor, amount).
|
||||
pub search: Option<String>,
|
||||
/// A page: overview, cash, profit, transactions, reports, accounts,
|
||||
/// documents, company, journal.
|
||||
pub page: Option<String>,
|
||||
/// For account and report: first date, YYYY-MM-DD.
|
||||
pub start: Option<String>,
|
||||
/// For account and report: last date, YYYY-MM-DD.
|
||||
pub end: Option<String>,
|
||||
/// For a report: split into columns, "total" (default), "month",
|
||||
/// "quarter", or "year" (P&L, balance sheet, cash flow).
|
||||
pub columns: Option<String>,
|
||||
/// For a report: compare with "prior_period" or "prior_year".
|
||||
pub compare: Option<String>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
/// Last date, YYYY-MM-DD.
|
||||
end: Option<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
/// Last date, YYYY-MM-DD.
|
||||
end: Option<String>,
|
||||
/// Split into columns: total, month, quarter, or year.
|
||||
#[serde(default)]
|
||||
columns: Option<String>,
|
||||
/// Compare with: prior_period or prior_year.
|
||||
#[serde(default)]
|
||||
compare: Option<String>,
|
||||
},
|
||||
/// 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<ViewerCommand> {
|
||||
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<ViewerCommand> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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-<hash
|
||||
of the data dir>.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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 220 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 343 KiB |
+14
-5
@@ -159,7 +159,13 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl
|
||||
<p class="note">One line installs everything, makes your books, and starts your bookkeeper in Claude Code. Using Codex? Add <code class="inline">--codex</code> to the end. Linux and macOS; Windows via WSL2.</p>
|
||||
</div>
|
||||
|
||||
<figure class="duo">
|
||||
<div class="shot hero-shot"><img src="assets/overview.png" alt="The wanderbooks viewer's overview: cash, what you owe, spending, revenue, runway, a cash chart, and what needs attention" width="1440" height="920"></div>
|
||||
<p class="caption">The viewer: a live, read-only window on your books. Demo company.</p>
|
||||
|
||||
<section id="session">
|
||||
<h2>Here's what using it looks like.</h2>
|
||||
<p class="sub">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.</p>
|
||||
<figure class="duo" style="margin-top:8px">
|
||||
<div class="term-pane" role="img" aria-label="An example Claude Code session recording a receipt in wanderbooks">
|
||||
<div class="term-bar"><span></span><span></span><span></span><b>claude — ~/Books/Juniper</b></div>
|
||||
<div class="term-body">
|
||||
@@ -174,12 +180,14 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl
|
||||
2100 Business Credit Card Cr 59.38</pre>Balanced, receipt attached. Software is now $1,324.69 for the year. Post it?</div>
|
||||
<div class="you">> yes</div>
|
||||
<div class="tool"><i>●</i><b>wanderbooks</b> · books.post_entry <span>(confirmed)</span></div>
|
||||
<div class="ai"><i>●</i>Posted as <b>#245</b>. It's in the viewer now.</div>
|
||||
<div class="tool"><i>●</i><b>wanderbooks</b> · books.show <span>(entry #245)</span></div>
|
||||
<div class="ai"><i>●</i>Posted as <b>#245</b>. It's on your screen in the viewer.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shot viewer"><img src="assets/entry.png" alt="The wanderbooks viewer showing entry #245, Adobe Creative Cloud, with its lines and the rendered receipt" width="1440" height="1150"></div>
|
||||
<figcaption>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.</figcaption>
|
||||
<figcaption>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.</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section id="ask">
|
||||
<h2>Just ask.</h2>
|
||||
@@ -188,7 +196,8 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl
|
||||
<div class="card ask"><div class="say">Find the receipt for the $106.38 Apple charge in my email and record it.</div><p>Searches your inbox, files the receipt as a PDF, drafts the entry with the right accounts, and shows you the preview.</p></div>
|
||||
<div class="card ask"><div class="say">Here's last month's bank export. Categorize everything.</div><p>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.</p></div>
|
||||
<div class="card ask"><div class="say">The domain renewal went to the wrong account. Fix it.</div><p>Reverses the entry and records it correctly. Nothing is ever erased; the history shows exactly what changed and why.</p></div>
|
||||
<div class="card ask"><div class="say">Why did software spending jump in March?</div><p>Traces the number to the entries and receipts behind it, and tells you what's new.</p></div>
|
||||
<div class="card ask"><div class="say">Show me every Adobe charge this year.</div><p>Opens the search in the viewer beside you, every charge listed, each one a click from its receipt.</p></div>
|
||||
<div class="card ask"><div class="say">Why did software spending jump in March?</div><p>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.</p></div>
|
||||
<div class="card ask"><div class="say">Can we afford to hire someone at $6,000 a month?</div><p>Works it out from your actual cash, burn, and what's coming in, and shows its math.</p></div>
|
||||
<div class="card ask"><div class="say">We paid the designer $2,400 this year. Does she need a 1099?</div><p>Checks the threshold for the year, whether payments were by card, and whether her W-9 is on file.</p></div>
|
||||
<div class="card ask"><div class="say">Close September.</div><p>Checks for missing receipts and unposted drafts, reconciles to your bank statement, posts depreciation and amortization, and closes the period.</p></div>
|
||||
@@ -253,7 +262,7 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl
|
||||
|
||||
<section>
|
||||
<div class="two">
|
||||
<div class="shot"><img src="assets/overview.png" alt="The viewer's overview: cash, what you owe, spending, revenue, runway, a cash chart, and what needs attention" width="1440" height="920"></div>
|
||||
<div class="shot"><img src="assets/cash.png" alt="The viewer's cash page: account balances, cash over time, money in and out by month, runway, and what's coming up" width="1440" height="920"></div>
|
||||
<div>
|
||||
<h2>Talk to change. Look in the viewer.</h2>
|
||||
<p class="sub">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.</p>
|
||||
|
||||
Reference in new issue
Block a user