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
@@ -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(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user