feat(mcp): books.journal_history, the journal in words (P5)
Agent reason: an agent traces a figure back to what produced it by reading
the journal, but it must never read the file. books.journal_history is the
one way: each line in order with its seq, time, type, a sentence saying what
it did to what, its subject and the entry it belongs to, filtered by
after_seq, type (exact, or a group like entry.) and subject (entry:12,
invoice:3, bill:3, account:10, party:5, asset:2), paged with more, with the
torn tails set aside on open listed beside them. It reads the verified
journal through read_journal_and_format, so a damaged chain is an error, not
a story.
The sentences come from ledger-store's describe.rs, which quotes only
identifiers, dates, amounts and the short names a person typed: never a
setting's value (settings.set names its keys) and never a full TIN (masked,
B-26). Every event type the store writes has a sentence (tested against
EVENT_TYPES). The tool is read-only (books:read); the books://journal/{seq}
and books://entries/{id} resources read through it.
Departure from the plan: the viewer's own sentences (its describe arms in
ledger-gui, which carry icons, routes and today's party and account names)
stay where they are; moving a thousand lines of egui text behind a
formatting trait was not worth the risk to the viewer's tests, so the server
has the shared describer and the viewer keeps its own.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
1 parent
4236f03744
commit
61da9e57cd
8 files changed
+594
-3
No files matched your search
@@ -125,7 +125,7 @@ fn mcp_end_to_end() {
|
||||
// Tool listing: the 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(), 133, "expected 133 tools, got {}", tools.len());
|
||||
assert_eq!(tools.len(), 134, "expected 134 tools, got {}", tools.len());
|
||||
// Files come in as tool input: books.begin_upload, never a local path.
|
||||
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
|
||||
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
|
||||
|
||||
@@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed;
|
||||
const K: Needs = PostForApprovalRules;
|
||||
|
||||
/// Every tool and what it needs (INVARIANT 1).
|
||||
pub const TABLE: [(&str, Needs); 133] = [
|
||||
pub const TABLE: [(&str, Needs); 134] = [
|
||||
("books.handbook", R),
|
||||
("books.start", R),
|
||||
// Settings are committed at once: the settings commit of the plan.
|
||||
@@ -200,6 +200,8 @@ pub const TABLE: [(&str, Needs); 133] = [
|
||||
("books.list_drafts", R),
|
||||
("books.set_draft_state", W),
|
||||
("books.discard_draft", C),
|
||||
// P5
|
||||
("books.journal_history", R),
|
||||
("books.preview_entry", R),
|
||||
];
|
||||
|
||||
|
||||
@@ -584,6 +584,7 @@ impl BooksServer {
|
||||
+ Self::tax_router()
|
||||
+ Self::write_offs_router()
|
||||
+ Self::drafts_router()
|
||||
+ Self::journal_router()
|
||||
}
|
||||
|
||||
/// The settings in force for this request: the books' own, taken from
|
||||
@@ -1403,6 +1404,8 @@ mod backup_tools;
|
||||
mod files;
|
||||
#[path = "tools/instruments.rs"]
|
||||
mod instruments;
|
||||
#[path = "tools/journal.rs"]
|
||||
mod journal_tools;
|
||||
#[path = "tools/kept.rs"]
|
||||
mod kept;
|
||||
#[path = "tools/projects.rs"]
|
||||
@@ -1539,6 +1542,34 @@ impl BooksServer {
|
||||
.await?;
|
||||
json(&out)
|
||||
}
|
||||
_ if path.starts_with("books://entries/") => {
|
||||
let id: u64 = path["books://entries/".len()..]
|
||||
.parse()
|
||||
.map_err(|_| format!("bad entry resource URI: {uri}"))?;
|
||||
let Json(out) = self
|
||||
.get_entry(Parameters(GetEntryParams { entry_id: id }))
|
||||
.await?;
|
||||
json(&out)
|
||||
}
|
||||
_ if path.starts_with("books://journal/") => {
|
||||
let seq: u64 = path["books://journal/".len()..]
|
||||
.parse()
|
||||
.map_err(|_| format!("bad journal resource URI: {uri}"))?;
|
||||
if seq == 0 {
|
||||
return Err("journal seqs start at 1".into());
|
||||
}
|
||||
let Json(out) = self
|
||||
.journal_history(Parameters(journal_tools::JournalHistoryParams {
|
||||
after_seq: Some(seq - 1),
|
||||
limit: Some(1),
|
||||
..Default::default()
|
||||
}))
|
||||
.await?;
|
||||
if out.lines.first().is_none_or(|l| l.seq != seq) {
|
||||
return Err(format!("the journal has no line {seq}"));
|
||||
}
|
||||
json(&out)
|
||||
}
|
||||
handbook::URI => {
|
||||
let Json(out) = self.handbook(Parameters(HandbookParams::default())).await?;
|
||||
json(&out)
|
||||
|
||||
@@ -42,7 +42,7 @@ fn annotations_complete() {
|
||||
const D: Hints = (Some(false), Some(true), None);
|
||||
const WI: Hints = (Some(false), Some(false), Some(true));
|
||||
const DI: Hints = (Some(false), Some(true), Some(true));
|
||||
let table: [(&str, Hints); 133] = [
|
||||
let table: [(&str, Hints); 134] = [
|
||||
("books.handbook", R),
|
||||
("books.start", R),
|
||||
("books.set_setting", DI),
|
||||
@@ -178,6 +178,7 @@ fn annotations_complete() {
|
||||
("books.list_drafts", R),
|
||||
("books.set_draft_state", W),
|
||||
("books.discard_draft", D),
|
||||
("books.journal_history", R),
|
||||
("books.preview_entry", R),
|
||||
];
|
||||
let tools = BooksServer::tool_router().list_all();
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! `books.journal_history`: the journal in words (plan P5 step 4).
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: an agent can trace what the books did, line by line, without the
|
||||
//! journal file: the only way to "read the journal".
|
||||
//! SOURCE: plan/served.md 4.3 (tracing figures), P5 step 4; the describer
|
||||
//! is ledger_store::describe.
|
||||
//! INVARIANTS:
|
||||
//! 1. Read-only; needs books:read; at most 200 lines a call, 50 by
|
||||
//! default, in journal order from the line after `after_seq`.
|
||||
//! 2. A sentence quotes no setting value and no full TIN
|
||||
//! (ledger_store::describe INVARIANT 2).
|
||||
//! 3. `subject` is `kind:id`; a bad one is refused with the shapes.
|
||||
//! ```
|
||||
|
||||
use super::*;
|
||||
use ledger_store::describe::{HistoryFilter, parse_subject};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The most lines one call returns.
|
||||
const HISTORY_MAX: usize = 200;
|
||||
/// The lines a call returns when it names no limit.
|
||||
const HISTORY_DEFAULT: usize = 50;
|
||||
|
||||
/// Parameters for `books.journal_history`.
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct JournalHistoryParams {
|
||||
/// Only lines after this journal seq (default: from the first).
|
||||
#[serde(default)]
|
||||
pub after_seq: Option<u64>,
|
||||
/// Only these line types: an exact type (`entry.posted`) or a group
|
||||
/// ending in a dot (`entry.`).
|
||||
#[serde(default)]
|
||||
pub types: Vec<String>,
|
||||
/// Only lines about one subject: `entry:12`, `invoice:3`, `bill:3`,
|
||||
/// `account:10`, `party:5` or `asset:2`.
|
||||
#[serde(default)]
|
||||
pub subject: Option<String>,
|
||||
/// At most this many lines (default 50, most 200).
|
||||
#[serde(default)]
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
/// One journal line, in words.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct JournalLineOut {
|
||||
/// Its seq.
|
||||
pub seq: u64,
|
||||
/// When it was written (UTC).
|
||||
pub ts: String,
|
||||
/// Its type, e.g. `entry.posted`.
|
||||
#[serde(rename = "type")]
|
||||
pub line_type: String,
|
||||
/// What it did, in a sentence.
|
||||
pub summary: String,
|
||||
/// What it is about, as `kind:id`, if it is about one thing.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subject: Option<String>,
|
||||
/// The entry it belongs to, if it is about one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub entry_id: Option<u64>,
|
||||
}
|
||||
|
||||
/// A torn journal tail set aside when the books opened.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct TailRepairOut {
|
||||
/// When (UTC).
|
||||
pub at: String,
|
||||
/// How many bytes were set aside.
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
/// Result of `books.journal_history`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct JournalHistoryOut {
|
||||
/// The lines, in journal order.
|
||||
pub lines: Vec<JournalLineOut>,
|
||||
/// Whether more lines match after the last (ask again with its seq as
|
||||
/// after_seq).
|
||||
pub more: bool,
|
||||
/// The journal's last seq.
|
||||
pub head_seq: u64,
|
||||
/// Torn tails set aside on open, oldest first.
|
||||
pub tail_repairs: Vec<TailRepairOut>,
|
||||
}
|
||||
|
||||
#[tool_router(router = journal_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.journal_history",
|
||||
title = "Journal history",
|
||||
description = "The journal in words, in order: each line's seq, time, type and a sentence saying what it did, to what, and the entry it belongs to. Filter by after_seq, types (an exact type like entry.posted, or a group like entry.) and subject (entry:12, invoice:3, bill:3, account:10, party:5, asset:2). The way to trace a figure back to what produced it, and the only way to see what the journal holds. Read-only; at most 200 lines a call.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
pub(super) async fn journal_history(
|
||||
&self,
|
||||
params: Parameters<JournalHistoryParams>,
|
||||
) -> Result<Json<JournalHistoryOut>, String> {
|
||||
let p = params.0;
|
||||
let subject = p
|
||||
.subject
|
||||
.as_deref()
|
||||
.map(|s| parse_subject(s).map(|(k, id)| format!("{k}:{id}")))
|
||||
.transpose()?;
|
||||
let limit = (p.limit.unwrap_or(HISTORY_DEFAULT as u32) as usize).clamp(1, HISTORY_MAX);
|
||||
let filter = HistoryFilter {
|
||||
after_seq: p.after_seq.unwrap_or(0),
|
||||
types: p.types,
|
||||
subject,
|
||||
limit,
|
||||
};
|
||||
let store = self.lock()?;
|
||||
let h = store.journal_history(&filter).map_err(|e| e.to_string())?;
|
||||
Ok(Json(JournalHistoryOut {
|
||||
lines: h
|
||||
.lines
|
||||
.into_iter()
|
||||
.map(|d| JournalLineOut {
|
||||
seq: d.seq,
|
||||
ts: d.ts,
|
||||
line_type: d.line_type,
|
||||
summary: d.summary,
|
||||
subject: d.subjects.first().cloned(),
|
||||
entry_id: d.entry_id,
|
||||
})
|
||||
.collect(),
|
||||
more: h.more,
|
||||
head_seq: h.head_seq,
|
||||
tail_repairs: h
|
||||
.tail_repairs
|
||||
.into_iter()
|
||||
.map(|(at, bytes)| TailRepairOut { at, bytes })
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
//! The journal in words: one sentence for each line, and the subjects a
|
||||
//! line is about. The one describer the server's `books.journal_history`
|
||||
//! reads.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: an agent can read what the books did, in order, without the
|
||||
//! journal file: each line says in a sentence what happened, to
|
||||
//! what, and which entry it belongs to.
|
||||
//! SOURCE: plan/served.md P5 step 4 (the only way to "read the journal");
|
||||
//! docs/receipt-store.md (the lines and their payloads);
|
||||
//! docs/spec/rules.md B-26 (no output holds a full TIN).
|
||||
//! INVARIANTS:
|
||||
//! 1. Every line type the store writes has a sentence (test: every
|
||||
//! `format::EVENT_TYPES` entry).
|
||||
//! 2. A sentence quotes only identifiers, dates, amounts and the
|
||||
//! short names and descriptions a person typed; never a setting's
|
||||
//! value, a payload's secret, or a full TIN (they are masked).
|
||||
//! 3. Lines are returned in journal order, from the line after
|
||||
//! `after_seq`, at most `limit`; a filter never reorders them.
|
||||
//! 4. A subject is `kind:id` (`entry:12`, `invoice:3`, `bill:3`,
|
||||
//! `account:10`, `party:5`, `asset:2`); a line is about the subject
|
||||
//! its type names and every one its payload refers to.
|
||||
//! ```
|
||||
|
||||
use crate::journal::JournalLine;
|
||||
use serde_json::Value;
|
||||
|
||||
/// The most characters of any quoted text.
|
||||
const QUOTE_MAX: usize = 80;
|
||||
|
||||
/// One line of the journal, described.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Described {
|
||||
/// The line's seq.
|
||||
pub seq: u64,
|
||||
/// When it was written (UTC, RFC 3339).
|
||||
pub ts: String,
|
||||
/// Its type.
|
||||
pub line_type: String,
|
||||
/// A sentence.
|
||||
pub summary: String,
|
||||
/// The subjects it is about, as `kind:id`.
|
||||
pub subjects: Vec<String>,
|
||||
/// The entry it belongs to, if it is about one.
|
||||
pub entry_id: Option<u64>,
|
||||
}
|
||||
|
||||
/// Which lines to list (INVARIANT 3).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct HistoryFilter {
|
||||
/// Only lines after this seq.
|
||||
pub after_seq: u64,
|
||||
/// Only these types: an exact type (`entry.posted`) or a group ending
|
||||
/// in a dot (`entry.`). Empty: every type.
|
||||
pub types: Vec<String>,
|
||||
/// Only lines about this subject (`entry:12`).
|
||||
pub subject: Option<String>,
|
||||
/// At most this many.
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
/// A subject as `kind:id`, parsed.
|
||||
pub fn parse_subject(text: &str) -> Result<(String, u64), String> {
|
||||
let bad = || format!("subject {text:?}: give kind:id, like entry:12, invoice:3, bill:3, account:10, party:5 or asset:2");
|
||||
let (kind, id) = text.split_once(':').ok_or_else(bad)?;
|
||||
let kinds = [
|
||||
"entry", "invoice", "bill", "account", "party", "asset", "period", "schedule",
|
||||
"reconciliation", "payroll_run", "project",
|
||||
];
|
||||
if !kinds.contains(&kind.trim()) {
|
||||
return Err(bad());
|
||||
}
|
||||
let id = id.trim().parse::<u64>().map_err(|_| bad())?;
|
||||
Ok((kind.trim().to_string(), id))
|
||||
}
|
||||
|
||||
fn num(p: &Value, k: &str) -> Option<u64> {
|
||||
p.get(k).and_then(Value::as_u64)
|
||||
}
|
||||
|
||||
fn quote(text: &str) -> String {
|
||||
let t: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if t.chars().count() > QUOTE_MAX {
|
||||
let mut s: String = t.chars().take(QUOTE_MAX - 1).collect();
|
||||
s.push('…');
|
||||
s
|
||||
} else {
|
||||
t
|
||||
}
|
||||
}
|
||||
|
||||
/// The subjects a line is about (INVARIANT 4).
|
||||
pub fn subjects(line: &JournalLine) -> Vec<String> {
|
||||
let p = &line.payload;
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut add = |kind: &str, id: Option<u64>| {
|
||||
if let Some(id) = id {
|
||||
let s = format!("{kind}:{id}");
|
||||
if !out.contains(&s) {
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
};
|
||||
let prefix = line.line_type.split('.').next().unwrap_or("");
|
||||
let own = match prefix {
|
||||
"entry" => Some("entry"),
|
||||
"invoice" => Some("invoice"),
|
||||
"bill" => Some("bill"),
|
||||
"account" => Some("account"),
|
||||
"party" => Some("party"),
|
||||
"asset" => Some("asset"),
|
||||
"period" => Some("period"),
|
||||
"schedule" => Some("schedule"),
|
||||
"reconciliation" => Some("reconciliation"),
|
||||
"payroll" => Some("payroll_run"),
|
||||
"project" => Some("project"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(kind) = own {
|
||||
// `entry.posted` carries `entry_id`; `entry.created` carries `id`.
|
||||
let id = match prefix {
|
||||
"entry" => num(p, "entry_id").or_else(|| num(p, "id")),
|
||||
"invoice" => num(p, "invoice_id").or_else(|| num(p, "id")),
|
||||
"bill" => num(p, "bill_id").or_else(|| num(p, "id")),
|
||||
"asset" => num(p, "asset_id").or_else(|| num(p, "id")),
|
||||
"party" => num(p, "party_id").or_else(|| num(p, "id")),
|
||||
_ => num(p, "id"),
|
||||
};
|
||||
add(kind, id);
|
||||
}
|
||||
add("entry", num(p, "entry_id"));
|
||||
add("invoice", num(p, "invoice_id"));
|
||||
add("bill", num(p, "bill_id"));
|
||||
add("account", num(p, "account_id"));
|
||||
add("asset", num(p, "asset_id"));
|
||||
add("party", num(p, "party_id"));
|
||||
if line.line_type == "receipt.attached" || line.line_type == "receipt.linked" {
|
||||
add("entry", num(p, "entry_id"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A line's amount, when it has one: a string, or `{amount: ..}`.
|
||||
fn amount_of(p: &Value) -> Option<String> {
|
||||
let a = p.get("amount")?;
|
||||
a.as_str()
|
||||
.map(str::to_string)
|
||||
.or_else(|| a.get("amount").and_then(Value::as_str).map(str::to_string))
|
||||
}
|
||||
|
||||
/// The sentence for a line (INVARIANT 2).
|
||||
pub fn describe(line: &JournalLine) -> String {
|
||||
let p = &line.payload;
|
||||
let ty = line.line_type.as_str();
|
||||
let mut head = ty.replace(['.', '_'], " ");
|
||||
if let Some(first) = head.get(0..1) {
|
||||
head = first.to_uppercase() + &head[1..];
|
||||
}
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
// What it is about, by id.
|
||||
for (k, label) in [
|
||||
("entry_id", "entry"),
|
||||
("invoice_id", "invoice"),
|
||||
("bill_id", "bill"),
|
||||
("account_id", "account"),
|
||||
("asset_id", "asset"),
|
||||
("party_id", "party"),
|
||||
("attachment_id", "attachment"),
|
||||
] {
|
||||
if let Some(n) = num(p, k) {
|
||||
parts.push(format!("{label} {n}"));
|
||||
}
|
||||
}
|
||||
if parts.is_empty()
|
||||
&& let Some(n) = num(p, "id")
|
||||
{
|
||||
parts.push(format!("#{n}"));
|
||||
}
|
||||
// What a person typed about it.
|
||||
for k in ["description", "title", "name", "subject", "code"] {
|
||||
if let Some(t) = p.get(k).and_then(Value::as_str)
|
||||
&& !t.is_empty()
|
||||
{
|
||||
parts.push(quote(t));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(a) = amount_of(p) {
|
||||
parts.push(a);
|
||||
}
|
||||
if let Some(d) = p.get("date").and_then(Value::as_str) {
|
||||
parts.push(d.to_string());
|
||||
}
|
||||
// Settings: which keys, never their values.
|
||||
if ty == "settings.set"
|
||||
&& let Some(changes) = p.get("changes").and_then(Value::as_array)
|
||||
{
|
||||
let keys: Vec<&str> = changes
|
||||
.iter()
|
||||
.filter_map(|c| c.get("key").and_then(Value::as_str))
|
||||
.take(8)
|
||||
.collect();
|
||||
parts.push(keys.join(", "));
|
||||
}
|
||||
let text = if parts.is_empty() {
|
||||
head
|
||||
} else {
|
||||
format!("{head}: {}", parts.join(", "))
|
||||
};
|
||||
ledger_core::parties::redact_full_tins(&text).into_owned()
|
||||
}
|
||||
|
||||
/// Describe one line.
|
||||
pub fn describe_line(line: &JournalLine) -> Described {
|
||||
let subjects = subjects(line);
|
||||
let entry_id = subjects
|
||||
.iter()
|
||||
.find_map(|s| s.strip_prefix("entry:").and_then(|n| n.parse().ok()));
|
||||
Described {
|
||||
seq: line.seq,
|
||||
ts: line.ts.clone(),
|
||||
line_type: line.line_type.clone(),
|
||||
summary: describe(line),
|
||||
subjects,
|
||||
entry_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// The lines `filter` asks for, described, in journal order (INVARIANT 3),
|
||||
/// and whether more match after the last one returned.
|
||||
pub fn history(lines: &[JournalLine], filter: &HistoryFilter) -> (Vec<Described>, bool) {
|
||||
let wanted = |l: &JournalLine| {
|
||||
l.seq > filter.after_seq
|
||||
&& (filter.types.is_empty()
|
||||
|| filter.types.iter().any(|t| {
|
||||
if t.ends_with('.') {
|
||||
l.line_type.starts_with(t.as_str())
|
||||
} else {
|
||||
l.line_type == *t
|
||||
}
|
||||
}))
|
||||
&& filter
|
||||
.subject
|
||||
.as_ref()
|
||||
.is_none_or(|s| subjects(l).contains(s))
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut more = false;
|
||||
for l in lines.iter().filter(|l| wanted(l)) {
|
||||
if out.len() == filter.limit {
|
||||
more = true;
|
||||
break;
|
||||
}
|
||||
out.push(describe_line(l));
|
||||
}
|
||||
(out, more)
|
||||
}
|
||||
|
||||
/// The journal in words, read from the books' own journal file.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct History {
|
||||
/// The lines asked for, in order.
|
||||
pub lines: Vec<Described>,
|
||||
/// Whether more match after the last.
|
||||
pub more: bool,
|
||||
/// The journal's last seq.
|
||||
pub head_seq: u64,
|
||||
/// Torn tails set aside on open: (when, bytes).
|
||||
pub tail_repairs: Vec<(String, u64)>,
|
||||
}
|
||||
|
||||
impl crate::Store {
|
||||
/// `filter`'s lines of the journal, described (the only way an agent
|
||||
/// reads the journal: `books.journal_history`). The file is read and
|
||||
/// its chain verified, as `Store::verify` does.
|
||||
pub fn journal_history(&self, filter: &HistoryFilter) -> Result<History, crate::StoreError> {
|
||||
let (lines, _format) = crate::journal::read_journal_and_format(
|
||||
&self.data_dir().join(crate::store::JOURNAL_FILE),
|
||||
)?;
|
||||
let (described, more) = history(&lines, filter);
|
||||
let tail_repairs = self
|
||||
.tail_repairs()?
|
||||
.into_iter()
|
||||
.map(|(when, bytes, _saved_as)| (when, bytes))
|
||||
.collect();
|
||||
Ok(History {
|
||||
lines: described,
|
||||
more,
|
||||
head_seq: lines.last().map_or(0, |l| l.seq),
|
||||
tail_repairs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn line(seq: u64, ty: &str, payload: Value) -> JournalLine {
|
||||
JournalLine {
|
||||
seq,
|
||||
ts: "2026-10-29T00:00:00Z".into(),
|
||||
line_type: ty.into(),
|
||||
payload,
|
||||
prev: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_event_type_has_a_sentence() {
|
||||
for ty in crate::format::EVENT_TYPES {
|
||||
let s = describe(&line(1, ty, json!({})));
|
||||
assert!(!s.is_empty(), "{ty}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sentence_names_the_entry_and_what_a_person_typed() {
|
||||
let l = line(
|
||||
5,
|
||||
"entry.posted",
|
||||
json!({"entry_id": 12, "description": "Office supplies"}),
|
||||
);
|
||||
assert_eq!(describe(&l), "Entry posted: entry 12, Office supplies");
|
||||
assert_eq!(subjects(&l), vec!["entry:12"]);
|
||||
assert_eq!(describe_line(&l).entry_id, Some(12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_show_their_keys_never_their_values() {
|
||||
let l = line(
|
||||
2,
|
||||
"settings.set",
|
||||
json!({"changes": [{"key": "company.ein", "value": "12-3456789"}]}),
|
||||
);
|
||||
let s = describe(&l);
|
||||
assert!(s.contains("company.ein") && !s.contains("12-3456789"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_tin_in_a_description_is_masked() {
|
||||
let l = line(3, "entry.created", json!({"id": 1, "description": "TIN 123-45-6789"}));
|
||||
assert!(!describe(&l).contains("6789"), "{}", describe(&l));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_filters_in_order_and_says_when_more_remain() {
|
||||
let lines: Vec<JournalLine> = (1..=6)
|
||||
.map(|i| {
|
||||
line(
|
||||
i,
|
||||
if i % 2 == 0 { "entry.posted" } else { "entry.created" },
|
||||
json!({"entry_id": i % 3, "id": i % 3}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let f = HistoryFilter {
|
||||
after_seq: 1,
|
||||
types: vec!["entry.posted".into()],
|
||||
subject: None,
|
||||
limit: 2,
|
||||
};
|
||||
let (got, more) = history(&lines, &f);
|
||||
assert_eq!(got.iter().map(|d| d.seq).collect::<Vec<_>>(), vec![2, 4]);
|
||||
assert!(more);
|
||||
let f = HistoryFilter {
|
||||
subject: Some("entry:0".into()),
|
||||
limit: 10,
|
||||
..Default::default()
|
||||
};
|
||||
let (got, more) = history(&lines, &f);
|
||||
assert_eq!(got.iter().map(|d| d.seq).collect::<Vec<_>>(), vec![3, 6]);
|
||||
assert!(!more);
|
||||
assert!(parse_subject("entry:12").is_ok());
|
||||
assert!(parse_subject("nonsense").is_err());
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod blob;
|
||||
pub mod chain;
|
||||
pub mod describe;
|
||||
pub mod error;
|
||||
pub mod exports;
|
||||
pub mod form1099;
|
||||
|
||||
@@ -2648,3 +2648,44 @@ state and discard tools wait for the books' format upgrade).
|
||||
drafts_open_on_or_before: [draft], unreconciled: [...], settings_problems:
|
||||
[...] }` for the report's end date, and take `strict: true` to be refused,
|
||||
naming what is open, instead of warned.
|
||||
|
||||
## Tools — the journal in words (P5)
|
||||
|
||||
- `books.journal_history { after_seq?, types?, subject?, limit? }` → `{
|
||||
lines: [{ seq, ts, type, summary, subject?, entry_id? }], more, head_seq,
|
||||
tail_repairs: [{ at, bytes }] }`. The journal in order, a sentence for
|
||||
each line (`summary`: what it did, to what, the short names a person
|
||||
typed; never a setting's value, never a full TIN), the line's own
|
||||
`subject` (`entry:12`, `invoice:3`, `bill:3`, `account:10`, `party:5`,
|
||||
`asset:2`) and the entry it belongs to. `types` takes an exact type
|
||||
(`entry.posted`) or a group ending in a dot (`entry.`); `subject` keeps
|
||||
the lines about one thing (a line is about the subject its type names and
|
||||
every one its payload refers to); `limit` is 50 by default, 200 at most;
|
||||
`more` says to ask again with the last `seq` as `after_seq`. Read-only,
|
||||
`books:read`. It is how a figure is traced to what produced it and the
|
||||
only way to read the journal; `tail_repairs` lists torn tails set aside
|
||||
when the books opened (when, how many bytes). The sentences come from
|
||||
`ledger-store`'s `describe.rs`.
|
||||
|
||||
## Prompts (P5)
|
||||
|
||||
The server advertises the prompts capability (also in the bridge's cold
|
||||
`initialize`). `prompts/list` and `prompts/get` need a token that may read.
|
||||
Each prompt's text is a file in `ledger-mcp/prompts/`, with the company's
|
||||
name and the arguments filled in; it names only tools that exist (a test
|
||||
checks). An argument is one token (letters, digits, `-`, `_`, `.`, `:`; at
|
||||
most 40 characters) of the shape the prompt asks for, so an argument can
|
||||
never rewrite the text; an unknown prompt, a missing, extra or malformed
|
||||
argument is refused.
|
||||
|
||||
| Prompt | Arguments | Drives |
|
||||
|---|---|---|
|
||||
| `onboard` | none | the company's facts, the chart, the policies, the opening balances, the trial-balance check |
|
||||
| `reconcile` | `account` (a code), `month` (YYYY-MM) | the statement, `books.record_statement`, `books.mark_cleared`, `books.reconcile` |
|
||||
| `month_end_close` | `month` (YYYY-MM) | the company's own close steps, reconciliations, drafts, schedules, checks, `books.close_period` |
|
||||
| `cpa_handoff` | `year` (YYYY) | `books.verify`, the periods, 1099 work, `books.export_cpa_package` |
|
||||
| `annual_compliance` | `year` (YYYY) | the year's filings, 1099s, assets, open items |
|
||||
| `audit` | `scope` (`month`, `annual`, `cpa_handoff` or `full`) | `books.verify`, `books.control_check`, the digest, evidence against entries (P8 fills in its own tools) |
|
||||
|
||||
`migrate`, `process_inbox` and `receipts_from_email` are not built (D-46,
|
||||
D-28, D-17).
|
||||
Reference in new issue
Block a user