diff --git a/CHANGELOG.md b/CHANGELOG.md index b3bae4c..48d8048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,42 @@ unless turned off. Fresh installs and upgrades from 1.7.0 both get them. have measured the gate. The committed pre-release results were taken with off as the default, so they are the same measurement. +### The app can turn repeat reads off, and shows what they have done + +Turning repeat reads off meant an environment variable or a hand-edited JSON file, and nothing +showed what they had done. Nothing the MCP server sends changes: no tool's output, `tools/list` or +hook. + +- **A Repeat reads switch** on the Context tab of the main window, on by default. It writes the + `lumen_settings.json` described above (atomically, readable by its owner only, the other keys of + a valid file kept), so a change applies to the next Lumen read in every running Claude Code + session with no restart, and it survives a restart, unlike the alert thresholds beside it. +- **It shows the settings file, not the server.** The window shows what the file says, or the + default when there is no file. It does not read `LUMEN_REPEAT_READS`: a value in a Claude Code + MCP config outranks the file and the app cannot see it, nor a `LUMEN_DB` there that makes the + server read another ledger's settings file. Its text says both. +- **A file that cannot be used is shown as off, with the reason**: the same off the server + applies, which it records as a `repeat_reads_config` fault. Switching on rewrites the file. A + setting that cannot be read shows neither state and offers Retry; a change that cannot be saved + puts the switch back and says why. Nothing is shown as saved that was not. +- **`lumen doctor` and the server's message about an unusable settings file** name the switch as + the way to repair it, where they said only to edit or delete the file. +- **A Repeat reads card on the Optimizer tab**: the unchanged answers and the deltas served, the + tokens they did not re-send, and how often the full result was asked for again with `fresh=true`, + as n of m (m is the unchanged answers, the deltas and the `fresh` requests together; only + requests made while repeat reads were on are counted, so it stops moving when they are off). + It is counted apart from every total on the tab: in none of the token totals, the by-tool and + by-channel breakdowns or the net value (the provenance counts do include these rows). It shows + no dollar figure and no percentage. The tokens are counted against reading the whole file again, + which is more than the outline a read returns with repeat reads off, so the card does not call + them a saving and says what they are counted against. The figure covers only answers whose token + count was measured, says how many when that is fewer than all of them, and is a dash when none + was. The card says when the settings file turns repeat reads off, and counts the reads answered in + full because Lumen could not tell which conversation they came from, with a link to Setup (the + app install's remedy; on the plugin, update or reinstall it, as the upgrade notes above say). +- **Stats.** The optimizer report gains `repeatReads`; there is no schema change. A backend that + does not send it gets no card. + ### Repeat reads follow the conversation, not the process (#32) `/clear` and `/resume` keep `lumen-mcp` running, and it read `CLAUDE_CODE_SESSION_ID` once at diff --git a/README.md b/README.md index e236795..bf06f19 100644 --- a/README.md +++ b/README.md @@ -423,6 +423,12 @@ the redirect, or the file was excluded. These are tracked as context (never as s so you can see the true adoption rate. A high "not optimized" count in Full mode suggests Claude is bypassing the redirect; see [Verify it's working](#verify-its-working). +#### Repeat reads + +A card that counts what [repeat reads](#repeat-reads-on-by-default-since-180) answered short, +apart from every figure above it, and says what its token figure is counted against. What it +shows, and how to read it, is described under that heading. + #### Mode banner (Full / Soft) A persistent badge on the Optimizer tab showing which mode the current (or most recent) @@ -740,8 +746,11 @@ each `(session, request)` and answers a repeat with less. The built-in Read is n Claude Code already dedups it. It was an opt-in flag in 1.7.0 (`LUMEN_REPEAT_READS=on`); from 1.8.0 it is on unless you turn it off. -**Turning it off.** Either of these, and the environment variable wins when both are set: +**Turning it off.** Any of these, and the environment variable wins when it says something: +- the **Repeat reads** switch on the Context tab of the Lumen app's main window. It writes the + settings file below, so a change applies to the next Lumen read in every running Claude Code + session, with no restart, and it is still there after Lumen or Claude Code restarts. - the environment variable, in the `env` block of `~/.claude/settings.json` (or wherever Claude Code gets its environment): `"env": { "LUMEN_REPEAT_READS": "off" }`. It is read when Claude Code starts the server, so it applies from the next Claude Code session. @@ -752,6 +761,14 @@ Claude Code already dedups it. It was an opt-in flag in 1.7.0 (`LUMEN_REPEAT_REA it on every Lumen call (about 30 microseconds), so a change applies to the next call, with no restart. `lumen doctor` prints the path it is using and the state. +The switch shows what the settings file says, or the default (on) when there is no file, and +nothing else. It cannot see a `LUMEN_REPEAT_READS` in the environment Claude Code gives the server, +which wins over the file, nor a `LUMEN_DB` there that points the server at a ledger elsewhere, whose +own settings file is then the one that counts; its text says so. A file it cannot use is shown as +off, with the reason, and switching on replaces it; the other keys of a file that is valid are kept. +When the setting cannot be read, or a change cannot be saved, the window says that. It does not +show on or off in the meantime. + With it off every read is answered as it always was, nothing is remembered, and the `fresh` option is not advertised. Switching from off to on in the middle of a session leaves the client's cached tool list without `fresh` until the next session; that is harmless, because the @@ -833,6 +850,33 @@ The size of a delta is measured per delta served against the outline it replaces the `fresh=true` sentence included; it is not an overall saving. Overall effect, follow-up rate and the `fresh=true` rate are measured live, in the `ab` split. +**What the Optimizer tab shows (1.8.0).** A Repeat reads card, below the other figures, counts +what repeat reads have done over the ledger's lifetime. It is counted apart from the rest: these +answers are in none of the token totals (Lumen optimized, Today, This week), the by-tool and +by-channel breakdowns or the net value, and the card never calls anything a saving. (The +provenance counts at the top of the tab, how many metered events have unverified token +provenance, still include these rows: they count every row that carries a token count.) + +- *Unchanged answers served* and *Edits served as a delta*: how many repeat reads got the + one-sentence note and how many got a delta. +- *Tokens not re-sent*: for those answers, the whole file's tokens minus the answer's, added up. + Read its baseline carefully: it is counted against reading the whole file again. With repeat + reads off a read returns the outline (for `recall_file`, the symbols asked for), which is + usually far smaller than the file, so this is more than repeat reads gain over having them off, + and it is not shown as a saving. It counts only answers whose token count was measured: when + that is fewer than all of them the card says how many, and when it is none the figure is a dash. +- *Full result asked for again (`fresh=true`)*: n of m, where m is the unchanged answers, the + deltas and the `fresh` requests together, so it is how often the model did not take a repeat + answer. Only requests made while repeat reads were on are counted (under `ab`, the half that + takes the new path), so the figure stops moving when they are turned off. +- When the settings file turns repeat reads off, the card says so, and that a + `LUMEN_REPEAT_READS` in a Claude Code MCP config would outrank the file unseen. It never says + what the server will do, for the same reason the switch cannot. +- Reads answered in full because Lumen could not tell which conversation they came from (the plan + `unidentified`, counted while repeat reads were on): the count, with an **Open Setup** link + to the app's Setup screen, which runs Setup, after which Claude Code needs a restart. That + remedy is for the app install; on the plugin, update or reinstall it, as in the table above. + ### Experimental: ranked outline (1.3.0, off by default) `smart_read`'s outline can be sized by the economics of the call rather than by a fixed diff --git a/crates/lumen-cli/src/doctor.rs b/crates/lumen-cli/src/doctor.rs index 5b7d703..a7eb858 100644 --- a/crates/lumen-cli/src/doctor.rs +++ b/crates/lumen-cli/src/doctor.rs @@ -124,7 +124,9 @@ impl Finding { ), lumen_core::settings::Problem::File(_) => format!( "fix {file} so it reads {{\"repeat_reads\": true}} (or false), or delete it \ - to get the default, on. It applies to the next Lumen call, with no restart." + to get the default, on. In the Lumen app, the Repeat reads switch on the \ + Context tab of the main window rewrites it. It applies to the next Lumen \ + call, with no restart." ), }, } @@ -1245,6 +1247,30 @@ mod tests { assert!(render(&f).contains("\"enabled\""), "{}", render(&f)); } + #[test] + fn the_remedy_for_an_unreadable_file_names_the_switch_in_the_app() { + use lumen_core::settings::FileState; + let f = Facts { + repeat_reads: Some(resolved(None, FileState::Text("{broken".into()))), + repeat_reads_file: Some("/data/lumen_settings.json".into()), + ..healthy() + }; + let fix = findings(&f)[0].remedy(); + assert!( + fix.contains("Repeat reads switch on the Context tab of the main window"), + "{fix}" + ); + assert!(fix.contains("rewrites"), "{fix}"); + + // A bad variable is not something the switch can fix: the variable wins over the file. + let f = Facts { + repeat_reads: Some(resolved(Some("enabled"), FileState::Absent)), + ..healthy() + }; + let fix = findings(&f)[0].remedy(); + assert!(!fix.contains("Context tab"), "{fix}"); + } + #[test] fn a_healthy_default_install_says_on_by_default_and_reports_nothing() { let f = Facts { diff --git a/crates/lumen-core/src/settings.rs b/crates/lumen-core/src/settings.rs index 33dbdaf..845eed5 100644 --- a/crates/lumen-core/src/settings.rs +++ b/crates/lumen-core/src/settings.rs @@ -75,8 +75,10 @@ impl Problem { ), Problem::File(why) => format!( "{} cannot be used ({why}): repeat reads are off, every read is in full. \ - Fix or delete the file (deleting it gives the default, on), or set \ - {REPEAT_READS_ENV} to on or off, which takes precedence over the file.", + Fix or delete the file (deleting it gives the default, on), or choose a \ + setting with the Repeat reads switch on the Context tab of the main window \ + in the Lumen app, which rewrites it. Setting {REPEAT_READS_ENV} to on or off \ + takes precedence over the file.", file.display() ), } @@ -264,10 +266,8 @@ pub fn surface(problem: &Problem, file: &Path, min_interval: std::time::Duration /// Write the persisted setting for a ledger at `db`. /// -/// Nothing in the app calls this yet: there is no toggle, so today the file is edited by hand -/// or `LUMEN_REPEAT_READS` is set (the README and `lumen doctor` say so). It is the writer a -/// toggle would use, kept beside the reader so the two agree on the format, and the tests -/// round-trip it. +/// The Repeat reads switch in the Lumen app (`set_repeat_reads_setting`) calls this. +/// It sits beside the reader so the two agree on the format, and the tests round-trip it. /// /// Atomic (written beside, renamed into place) so a reader never sees half a file, and /// private to this user. Other keys in a valid existing file are kept. A file that cannot be @@ -461,12 +461,17 @@ mod tests { assert!(said.contains("/data/lumen_settings.json"), "{said}"); assert!(said.contains("delete the file"), "{said}"); assert!(said.contains(REPEAT_READS_ENV), "{said}"); - // There is no switch in the app to go back to: do not send anyone looking for one. - assert!(!said.contains("in the app"), "{said}"); + // The main window's switch rewrites the file, so it repairs a file that cannot be used. + assert!( + said.contains("Repeat reads switch on the Context tab of the main window"), + "{said}" + ); + // It writes the file, and the variable outranks the file: a bad value there is not + // something it can fix, so that message must not send anyone to it. assert!( !Problem::Env("x".into()) .describe(file) - .contains("in the app"), + .contains("Context tab"), "the same for the environment variable's message" ); } @@ -541,6 +546,22 @@ mod tests { assert_eq!(mode & 0o077, 0, "{mode:o}"); } + #[cfg(unix)] + #[test] + fn a_reader_holding_the_file_open_still_sees_the_whole_old_one_after_a_write() { + use std::io::Read; + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("ledger.db"); + write_repeat_reads(&db, false).unwrap(); + let before = std::fs::read_to_string(settings_path(&db)).unwrap(); + let mut held = std::fs::File::open(settings_path(&db)).unwrap(); + write_repeat_reads(&db, true).unwrap(); + let mut seen = String::new(); + held.read_to_string(&mut seen).unwrap(); + assert_eq!(seen, before); + assert_eq!(read_file_mode(&db), Mode::On); + } + #[test] fn the_writer_creates_a_missing_directory() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/lumen-stats/src/lib.rs b/crates/lumen-stats/src/lib.rs index bc246e9..eb03767 100644 --- a/crates/lumen-stats/src/lib.rs +++ b/crates/lumen-stats/src/lib.rs @@ -10,6 +10,10 @@ // `rename_all = "camelCase"` attributes are load-bearing: renaming a field here // silently breaks the UI. +use lumen_core::repeat::{ + ARM_TREATMENT, PLAN_FRESH, PLAN_UNIDENTIFIED, ROUTE_DELTA, ROUTE_RECALL_DELTA, + ROUTE_RECALL_UNCHANGED, ROUTE_UNCHANGED, +}; use serde::Serialize; use sqlx::SqlitePool; @@ -345,6 +349,38 @@ pub struct ToolBreakdown { pub full_tokens: i64, } +/// What repeat reads did, counted apart from everything else in [`OptimizerReport`]. +/// +/// A served answer is a row whose route is one of the four repeat routes. None of them is in +/// [`LUMEN_ROUTES`], so the token totals, the breakdowns and the net value above do not include +/// these rows; the provenance counts, which take every row that carries a count, do. +/// +/// Served rows are found by route and by nothing else. In the A/B experiment a control row +/// records the plan the server WOULD have run (`unchanged`, `delta`) under an ordinary route: +/// nothing was served, so counting by plan would count answers nobody was given. +#[derive(Serialize, Debug, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RepeatReads { + /// Reads answered "unchanged since an earlier read" instead of with the result again. + pub unchanged_served: i64, + /// Reads of an edited file answered with only what changed. + pub deltas_served: i64, + /// `saved_tokens` of the served answers whose count was measured: what each did not send, + /// against reading the whole file again. Not against the outline the same call would have + /// returned with the feature off, which is a smaller baseline. + pub tokens_not_resent: i64, + /// The served answers behind `tokens_not_resent`. Fewer than `unchanged_served + + /// deltas_served` means the others carry no measured count and add nothing to it. + pub measured_answers: i64, + /// Calls that passed `fresh=true` while repeat reads were answering (the treatment arm): the + /// whole result asked for again. A control call, or one made while the feature was off, was + /// not a request to bypass an answer. + pub fresh_requests: i64, + /// Calls answered in full because Lumen could not tell which conversation they came from, + /// in either arm: it says something about the install, not about the feature. + pub unidentified_reads: i64, +} + #[derive(Serialize, Debug, PartialEq)] #[serde(rename_all = "camelCase")] pub struct OptimizerReport { @@ -400,6 +436,8 @@ pub struct OptimizerReport { /// Rounds each intercept actually costs. Measured at 1.604 — 60.4% of `smart_read` /// calls are followed by a `recall_file` on the same file. pub pair_multiplier: f64, + /// Repeat reads, lifetime. Counted apart from every figure above. + pub repeat_reads: RepeatReads, } pub async fn get_optimizer_stats(pool: &SqlitePool) -> Result { @@ -597,6 +635,8 @@ pub async fn get_optimizer_stats(pool: &SqlitePool) -> Result Result Result { + // The four routes are found by name and the plan is never consulted for them: a control-arm + // row has the same plan and an ordinary route, and nothing was served. + let ( + unchanged_served, + deltas_served, + tokens_not_resent, + measured_answers, + fresh_requests, + unidentified_reads, + ): (i64, i64, i64, i64, i64, i64) = sqlx::query_as( + "SELECT + COALESCE(SUM(CASE WHEN routed_via IN (?1, ?2) THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN routed_via IN (?3, ?4) THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN routed_via IN (?1, ?2, ?3, ?4) AND token_source = 'measured' + THEN saved_tokens ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN routed_via IN (?1, ?2, ?3, ?4) AND token_source = 'measured' + THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN repeat_plan = ?5 AND repeat_arm = ?6 THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN repeat_plan = ?7 THEN 1 ELSE 0 END), 0) + FROM read_events", + ) + .bind(ROUTE_UNCHANGED) + .bind(ROUTE_RECALL_UNCHANGED) + .bind(ROUTE_DELTA) + .bind(ROUTE_RECALL_DELTA) + .bind(PLAN_FRESH) + .bind(ARM_TREATMENT) + .bind(PLAN_UNIDENTIFIED) + .fetch_one(pool) + .await + .map_err(|e| e.to_string())?; + + Ok(RepeatReads { + unchanged_served, + deltas_served, + tokens_not_resent, + measured_answers, + fresh_requests, + unidentified_reads, }) } diff --git a/crates/lumen-stats/tests/aggregates.rs b/crates/lumen-stats/tests/aggregates.rs index d87d8e6..bbc5d91 100644 --- a/crates/lumen-stats/tests/aggregates.rs +++ b/crates/lumen-stats/tests/aggregates.rs @@ -1079,11 +1079,319 @@ async fn optimizer_this_week_starts_on_monday() { ); } +// ── Repeat reads ───────────────────────────────────────────────────────────── +// +// What the Optimizer page's repeat-reads card is told. The rows are the ones lumen-mcp writes: +// the route that answered, and the arm and plan the gate recorded. + +type Text = Option<&'static str>; + +const TREAT: Text = Some("treatment"); +const CTRL: Text = Some("control"); +const MEAS: Text = Some("measured"); + +/// A `smart_read` or `recall_file` row as the tools write it: route, A/B arm, plan, where the +/// token count came from, and the counts. Arm and plan are NULL while the feature is off, and +/// the source is NULL on a row from before provenance was recorded. +type Gated = (&'static str, Text, Text, Text, Counts); + +/// An answer that was served, in the treatment arm, with the plan its route names. +fn served(route: &'static str, source: Text, c: Counts) -> Gated { + let plan = if route.ends_with("_unchanged") { + "unchanged" + } else { + "delta" + }; + (route, TREAT, Some(plan), source, c) +} + +/// A call the ordinary route answered, whatever the arm and plan. Its counts matter only to the +/// totals, so they are fixed. +fn ordinary(route: &'static str, arm: Text, plan: &'static str) -> Gated { + (route, arm, Some(plan), MEAS, (200, 1000, 800)) +} + +async fn ledger(pool: &SqlitePool, rows: &[Gated]) { + for &(route, arm, plan, source, c) in rows { + let tool = if route.starts_with("recall_file") { + "mcp__lumen__recall_file" + } else { + "mcp__lumen__smart_read" + }; + sqlx::query( + "INSERT INTO read_events(ts,tool,path,lines,tokens_returned,full_tokens,saved_tokens, + routed_via,channel,token_source,repeat_arm,repeat_plan) + VALUES(datetime('now'),?1,'/some/path.rs',100,?2,?3,?4,?5,'cli',?6,?7,?8)", + ) + .bind(tool) + .bind(c.0) + .bind(c.1) + .bind(c.2) + .bind(route) + .bind(source) + .bind(arm) + .bind(plan) + .execute(pool) + .await + .expect("insert gated read_event"); + } +} + +async fn repeat_reads(pool: &SqlitePool) -> RepeatReads { + get_optimizer_stats(pool).await.unwrap().repeat_reads +} + +/// The six figures, in the order the card shows them. +fn reads(u: i64, d: i64, tokens: i64, measured: i64, fresh: i64, unidentified: i64) -> RepeatReads { + RepeatReads { + unchanged_served: u, + deltas_served: d, + tokens_not_resent: tokens, + measured_answers: measured, + fresh_requests: fresh, + unidentified_reads: unidentified, + } +} + +#[tokio::test] +async fn repeat_reads_on_an_empty_ledger_are_all_zero() { + let (_d, pool) = fixture().await; + assert_eq!(repeat_reads(&pool).await, reads(0, 0, 0, 0, 0, 0)); +} + +#[tokio::test] +async fn a_served_answer_is_found_by_its_route_in_each_of_the_four_routes() { + let (_d, pool) = fixture().await; + let rows = [ + served("smart_read_unchanged", MEAS, (30, 1000, 970)), + served("recall_file_unchanged", MEAS, (30, 500, 470)), + served("smart_read_delta", MEAS, (120, 1500, 1380)), + served("recall_file_delta", MEAS, (90, 800, 710)), + ]; + ledger(&pool, &rows).await; + + assert_eq!( + repeat_reads(&pool).await, + reads(2, 2, 970 + 470 + 1380 + 710, 4, 0, 0) + ); +} + +#[tokio::test] +async fn a_control_row_is_not_a_served_answer_however_its_plan_reads() { + let (_d, pool) = fixture().await; + // A control row records the plan the server WOULD have run, under the ordinary route: the + // model was given the outline and nothing was served. + let rows = [ + ordinary("smart_read", CTRL, "unchanged"), + ordinary("recall_file", CTRL, "delta"), + served("smart_read_unchanged", MEAS, (30, 1000, 970)), + served("recall_file_delta", MEAS, (90, 700, 610)), + ]; + ledger(&pool, &rows).await; + + // Not 800 and 550 more: those are the ordinary savings, counted in the totals above the card. + assert_eq!(repeat_reads(&pool).await, reads(1, 1, 970 + 610, 2, 0, 0)); +} + +#[tokio::test] +async fn only_a_measured_answer_adds_to_the_tokens_not_resent() { + let (_d, pool) = fixture().await; + let rows = [ + served("smart_read_unchanged", MEAS, (30, 1000, 970)), + served("smart_read_unchanged", Some("estimated"), (30, 2000, 1970)), + served("recall_file_delta", None, (90, 800, 710)), + served("smart_read_delta", Some("unsupported"), (90, 400, 310)), + ]; + ledger(&pool, &rows).await; + + // Every answer served counts as served; one of the four carries a measured count. + assert_eq!(repeat_reads(&pool).await, reads(2, 2, 970, 1, 0, 0)); +} + +#[tokio::test] +async fn a_fresh_request_counts_only_while_repeat_reads_were_answering() { + let (_d, pool) = fixture().await; + let rows = [ + ordinary("smart_read", TREAT, "fresh"), + ordinary("recall_file", TREAT, "fresh"), + // Nothing was being answered for this path... + ordinary("smart_read", CTRL, "fresh"), + // ...or the feature was off and the call recorded no arm at all. + ordinary("smart_read", None, "fresh"), + ]; + ledger(&pool, &rows).await; + + assert_eq!(repeat_reads(&pool).await, reads(0, 0, 0, 0, 2, 0)); +} + +#[tokio::test] +async fn an_unidentified_read_counts_in_either_arm() { + let (_d, pool) = fixture().await; + let rows = [ + ordinary("smart_read", TREAT, "unidentified"), + ordinary("recall_file", CTRL, "unidentified"), + ]; + ledger(&pool, &rows).await; + + // Which arm a path fell in says nothing about whether the install can name a conversation. + assert_eq!(repeat_reads(&pool).await, reads(0, 0, 0, 0, 0, 2)); +} + +#[tokio::test] +async fn first_reads_and_every_fallback_are_none_of_the_four_figures() { + let (_d, pool) = fixture().await; + for plan in [ + "first", + "rewind", + "resync", + "compaction", + "unverifiable", + "unchanged_not_smaller", + "delta_too_big", + "no_base", + ] { + let rows = [ + ordinary("smart_read", TREAT, plan), + ordinary("recall_file", CTRL, plan), + ]; + ledger(&pool, &rows).await; + } + + assert_eq!(repeat_reads(&pool).await, RepeatReads::default()); +} + +#[tokio::test] +async fn served_answers_are_in_none_of_the_totals_above_them() { + let (_d, pool) = fixture().await; + // With enough turns to price a round the net value is not zero, so a served row reaching it + // would show. + turns_for_cost(&pool, 200, 100_000, 1_000).await; + event( + &pool, + "datetime('now')", + "smart_read", + "cli", + (200, 1000, 800), + ) + .await; + event( + &pool, + "datetime('now')", + "recall_file", + "vscode", + (100, 500, 400), + ) + .await; + let before = get_optimizer_stats(&pool).await.unwrap(); + assert!(before.net_value_usd != 0.0, "the fixture prices a round"); + + let rows = [ + served("smart_read_unchanged", MEAS, (30, 1000, 970)), + served("recall_file_unchanged", MEAS, (30, 500, 470)), + served("smart_read_delta", MEAS, (120, 1500, 1380)), + served("recall_file_delta", MEAS, (90, 800, 710)), + ]; + ledger(&pool, &rows).await; + let after = get_optimizer_stats(&pool).await.unwrap(); + + assert_eq!( + after.lifetime_optimized_tokens, + before.lifetime_optimized_tokens + ); + assert_eq!(after.lifetime_full_tokens, before.lifetime_full_tokens); + assert_eq!(after.today_saved_tokens, before.today_saved_tokens); + assert_eq!(after.this_week_saved_tokens, before.this_week_saved_tokens); + assert_eq!(after.by_tool, before.by_tool); + assert_eq!(after.by_channel, before.by_channel); + assert_eq!(after.net_value_usd, before.net_value_usd); + assert_eq!(after.gross_value_usd, before.gross_value_usd); + assert_eq!(after.round_cost_usd, before.round_cost_usd); + assert_eq!( + after.repeat_reads, + reads(2, 2, 970 + 470 + 1380 + 710, 4, 0, 0) + ); +} + +/// Every kind of row the ledger can hold, in one, with each figure asserted exactly. +#[tokio::test] +async fn repeat_reads_over_a_ledger_holding_every_kind_of_row() { + let (_d, pool) = fixture().await; + let rows = [ + // Served and measured: 970 + 1970 + 470 + 1380 + 710 tokens over five answers. + served("smart_read_unchanged", MEAS, (30, 1000, 970)), + served("smart_read_unchanged", MEAS, (30, 2000, 1970)), + served("recall_file_unchanged", MEAS, (30, 500, 470)), + served("smart_read_delta", MEAS, (120, 1500, 1380)), + served("recall_file_delta", MEAS, (90, 800, 710)), + // Served with no measured count: answers, but not tokens. + served("smart_read_unchanged", None, (30, 400, 370)), + served("recall_file_delta", Some("estimated"), (100, 600, 500)), + // The control arm: the plan that would have run, under the ordinary route. + ordinary("smart_read", CTRL, "unchanged"), + ordinary("recall_file", CTRL, "delta"), + // The whole result asked for again: while answering, in control, with the feature off. + ordinary("smart_read", TREAT, "fresh"), + ordinary("recall_file", TREAT, "fresh"), + ordinary("smart_read", CTRL, "fresh"), + ordinary("smart_read", None, "fresh"), + // Calls that could not be placed in a conversation. + ordinary("smart_read", TREAT, "unidentified"), + ordinary("recall_file", TREAT, "unidentified"), + ordinary("smart_read", CTRL, "unidentified"), + // First reads and fallbacks to the whole answer. + ordinary("smart_read", TREAT, "first"), + ordinary("smart_read", TREAT, "rewind"), + ordinary("smart_read", TREAT, "no_base"), + ]; + ledger(&pool, &rows).await; + // Other tools carry no arm and no plan. + event( + &pool, + "datetime('now')", + "compress_logs", + "cli", + (100, 1000, 900), + ) + .await; + event( + &pool, + "datetime('now')", + "builtin_read", + "cli", + (500, 500, 0), + ) + .await; + + assert_eq!(repeat_reads(&pool).await, reads(4, 3, 5500, 5, 2, 3)); +} + // ── Wire format ────────────────────────────────────────────────────────────── // // The Angular frontend reads these exact keys. Renaming a Rust field without the // serde attribute would silently blank out a page rather than fail loudly. +#[tokio::test] +async fn repeat_reads_json_uses_camel_case_for_the_frontend() { + let (_d, pool) = fixture().await; + let v = serde_json::to_value(get_optimizer_stats(&pool).await.unwrap()).unwrap(); + let r = v + .get("repeatReads") + .expect("missing camelCase key: repeatReads"); + for key in [ + "unchangedServed", + "deltasServed", + "tokensNotResent", + "measuredAnswers", + "freshRequests", + "unidentifiedReads", + ] { + assert!( + r.get(key).is_some(), + "missing camelCase key: repeatReads.{key}" + ); + } +} + #[tokio::test] async fn optimizer_json_uses_camel_case_for_the_frontend() { let (_d, pool) = fixture().await; diff --git a/lumenator/src-tauri/src/lib.rs b/lumenator/src-tauri/src/lib.rs index 8e5a7d3..4b1fe23 100644 --- a/lumenator/src-tauri/src/lib.rs +++ b/lumenator/src-tauri/src/lib.rs @@ -522,6 +522,8 @@ pub fn run() { get_context_report, get_fault_report, get_fault_count, + get_repeat_reads_setting, + set_repeat_reads_setting, check_for_update, file_fault_report, show_main_window, @@ -1039,6 +1041,111 @@ async fn get_fault_count() -> Result { .map_err(|e| format!("fault count task failed: {e}")) } +/// What the Home window's "Repeat reads" switch shows. +/// +/// The settings file, or the default when there is none, and nothing else. In the MCP server +/// `LUMEN_REPEAT_READS` outranks the file, but one set in a Claude Code MCP config is not in +/// this process's environment, so the app cannot see it and does not pretend to: it never +/// reads the variable. +#[derive(Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RepeatReadsSetting { + /// What the file says, on when there is no file. Off when the file could not be used: + /// the server answers every read in full then. + pub on: bool, + pub source: SettingSource, + /// Why the file could not be used; `None` when it could. + pub problem: Option, + /// Where the file is (or would be), so the text can name it. + pub file: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SettingSource { + /// No file, or a file that does not mention the setting. + Default, + File, +} + +/// The file's state as the window shows it. +/// +/// An error rather than a guess for what the app never asks for: the A/B split and an +/// environment value only come out of `resolve` when it is handed the environment, and this +/// app does not hand it one. +fn setting_view( + file: &std::path::Path, + r: &lumen_core::settings::Resolved, +) -> Result { + use lumen_core::ranked::Mode; + use lumen_core::settings::{Problem, Source}; + let on = match r.mode { + Mode::On => true, + Mode::Off => false, + Mode::Ab => return Err("the settings file asked for the A/B split".to_string()), + }; + let source = match r.source { + Source::Default => SettingSource::Default, + Source::File => SettingSource::File, + Source::Env => return Err("the environment decided, and the app does not read it".into()), + }; + let problem = match &r.problem { + None => None, + Some(Problem::File(why)) => Some(why.clone()), + Some(Problem::Env(v)) => return Err(format!("an environment value {v:?} was reported")), + }; + Ok(RepeatReadsSetting { + on, + source, + problem, + file: file.display().to_string(), + }) +} + +/// [`get_repeat_reads_setting`] for a ledger it is given, so a test drives what the switch +/// shows without resolving the ledger from the environment. +/// +/// `resolve` is handed no environment on purpose, see [`RepeatReadsSetting`]. +fn repeat_reads_setting_at(db: &std::path::Path) -> Result { + let file = lumen_core::settings::settings_path(db); + let resolved = lumen_core::settings::resolve(None, &lumen_core::settings::read_file(&file)); + setting_view(&file, &resolved) +} + +fn set_repeat_reads_at(db: &std::path::Path, on: bool) -> Result { + lumen_core::settings::write_repeat_reads(db, on).map_err(|e| { + format!( + "cannot write {}: {e}", + lumen_core::settings::settings_path(db).display() + ) + })?; + repeat_reads_setting_at(db) +} + +fn ledger_path() -> Result { + lumen_core::meter::db_path() + .ok_or_else(|| "cannot tell where the ledger lives, so where its settings file is".into()) +} + +/// The Repeat reads switch as the settings file has it. +#[tauri::command] +async fn get_repeat_reads_setting() -> Result { + tauri::async_runtime::spawn_blocking(|| repeat_reads_setting_at(&ledger_path()?)) + .await + .map_err(|e| format!("repeat reads setting task failed: {e}"))? +} + +/// Write the switch, then say what the file now holds. +/// +/// Applies to the next Lumen read in every running Claude Code session: the server reads the +/// file on each call. +#[tauri::command] +async fn set_repeat_reads_setting(on: bool) -> Result { + tauri::async_runtime::spawn_blocking(move || set_repeat_reads_at(&ledger_path()?, on)) + .await + .map_err(|e| format!("repeat reads setting task failed: {e}"))? +} + /// Width of the tray popover. Fixed: it is positioned under the tray icon and a varying /// width would make it jump horizontally as rows appear. const PANEL_WIDTH: f64 = 320.0; @@ -1321,4 +1428,388 @@ mod tests { assert!(url.starts_with("sqlite:"), "got {url}"); assert!(url.ends_with("?mode=rwc"), "got {url}"); } + + // ── repeat reads switch ────────────────────────────────────────────────── + + /// The settings file sits beside the ledger, so only the directory needs to exist. + fn ledger_in(dir: &tempfile::TempDir) -> std::path::PathBuf { + dir.path().join("lumen.db") + } + + fn settings_file(dir: &tempfile::TempDir) -> std::path::PathBuf { + lumen_core::settings::settings_path(&ledger_in(dir)) + } + + fn shown(dir: &tempfile::TempDir) -> RepeatReadsSetting { + repeat_reads_setting_at(&ledger_in(dir)).expect("a state to show") + } + + #[test] + fn with_no_settings_file_repeat_reads_show_on_by_default() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + shown(&dir), + RepeatReadsSetting { + on: true, + source: SettingSource::Default, + problem: None, + file: settings_file(&dir).display().to_string(), + } + ); + } + + #[test] + fn a_file_that_says_on_shows_on_from_the_file() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(settings_file(&dir), r#"{"repeat_reads": true}"#).unwrap(); + let v = shown(&dir); + assert!(v.on); + assert_eq!(v.source, SettingSource::File); + assert_eq!(v.problem, None); + } + + #[test] + fn a_file_that_says_off_shows_off_from_the_file() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(settings_file(&dir), r#"{"repeat_reads": false}"#).unwrap(); + let v = shown(&dir); + assert!(!v.on); + assert_eq!(v.source, SettingSource::File); + assert_eq!(v.problem, None); + } + + #[test] + fn a_file_that_does_not_mention_the_setting_is_the_default() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(settings_file(&dir), r#"{"something_else": 1}"#).unwrap(); + let v = shown(&dir); + assert!(v.on); + assert_eq!(v.source, SettingSource::Default); + assert_eq!(v.problem, None); + } + + #[test] + fn a_file_that_cannot_be_used_shows_off_and_why() { + for (text, why) in [ + ("{not json", "not JSON"), + ("[]", "not a JSON object"), + (r#"{"repeat_reads": "yes"}"#, "must be true or false"), + ] { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(settings_file(&dir), text).unwrap(); + let v = shown(&dir); + assert!(!v.on, "{text}: the server reads every file in full then"); + assert_eq!(v.source, SettingSource::File, "{text}"); + let problem = v.problem.expect("a reason"); + assert!(problem.contains(why), "{text}: got {problem:?}"); + } + } + + #[test] + fn a_settings_path_that_cannot_be_read_shows_off_and_why() { + let dir = tempfile::tempdir().unwrap(); + // A directory where the file should be: present, and not something that can be read. + std::fs::create_dir(settings_file(&dir)).unwrap(); + let v = shown(&dir); + assert!(!v.on); + assert_eq!(v.source, SettingSource::File); + assert!(v.problem.expect("a reason").starts_with("unreadable:")); + } + + #[test] + fn switching_off_writes_the_file_and_reads_it_back() { + let dir = tempfile::tempdir().unwrap(); + let v = set_repeat_reads_at(&ledger_in(&dir), false).unwrap(); + assert!(!v.on); + assert_eq!(v.source, SettingSource::File); + assert_eq!(v.problem, None); + let on_disk: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(settings_file(&dir)).unwrap()).unwrap(); + assert_eq!(on_disk, serde_json::json!({ "repeat_reads": false })); + } + + #[test] + fn switching_back_on_reads_back_as_on() { + let dir = tempfile::tempdir().unwrap(); + set_repeat_reads_at(&ledger_in(&dir), false).unwrap(); + let v = set_repeat_reads_at(&ledger_in(&dir), true).unwrap(); + assert!(v.on); + assert_eq!(v.source, SettingSource::File); + assert!(shown(&dir).on, "and a later read sees the same"); + } + + #[test] + fn switching_replaces_a_file_that_could_not_be_used() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(settings_file(&dir), "{not json").unwrap(); + let v = set_repeat_reads_at(&ledger_in(&dir), true).unwrap(); + assert!(v.on); + assert_eq!(v.problem, None, "choosing the setting is the repair"); + let on_disk: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(settings_file(&dir)).unwrap()).unwrap(); + assert_eq!(on_disk, serde_json::json!({ "repeat_reads": true })); + } + + #[test] + fn a_write_that_fails_is_an_error_naming_the_file_and_the_cause() { + let dir = tempfile::tempdir().unwrap(); + // The directory the file would go in is a regular file, so nothing can be created in it. + let blocker = dir.path().join("blocker"); + std::fs::write(&blocker, "").unwrap(); + let db = blocker.join("lumen.db"); + let err = set_repeat_reads_at(&db, false).unwrap_err(); + let file = lumen_core::settings::settings_path(&db) + .display() + .to_string(); + let cause = err + .strip_prefix(&format!("cannot write {file}: ")) + .unwrap_or_else(|| panic!("names {file}, then why: got {err}")); + assert!( + cause.contains("(os error"), + "the operating system's reason follows the file: got {err}" + ); + } + + #[test] + fn a_resolution_the_app_never_asks_for_is_an_error_not_a_guess() { + use lumen_core::ranked::Mode; + use lumen_core::settings::{Problem, Resolved, Source}; + let file = std::path::Path::new("lumen_settings.json"); + let env_problem = Some(Problem::Env("x".into())); + for r in [ + Resolved { + mode: Mode::Ab, + source: Source::Default, + problem: None, + }, + Resolved { + mode: Mode::On, + source: Source::Env, + problem: None, + }, + Resolved { + mode: Mode::Off, + source: Source::File, + problem: env_problem, + }, + ] { + assert!(setting_view(file, &r).is_err(), "{r:?} is not a file state"); + } + } + + #[test] + fn the_setting_goes_to_the_window_under_the_keys_it_reads() { + let off = RepeatReadsSetting { + on: false, + source: SettingSource::File, + problem: Some("not JSON: x".into()), + file: "/d/lumen_settings.json".into(), + }; + assert_eq!( + serde_json::to_value(&off).unwrap(), + serde_json::json!({ + "on": false, + "source": "file", + "problem": "not JSON: x", + "file": "/d/lumen_settings.json", + }) + ); + let fresh = RepeatReadsSetting { + on: true, + source: SettingSource::Default, + problem: None, + file: "/d/lumen_settings.json".into(), + }; + let v = serde_json::to_value(&fresh).unwrap(); + assert_eq!(v["source"], "default"); + assert!(v["problem"].is_null()); + } + + #[test] + fn the_switch_does_not_read_the_variable_that_outranks_the_file() { + // The server lets LUMEN_REPEAT_READS beat the file, but the one that matters is in a + // Claude Code MCP config, not in this process. A window that read its own would show + // an answer for the wrong place. The variable is set on a child before it starts, so + // no other test sees it change. + const CHILD: &str = "LUMEN_TEST_SWITCH_CHILD"; + const THIS: &str = "tests::the_switch_does_not_read_the_variable_that_outranks_the_file"; + if std::env::var_os(CHILD).is_some() { + let dir = tempfile::tempdir().unwrap(); + let v = shown(&dir); + assert!(v.on, "the variable says off and there is no file"); + assert_eq!(v.source, SettingSource::Default); + return; + } + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args([THIS, "--exact", "--nocapture"]) + .env(CHILD, "1") + .env(lumen_core::settings::REPEAT_READS_ENV, "off") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "child failed:\n{stdout}\n{stderr}"); + assert!( + stdout.contains("1 passed"), + "the child ran nothing:\n{stdout}" + ); + } + + // ── commands the window invokes ────────────────────────────────────────── + + /// Every `.ts` file the app ships: specs and the fake bridge only describe calls. + fn frontend_sources(dir: &std::path::Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + if path.is_dir() { + frontend_sources(&path, out); + } else if name.ends_with(".ts") + && !name.ends_with(".spec.ts") + && !name.ends_with(".fake.ts") + { + out.push(path); + } + } + } + + /// The command names a source passes to `invoke` as a string literal: `invoke('x')`, + /// `invoke('x', ...)`, `this.bridge.invoke('x')`. + fn invoked_commands(src: &str) -> Vec { + let mut names = Vec::new(); + let mut rest = src; + while let Some(at) = rest.find("invoke") { + rest = &rest[at + "invoke".len()..]; + let mut tail = rest; + if tail.starts_with('<') { + let mut depth = 0; + let end = tail.find(|c| { + match c { + '<' => depth += 1, + '>' => depth -= 1, + _ => {} + } + depth == 0 + }); + match end { + Some(i) => tail = &tail[i + 1..], + None => continue, + } + } + if let Some(call) = tail.strip_prefix('(') { + if let Some(lit) = call.trim_start().strip_prefix('\'') { + if let Some(end) = lit.find('\'') { + names.push(lit[..end].to_string()); + } + } + } + } + names + } + + fn registered_commands() -> Vec { + const OPEN: &str = "generate_handler!["; + let src = include_str!("lib.rs"); + let from = src.find(OPEN).expect("the handler list") + OPEN.len(); + let list = &src[from..from + src[from..].find(']').expect("its end")]; + list.split(',') + .map(|c| c.trim().rsplit("::").next().unwrap_or_default().to_string()) + .filter(|c| !c.is_empty()) + .collect() + } + + #[test] + fn every_command_the_window_invokes_is_registered() { + let mut files = Vec::new(); + frontend_sources( + &std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/app"), + &mut files, + ); + let registered = registered_commands(); + let mut invoked = Vec::new(); + for file in &files { + for name in invoked_commands(&std::fs::read_to_string(file).unwrap()) { + assert!( + registered.contains(&name), + "{} invokes {name}, which no handler answers", + file.display() + ); + invoked.push(name); + } + } + for needed in ["get_repeat_reads_setting", "set_repeat_reads_setting"] { + assert!( + invoked.iter().any(|n| n == needed), + "the switch needs {needed}, and the scan found only {invoked:?}" + ); + } + } + + #[test] + fn the_invoke_scan_reads_each_way_a_call_is_written() { + let src = "invoke('a'); invoke('b', x); this.bridge.invoke(\n 'c'\n); \ + invoke>>('d'); invoke(NAME); invoke(`e`); invoke"; + assert_eq!(invoked_commands(src), ["a", "b", "c", "d"]); + } + + /// The keys of the object a source passes after the command's name: `invoke('x', { on })`. + fn invoke_arg_keys(src: &str, command: &str) -> Vec { + let literal = format!("'{command}'"); + let at = src + .find(&literal) + .unwrap_or_else(|| panic!("the window no longer invokes {command}")) + + literal.len(); + let Some(object) = src[at..] + .trim_start() + .strip_prefix(',') + .and_then(|rest| rest.trim_start().strip_prefix('{')) + else { + return Vec::new(); + }; + object[..object.find('}').expect("the object's end")] + .split(',') + .map(|entry| entry.split(':').next().unwrap_or_default().trim()) + .filter(|key| !key.is_empty()) + .map(String::from) + .collect() + } + + /// The parameter names of `fn (..)` in this file. + fn command_params(command: &str) -> Vec { + let src = include_str!("lib.rs"); + let head = format!("fn {command}("); + let from = src + .find(&head) + .unwrap_or_else(|| panic!("{command} is not defined here")) + + head.len(); + src[from..from + src[from..].find(')').expect("the parameter list's end")] + .split(',') + .filter_map(|param| param.split(':').next()) + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()) + .collect() + } + + #[test] + fn the_arguments_the_window_sends_are_the_ones_each_command_takes() { + let bridge = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/app/tauri-bridge.ts"), + ) + .unwrap(); + for command in ["get_repeat_reads_setting", "set_repeat_reads_setting"] { + assert_eq!( + invoke_arg_keys(&bridge, command), + command_params(command), + "{command}: the window sends keys the handler does not take" + ); + } + } + + #[test] + fn the_argument_scan_reads_each_way_a_key_is_written() { + let src = "invoke('a'); invoke('b', { on }); invoke(\n 'c',\n { enabled: on, other }\n);"; + assert!(invoke_arg_keys(src, "a").is_empty()); + assert_eq!(invoke_arg_keys(src, "b"), ["on"]); + assert_eq!(invoke_arg_keys(src, "c"), ["enabled", "other"]); + } } diff --git a/lumenator/src/app/components/index.ts b/lumenator/src/app/components/index.ts index aff8e04..d6cf6e8 100644 --- a/lumenator/src/app/components/index.ts +++ b/lumenator/src/app/components/index.ts @@ -182,6 +182,50 @@ interface OptimizerReport { valueRounds?: number; /** Rounds each intercept costs — 1.604 measured. */ pairMultiplier?: number; + /** + * What repeat reads have done so far. Counted apart from every figure above: the + * answers it counts are in none of the token totals. Optional because a backend + * older than 1.8.0 does not send it, and then the page shows nothing about it. + */ + repeatReads?: RepeatReads; +} + +/** + * Answers served by the repeat-reads feature, from `OptimizerReport.repeatReads`. + * + * `tokensNotResent` is counted against reading the whole file again, not against the + * outline Lumen would have sent with the feature off, and covers measured answers only. + */ +interface RepeatReads { + /** "Unchanged since an earlier read" answers. */ + unchangedServed: number; + /** Answers that sent only what an edit changed. */ + deltasServed: number; + /** Full tokens minus the tokens returned, over the served answers whose count was measured. */ + tokensNotResent: number; + /** How many of the served answers carry a measured count, so the page can say when the figure covers fewer. */ + measuredAnswers: number; + /** Full results asked for again with `fresh=true`, in the arm that got the feature. */ + freshRequests: number; + /** Reads that got a full result because Lumen could not tell which conversation they came from. */ + unidentifiedReads: number; +} + +/** + * The repeat-reads switch as the settings file holds it, from `get_repeat_reads_setting`. + * + * This is the file and the default, never the server's effective mode: a + * `LUMEN_REPEAT_READS` value in a Claude Code MCP config outranks both and the app does + * not see it. + */ +interface RepeatReadsSetting { + on: boolean; + /** Where `on` comes from: no file at all (the default is on), or the file. */ + source: 'default' | 'file'; + /** Why the file could not be used, when it could not. `on` is false then. */ + problem: string | null; + /** The settings file's path, for the text that names it. */ + file: string; } export type { @@ -196,6 +240,8 @@ export type { ChannelBreakdown, ToolBreakdown, OptimizerReport, + RepeatReads, + RepeatReadsSetting, }; export { RATE }; diff --git a/lumenator/src/app/pages/home/home.css b/lumenator/src/app/pages/home/home.css index 35efc6d..ec8d1be 100644 --- a/lumenator/src/app/pages/home/home.css +++ b/lumenator/src/app/pages/home/home.css @@ -312,6 +312,105 @@ overflow-wrap: anywhere; } +/* ── Repeat reads switch. Same card as the alert thresholds; the difference worth seeing is that + this one is saved, which its note says. ── */ +.repeat { + width: 100%; + background: var(--lumen-bg-secondary); + border: 1px solid var(--lumen-border); + border-radius: var(--lumen-radius-sm); + padding: 0.8rem 0.9rem; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.repeat__title { + margin: 0; + font-family: var(--lumen-font-sans); + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--lumen-text-dim); +} + +.repeat__intro, +.repeat__note { + margin: 0; + font-family: var(--lumen-font-sans); + font-size: 0.7rem; + line-height: 1.45; + color: var(--lumen-text-dim); + /* A path or an error from the OS is long and arbitrary; it wraps rather than widening the window. */ + overflow-wrap: anywhere; +} + +.repeat__note { + font-size: 0.64rem; + opacity: 0.8; +} + +.repeat__toggle-row { + display: flex; + align-items: center; + gap: 0.55rem; + cursor: pointer; + width: fit-content; + padding: 0.1rem 0; +} + +/* The checkbox is visually hidden, so keyboard focus has to show on the track. */ +.repeat__toggle-row:focus-within .toggle__track { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--lumen-brand) 25%, transparent); +} + +.repeat__toggle-label { + font-family: var(--lumen-font-sans); + font-size: 0.78rem; + color: var(--lumen-text); +} + +.repeat__state { + margin: 0; + font-family: var(--lumen-font-sans); + font-size: 0.76rem; + line-height: 1.45; + color: var(--lumen-text); + overflow-wrap: anywhere; +} + +.repeat__state--problem { + color: var(--lumen-warn); +} + +.repeat__unknown { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; +} + +.repeat__retry { + font-family: var(--lumen-font-sans); + font-size: 0.74rem; + color: var(--lumen-text); + background: var(--lumen-bg-elevated); + border: 1px solid var(--lumen-border-strong); + border-radius: 8px; + padding: 0.28rem 0.7rem; + cursor: pointer; +} + +.repeat__error { + margin: 0; + font-family: var(--lumen-font-sans); + font-size: 0.74rem; + line-height: 1.45; + color: var(--lumen-alert); + overflow-wrap: anywhere; +} + @keyframes fade-up { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } diff --git a/lumenator/src/app/pages/home/home.html b/lumenator/src/app/pages/home/home.html index 9c8cc63..0505524 100644 --- a/lumenator/src/app/pages/home/home.html +++ b/lumenator/src/app/pages/home/home.html @@ -167,6 +167,73 @@

Alert Thresholds

0 disables a spend alert. Resets to defaults on restart.

+ +
+

Repeat reads

+

+ When Claude reads a file again in the same conversation, Lumen answers with a short note + that it is unchanged, or with only what an edit changed, instead of the whole result. +

+ + @if (rr.setting(); as st) { + + + @if (st.problem; as why) { +

+ The settings file {{ st.file }} could not be read: {{ why }}. + Repeat reads are off until it is fixed. Switching on again rewrites the file. +

+ } @else if (!st.on) { +

Off, as the settings file says. Every read returns its full result.

+ } @else if (st.source === 'file') { +

On, as the settings file says.

+ } @else { +

On by default. The settings file does not turn it off.

+ } + } @else if (rr.readError(); as err) { + + } @else { +

Reading the settings file…

+ } + + @if (rr.writeError(); as werr) { + + } + + @if (rr.setting(); as st) { +

+ A change applies to the next Lumen read in every running Claude Code session, with no restart. + It is saved in {{ st.file }}, so unlike the alert thresholds above it survives a restart. +

+

+ This shows the settings file, not what the server will do: a LUMEN_REPEAT_READS value in a + Claude Code MCP config takes priority over the file, and a LUMEN_DB there that points at + another ledger makes the server read that ledger's settings file instead. This window cannot + see either. A session that started with repeat reads off is not told about the fresh option + until Claude Code restarts; every unchanged note still tells the model to pass fresh=true, + and it is honoured. +

+ } +
+
diff --git a/lumenator/src/app/pages/home/home.spec.ts b/lumenator/src/app/pages/home/home.spec.ts index 4572083..23414af 100644 --- a/lumenator/src/app/pages/home/home.spec.ts +++ b/lumenator/src/app/pages/home/home.spec.ts @@ -2,16 +2,26 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router, provideRouter } from '@angular/router'; import { Home } from './home'; import { SessionService } from '../../session.service'; +import { RepeatReadsService } from '../../repeat-reads.service'; import { TauriBridge } from '../../tauri-bridge'; import { APP_VERSION_CMD, FakeTauriBridge } from '../../tauri-bridge.fake'; +import type { RepeatReadsSetting } from '../../components/index'; describe('Home', () => { let fixture: ComponentFixture; let bridge: FakeTauriBridge; + const SETTINGS_FILE = '/Users/me/.lumen/lumen_settings.json'; + + function setting(over: Partial = {}): RepeatReadsSetting { + return { on: true, source: 'default', problem: null, file: SETTINGS_FILE, ...over }; + } + async function build(seed?: (b: FakeTauriBridge) => void): Promise { bridge = new FakeTauriBridge(); bridge.responses.set('lumen_setup_needed', false); + // No settings file: repeat reads are on by default. + bridge.responses.set('get_repeat_reads_setting', setting()); seed?.(bridge); TestBed.configureTestingModule({ providers: [ @@ -470,4 +480,245 @@ describe('Home', () => { expect(fixture.nativeElement.querySelector('.home__version')).toBeNull(); expect(el('.about__version').textContent).toBe('unavailable'); }); + + // ── repeat reads switch ──────────────────────────────────────────────────── + // + // The switch shows the settings file and the default, never the server's effective mode: + // a LUMEN_REPEAT_READS value in a Claude Code MCP config outranks the file, and the app + // cannot see it. Each state is asserted on the words shown and on the checkbox itself. + + describe('repeat reads switch', () => { + const SECTION = 'section[aria-label="Repeat reads"]'; + const BOX = `${SECTION} input[type="checkbox"]`; + const FILE_OFF = setting({ on: false, source: 'file' }); + const FILE_ON = setting({ on: true, source: 'file' }); + const CORRUPT = setting({ + on: false, + source: 'file', + problem: 'not JSON: expected value at line 1 column 1', + }); + + /** What the section says, whitespace collapsed. */ + function said(): string { + return (el(SECTION).textContent ?? '').replace(/\s+/g, ' ').trim(); + } + + async function settle(): Promise { + for (let i = 0; i < 10; i++) await Promise.resolve(); + fixture.detectChanges(); + } + + function deferred(): { promise: Promise; resolve: (v: T) => void } { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; + } + + async function open(seed?: (b: FakeTauriBridge) => void): Promise { + await build(seed); + await settle(); + } + + it('reads the settings file when the window opens', async () => { + await open(); + expect(bridge.countOf('get_repeat_reads_setting')).toBe(1); + }); + + it('shows on, and says it is the default, when there is no settings file', async () => { + await open(); + expect(said()).toContain('On by default. The settings file does not turn it off.'); + expect(el(BOX).checked).toBe(true); + }); + + it('shows off, as the settings file says, when the file turns it off', async () => { + await open((b) => b.responses.set('get_repeat_reads_setting', FILE_OFF)); + expect(said()).toContain('Off, as the settings file says. Every read returns its full result.'); + expect(el(BOX).checked).toBe(false); + }); + + it('shows on, as the settings file says, when the file turns it on', async () => { + await open((b) => b.responses.set('get_repeat_reads_setting', FILE_ON)); + expect(said()).toContain('On, as the settings file says.'); + expect(said()).not.toContain('by default'); + expect(el(BOX).checked).toBe(true); + }); + + it('says a settings file that could not be read is why repeat reads are off, and what fixes it', async () => { + await open((b) => b.responses.set('get_repeat_reads_setting', CORRUPT)); + const words = said(); + expect(words).toContain(`The settings file ${SETTINGS_FILE} could not be read`); + expect(words).toContain('not JSON: expected value at line 1 column 1'); + expect(words).toContain('Repeat reads are off until it is fixed.'); + expect(words).toContain('Switching on again rewrites the file.'); + expect(words).not.toContain('On by default'); + expect(words).not.toContain('as the settings file says'); + expect(el(BOX).checked).toBe(false); + }); + + it('says when a change applies, that it is saved, and that an environment value outranks it', async () => { + await open(); + const words = said(); + expect(words).toContain('applies to the next Lumen read in every running Claude Code session, with no restart'); + expect(words).toContain(`saved in ${SETTINGS_FILE}, so unlike the alert thresholds above it survives a restart`); + expect(words).toContain('LUMEN_REPEAT_READS value in a Claude Code MCP config takes priority over the file'); + expect(words).toContain('LUMEN_DB there that points at another ledger'); + expect(words).toContain('This window cannot see either.'); + expect(words).toContain('is not told about the fresh option until Claude Code restarts'); + }); + + it('says the same about the environment when the file turns it off', async () => { + await open((b) => b.responses.set('get_repeat_reads_setting', FILE_OFF)); + expect(said()).toContain('LUMEN_REPEAT_READS value in a Claude Code MCP config takes priority over the file'); + }); + + it('writes the choice and shows what the file then says', async () => { + await open((b) => b.responses.set('set_repeat_reads_setting', FILE_OFF)); + el(BOX).click(); + await settle(); + expect(bridge.lastArgsOf('set_repeat_reads_setting')).toEqual({ on: false }); + expect(el(BOX).checked).toBe(false); + expect(said()).toContain('Off, as the settings file says.'); + expect(said()).not.toContain('Not saved'); + }); + + it('rewrites a settings file that could not be read when it is switched on', async () => { + await open((b) => { + b.responses.set('get_repeat_reads_setting', CORRUPT); + b.responses.set('set_repeat_reads_setting', FILE_ON); + }); + el(BOX).click(); + await settle(); + expect(bridge.lastArgsOf('set_repeat_reads_setting')).toEqual({ on: true }); + expect(said()).toContain('On, as the settings file says.'); + expect(said()).not.toContain('could not be read'); + expect(el(BOX).checked).toBe(true); + }); + + it('puts the switch back, and shows why as text, when the write fails', async () => { + await open((b) => b.failures.add('set_repeat_reads_setting')); + el(BOX).click(); + await settle(); + expect(el(BOX).checked).toBe(true); + expect(el(`${SECTION} [role="alert"]`).textContent).toContain( + 'Not saved: fake: set_repeat_reads_setting failed', + ); + expect(said()).toContain('On by default'); + }); + + it('puts the switch back to off when switching on fails', async () => { + await open((b) => { + b.responses.set('get_repeat_reads_setting', FILE_OFF); + b.failures.add('set_repeat_reads_setting'); + }); + el(BOX).click(); + await settle(); + expect(el(BOX).checked).toBe(false); + expect(said()).toContain('Not saved: fake: set_repeat_reads_setting failed'); + expect(said()).toContain('Off, as the settings file says.'); + }); + + it('shows the file as it is when the write did not leave it as asked', async () => { + await open((b) => b.responses.set('set_repeat_reads_setting', FILE_ON)); + el(BOX).click(); + await settle(); + expect(el(BOX).checked).toBe(true); + expect(said()).toContain('The settings file says repeat reads are on, not off.'); + }); + + it('forgets the error once a later write takes', async () => { + await open((b) => b.failures.add('set_repeat_reads_setting')); + el(BOX).click(); + await settle(); + bridge.failures.delete('set_repeat_reads_setting'); + bridge.responses.set('set_repeat_reads_setting', FILE_OFF); + el(BOX).click(); + await settle(); + expect(said()).not.toContain('Not saved'); + expect(el(BOX).checked).toBe(false); + }); + + it('holds the switch still while the write is in flight, and keeps the keyboard focus on it', async () => { + const write = deferred(); + let writes = 0; + await open((b) => { + b.setRepeatReadsSetting = () => { + writes += 1; + return write.promise; + }; + }); + const box = el(BOX); + box.focus(); + box.click(); + await settle(); + expect(box.getAttribute('aria-disabled')).toBe('true'); + expect(box.disabled).toBe(false); + expect(document.activeElement).toBe(box); + box.click(); + await settle(); + expect(writes).toBe(1); + expect(box.checked).toBe(false); + write.resolve(FILE_OFF); + await settle(); + expect(box.hasAttribute('aria-disabled')).toBe(false); + expect(document.activeElement).toBe(box); + box.click(); + await settle(); + expect(writes).toBe(2); + }); + + it('claims nothing about on or off before the file has been read', async () => { + const read = deferred(); + await build((b) => { + b.getRepeatReadsSetting = () => read.promise; + }); + expect(said()).toContain('Reading the settings file…'); + expect(fixture.nativeElement.querySelector(BOX)).toBeNull(); + expect(said()).not.toMatch(/\bOn\b|\bOff\b/); + }); + + it('says it could not read the setting, and does not pretend on or off, when the read fails', async () => { + await open((b) => b.failures.add('get_repeat_reads_setting')); + expect(said()).toContain('fake: get_repeat_reads_setting failed'); + expect(said()).toContain('Lumen does not know whether repeat reads are on or off.'); + expect(fixture.nativeElement.querySelector(BOX)).toBeNull(); + expect(said()).not.toMatch(/\bOn\b|\bOff\b/); + }); + + it('reads again from Retry, and shows what it finds', async () => { + await open((b) => b.failures.add('get_repeat_reads_setting')); + bridge.failures.delete('get_repeat_reads_setting'); + bridge.responses.set('get_repeat_reads_setting', FILE_OFF); + el(`${SECTION} button`).click(); + await settle(); + expect(bridge.countOf('get_repeat_reads_setting')).toBe(2); + expect(said()).toContain('Off, as the settings file says.'); + expect(el(BOX).checked).toBe(false); + }); + + it('shows both failures when the file could not be read again before a write failed', async () => { + await open(); + bridge.failures.add('get_repeat_reads_setting'); + bridge.failures.add('set_repeat_reads_setting'); + const box = el(BOX); + void TestBed.inject(RepeatReadsService).load(); + box.click(); + await settle(); + expect(fixture.nativeElement.querySelector(BOX)).toBeNull(); + expect(said()).toContain('fake: get_repeat_reads_setting failed'); + expect(said()).toContain('Lumen does not know whether repeat reads are on or off.'); + expect(said()).toContain('Not saved: fake: set_repeat_reads_setting failed'); + }); + + it('does not show a write error left from before the window was opened again', async () => { + await open((b) => b.failures.add('set_repeat_reads_setting')); + el(BOX).click(); + await settle(); + expect(said()).toContain('Not saved'); + await TestBed.inject(RepeatReadsService).load(); + await settle(); + expect(said()).not.toContain('Not saved'); + }); + }); }); diff --git a/lumenator/src/app/pages/home/home.ts b/lumenator/src/app/pages/home/home.ts index 5783c08..f25869a 100644 --- a/lumenator/src/app/pages/home/home.ts +++ b/lumenator/src/app/pages/home/home.ts @@ -5,6 +5,7 @@ import { Cost } from '../../components/cost/cost'; import { Usage } from '../../components/usage/usage'; import { Firefly } from '../../components/firefly/firefly'; import { SessionService } from '../../session.service'; +import { RepeatReadsService } from '../../repeat-reads.service'; import { TauriBridge } from '../../tauri-bridge'; import { LumenTooltip } from '../../directives/tooltip.directive'; @@ -26,6 +27,7 @@ interface StartupHealth { }) export class Home implements OnInit { readonly s = inject(SessionService); + readonly rr = inject(RepeatReadsService); private readonly router = inject(Router); private readonly bridge = inject(TauriBridge); private readonly destroyRef = inject(DestroyRef); @@ -75,6 +77,7 @@ export class Home implements OnInit { .then(needed => { if (needed) this.router.navigate(['/setup']); }) .catch(() => { /* non-fatal: proceed normally if command fails */ }); this.s.refreshFaultCount(); + void this.rr.load(); } private readVersion(): void { @@ -123,4 +126,18 @@ export class Home implements OnInit { onNativeNotify(e: Event): void { this.s.setNativeNotify((e.target as HTMLInputElement).checked); } + + async onRepeatReads(e: Event): Promise { + const box = e.target as HTMLInputElement; + // Left enabled while it writes: a disabled box would drop the keyboard focus it holds. + if (this.rr.saving()) { + box.checked = !box.checked; + return; + } + const took = await this.rr.set(box.checked); + // A click flips the box by itself, and the binding writes it back only when the + // stored value changed, so a write that did not take has to put it back here. + const now = this.rr.setting(); + if (!took && now) box.checked = now.on; + } } diff --git a/lumenator/src/app/pages/optimizer/optimizer.css b/lumenator/src/app/pages/optimizer/optimizer.css index 9a71900..2e7e279 100644 --- a/lumenator/src/app/pages/optimizer/optimizer.css +++ b/lumenator/src/app/pages/optimizer/optimizer.css @@ -314,7 +314,8 @@ } /* ── breakdowns ── */ -.breakdown { +.breakdown, +.repeat-card { width: 100%; background: var(--lumen-bg-secondary); border: 1px solid var(--lumen-border); @@ -325,7 +326,8 @@ gap: 0.55rem; } -.breakdown__title { +.breakdown__title, +.repeat-card__title { margin: 0; font-family: var(--lumen-font-sans); font-size: 0.66rem; @@ -335,7 +337,8 @@ color: var(--lumen-text-dim); } -.breakdown__rows { +.breakdown__rows, +.repeat-card__rows { display: flex; flex-direction: column; gap: 0.5rem; @@ -402,7 +405,8 @@ color: var(--lumen-text-dim); } -.missed__val { +.missed__val, +.repeat-card__val { font-family: var(--lumen-font-mono); font-variant-numeric: tabular-nums; font-size: 0.84rem; @@ -441,13 +445,67 @@ /* The provenance caveat under the hero figure. Amber left rule, the same device .hs__rec uses for advice: this qualifies the number above it rather than reporting a fault, so it must be visible without reading as an error. */ -.hero__warn { - max-width: 46ch; - margin: 0.5rem auto 0; +.hero__warn, +.repeat-card__unidentified { padding-left: 0.5rem; border-left: 2px solid color-mix(in srgb, var(--lumen-warn) 60%, transparent); font-size: 0.68rem; line-height: 1.4; + color: var(--lumen-text-dim); +} + +.hero__warn { + max-width: 46ch; + margin: 0.5rem auto 0; text-align: left; +} + +/* ── repeat reads: counted apart from the totals above ── */ +.repeat-card { + font-family: var(--lumen-font-sans); + font-size: 0.74rem; + line-height: 1.45; + color: var(--lumen-text); + /* A settings file path or an OS error is long and arbitrary; it wraps rather than widening the window. */ + overflow-wrap: anywhere; +} + +.repeat-card :is(p, dl, dd) { + margin: 0; +} + +.repeat-card__none, +.repeat-card__note, +.repeat-card__what { color: var(--lumen-text-dim); } + +.repeat-card__note, +.repeat-card__what { + font-size: 0.64rem; +} + +.repeat-card__problem { + color: var(--lumen-warn); +} + +/* Name and figure on one line, what the figure is counted against under them. */ +.repeat-card__row { + display: grid; + grid-template-columns: 1fr auto; + align-items: baseline; + gap: 0.12rem 0.75rem; +} + +.repeat-card__val { + text-align: right; + white-space: nowrap; +} + +.repeat-card__what { + grid-column: 1 / -1; +} + +.repeat-card__link { + color: var(--lumen-brand); +} diff --git a/lumenator/src/app/pages/optimizer/optimizer.html b/lumenator/src/app/pages/optimizer/optimizer.html index 02b33b0..cc74b07 100644 --- a/lumenator/src/app/pages/optimizer/optimizer.html +++ b/lumenator/src/app/pages/optimizer/optimizer.html @@ -225,5 +225,126 @@

} + + @if (repeatCard(); as c) { +
+

+ Repeat reads + +

+ + + @if (rr.setting(); as st) { + @if (st.problem; as why) { +

+ Repeat reads are off now: the settings file {{ st.file }} could not be read + ({{ why }}). The Repeat reads switch on the Context tab rewrites it. +

+ } @else if (!st.on) { +

+ Repeat reads are off now: the settings file says off, so every read returns its full result. +

+ } + @if (st.problem || !st.on) { +

+ A LUMEN_REPEAT_READS value in a Claude Code MCP config takes priority over the + settings file, and this window cannot see it. +

+ } + } @else if (rr.readError()) { +

+ The repeat reads setting could not be read, so nothing is said here about its current + state. The Context tab shows why. +

+ } + + @if (c.served === 0) { +

+ No repeat read has been answered short yet. + A repeat read is a file Claude reads again in the same conversation; Lumen can answer it + with a short note that the file is unchanged, or with only what an edit changed, and + those answers are counted here. +

+ } @else { +

+ Answered so far, for files Claude had already read in the same conversation. +

+
+
+
+ Unchanged answers served +
+
+ {{ fmtTokens(c.unchangedServed) }} +
+
+ A short note instead of the whole result. +
+
+
+
+ Edits served as a delta +
+
+ {{ fmtTokens(c.deltasServed) }} +
+
+ Only what the edit changed. +
+
+
+
+ Tokens not re-sent +
+
+ {{ c.measuredAnswers === 0 ? '—' : fmtTokens(c.tokensNotResent) }} +
+
+ @if (c.measuredAnswers === 0) { + None of these answers has a measured token count, so no figure is claimed. + } @else { + Counted against reading the whole file again, not against the outline Lumen + sends with repeat reads off. + @if (c.measuredAnswers < c.served) { + Covers the {{ fmtTokens(c.measuredAnswers) }} of {{ fmtTokens(c.served) }} + answers whose token count was measured. + } + } +
+
+
+
+ Full result asked for again (fresh=true) +
+
+ {{ fmtTokens(c.freshRequests) }} of {{ fmtTokens(c.asked) }} +
+
+ {{ fmtTokens(c.asked) }} = {{ fmtTokens(c.unchangedServed) }} unchanged + + {{ fmtTokens(c.deltasServed) }} delta{{ c.deltasServed === 1 ? '' : 's' }} + {{ fmtTokens(c.freshRequests) }} asked again. +
+
+
+

+ Counted apart from the totals above: these answers are in none of the token totals, + the by-tool and by-channel breakdowns, or the net value. +

+ } + + @if (c.unidentifiedReads > 0) { +

+ {{ fmtTokens(c.unidentifiedReads) }} read{{ c.unidentifiedReads === 1 ? '' : 's' }} got a + full result because Lumen could not tell which conversation + {{ c.unidentifiedReads === 1 ? 'it' : 'they' }} came from. + Open Setup + to run it again, then restart Claude Code. +

+ } +
+ } + diff --git a/lumenator/src/app/pages/optimizer/optimizer.spec.ts b/lumenator/src/app/pages/optimizer/optimizer.spec.ts index 1471cb7..6fab2b5 100644 --- a/lumenator/src/app/pages/optimizer/optimizer.spec.ts +++ b/lumenator/src/app/pages/optimizer/optimizer.spec.ts @@ -5,7 +5,13 @@ import { SessionService } from '../../session.service'; import { TauriBridge } from '../../tauri-bridge'; import { FakeTauriBridge } from '../../tauri-bridge.fake'; import { RATE } from '../../components/index'; -import type { OptimizerReport, UsageReport, TokenAgg } from '../../components/index'; +import type { + OptimizerReport, + RepeatReads, + RepeatReadsSetting, + TokenAgg, + UsageReport, +} from '../../components/index'; /** * The optimizer page is where Lumen makes its central claim: "I saved you this @@ -50,14 +56,36 @@ describe('Optimizer', () => { } as UsageReport; } + const SETTINGS_FILE = '/Users/me/.lumen/lumen_settings.json'; + + function setting(over: Partial = {}): RepeatReadsSetting { + return { on: true, source: 'default', problem: null, file: SETTINGS_FILE, ...over }; + } + + function repeats(over: Partial = {}): RepeatReads { + return { + unchangedServed: 0, + deltasServed: 0, + tokensNotResent: 0, + measuredAnswers: 0, + freshRequests: 0, + unidentifiedReads: 0, + ...over, + }; + } + /** Build the page with the given backend reports already resolved. */ async function build( optimizerReport = report(), usageReport = usage(), + seed?: (b: FakeTauriBridge) => void, ): Promise { bridge = new FakeTauriBridge(); bridge.responses.set('get_optimizer_stats', optimizerReport); bridge.responses.set('get_usage', usageReport); + // No settings file: repeat reads are on by default. + bridge.responses.set('get_repeat_reads_setting', setting()); + seed?.(bridge); TestBed.configureTestingModule({ providers: [ provideRouter([]), @@ -67,17 +95,26 @@ describe('Optimizer', () => { }); fixture = TestBed.createComponent(Optimizer); fixture.detectChanges(); - // Let the constructor's two refresh calls resolve. - await Promise.resolve(); - await Promise.resolve(); - fixture.detectChanges(); + await settle(); return fixture.componentInstance; } + /** Let the constructor's refreshes and the setting's read resolve, then render. */ + async function settle(): Promise { + for (let i = 0; i < 10; i++) await Promise.resolve(); + fixture.detectChanges(); + } + function text(): string { return (fixture.nativeElement as HTMLElement).textContent ?? ''; } + /** What one element renders, whitespace collapsed. */ + function rendered(selector: string): string { + const el = (fixture.nativeElement as HTMLElement).querySelector(selector); + return (el?.textContent ?? '').replace(/\s+/g, ' ').trim(); + } + afterEach(() => TestBed.resetTestingModule()); // ── empty state ──────────────────────────────────────────────────────────── @@ -356,11 +393,6 @@ describe('Optimizer', () => { describe('token provenance', () => { const data = { lifetimeOptimizedTokens: 900, lifetimeFullTokens: 1_000 }; - function rendered(selector: string): string { - const el = (fixture.nativeElement as HTMLElement).querySelector(selector); - return (el?.textContent ?? '').replace(/\s+/g, ' ').trim(); - } - it('says exact, and verifiable, when every count was measured', async () => { await build(report({ ...data, unverifiedProvenanceRows: 0, provenanceTotalRows: 7_192 })); expect(rendered('.hero__secondary')) @@ -485,4 +517,302 @@ describe('Optimizer', () => { expect(text()).toContain('Not enough recorded turns yet to price a round'); }); }); + + // ── Repeat reads ─────────────────────────────────────────────────────────── + // + // The card counts answers that are in none of the totals above it. Every test reads what the + // card says on screen: the wording is the claim, and each state has to say what is true of it + // and nothing more. + + describe('repeat reads card', () => { + const CARD = 'section[aria-label="Repeat reads"]'; + const FILE_OFF = setting({ on: false, source: 'file' }); + const CORRUPT = setting({ + on: false, + source: 'file', + problem: 'not JSON: expected value at line 1 column 1', + }); + const n = (v: number): string => v.toLocaleString(); + // 1,540 answers served short and 60 requests for the full result: 1,600 repeat reads. + const SERVED = repeats({ + unchangedServed: 1_200, + deltasServed: 340, + tokensNotResent: 4_125_000, + measuredAnswers: 1_540, + freshRequests: 60, + }); + const BASELINE = + 'Counted against reading the whole file again, not against the outline Lumen sends with repeat reads off.'; + const ENV_CAVEAT = + 'A LUMEN_REPEAT_READS value in a Claude Code MCP config takes priority over the settings file, and this window cannot see it.'; + const OFF_FILE = + 'Repeat reads are off now: the settings file says off, so every read returns its full result.'; + const NONE_YET = 'No repeat read has been answered short yet.'; + + /** What the card says, whitespace collapsed. */ + function said(): string { + expect(fixture.nativeElement.querySelector(CARD)).not.toBeNull(); + return rendered(CARD); + } + + /** One entry per figure: its name, its value and what it is counted against. */ + function figures(): string[] { + return Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll(`${CARD} .repeat-card__row`), + ).map((r) => (r.textContent ?? '').replace(/\s+/g, ' ').trim()); + } + + function deferred(): { promise: Promise; resolve: (v: T) => void } { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; + } + + const fileSays = (s: RepeatReadsSetting) => (b: FakeTauriBridge) => + b.responses.set('get_repeat_reads_setting', s); + const cannotRead = (b: FakeTauriBridge) => b.failures.add('get_repeat_reads_setting'); + + it('reads the setting when the page opens', async () => { + await build(report({ repeatReads: repeats() })); + expect(bridge.countOf('get_repeat_reads_setting')).toBe(1); + }); + + // ── nothing counted ────────────────────────────────────────────────────── + + it('says none yet, and what a repeat read is, with no figure in it', async () => { + await build(report({ repeatReads: repeats() })); + expect(said()).toContain(NONE_YET); + expect(said()).toContain( + 'A repeat read is a file Claude reads again in the same conversation; Lumen can answer it ' + + 'with a short note that the file is unchanged, or with only what an edit changed, and ' + + 'those answers are counted here.', + ); + expect(figures()).toEqual([]); + // Nothing to put a number on: no zero, no ratio, no price. + expect(said()).not.toMatch(/\d|NaN|[%$]/); + expect(said()).not.toContain('off now'); + }); + + it('says repeat reads are off, as the settings file says, when it turns them off and none were counted', async () => { + await build(report({ repeatReads: repeats() }), usage(), fileSays(FILE_OFF)); + expect(said()).toContain(OFF_FILE); + expect(said()).toContain(ENV_CAVEAT); + expect(said()).toContain(NONE_YET); + expect(figures()).toEqual([]); + expect(said()).not.toMatch(/\d|NaN|[%$]/); + }); + + // ── answers served ─────────────────────────────────────────────────────── + + it('shows the four figures, each with what it is counted against', async () => { + await build(report({ repeatReads: SERVED })); + expect(said()).toContain('Answered so far, for files Claude had already read in the same conversation.'); + expect(figures()).toEqual([ + `Unchanged answers served ${n(1_200)} A short note instead of the whole result.`, + `Edits served as a delta ${n(340)} Only what the edit changed.`, + `Tokens not re-sent ${n(4_125_000)} ${BASELINE}`, + `Full result asked for again (fresh=true) ${n(60)} of ${n(1_600)} ` + + `${n(1_600)} = ${n(1_200)} unchanged + ${n(340)} deltas + ${n(60)} asked again.`, + ]); + expect(said()).not.toContain(NONE_YET); + expect(said()).not.toContain('off now'); + expect(said()).not.toContain(ENV_CAVEAT); + }); + + it('says the answers are counted apart from the totals above, and which totals', async () => { + await build(report({ repeatReads: SERVED })); + expect(said()).toContain( + 'Counted apart from the totals above: these answers are in none of the token totals, ' + + 'the by-tool and by-channel breakdowns, or the net value.', + ); + }); + + it('leaves every figure above it as it was', async () => { + const totals = { + lifetimeOptimizedTokens: 900, + lifetimeFullTokens: 1_000, + netValueUsd: 100, + grossValueUsd: 200, + roundCostUsd: 100, + valueRounds: 194, + }; + const above = (): string[] => + ['.hero__num', '.savings-card--caused .savings-card__num', '.period-row'].map(rendered); + await build(report(totals)); + const before = above(); + TestBed.resetTestingModule(); + await build(report({ ...totals, repeatReads: SERVED })); + expect(above()).toEqual(before); + expect(before.slice(0, 2)).toEqual(['+$100', '900']); + }); + + it('never words a figure as a saving, and shows no dollars or percentage', async () => { + await build(report({ repeatReads: SERVED })); + expect(said()).not.toMatch(/\bsav/i); + expect(said()).not.toMatch(/[$%]/); + }); + + it('sits after the not-optimized block, below every total', async () => { + await build( + report({ currentChannel: 'cli', missedCalls: 3, missedFullTokens: 12_000, repeatReads: SERVED }), + ); + const missed = fixture.nativeElement.querySelector('.missed') as HTMLElement; + const card = fixture.nativeElement.querySelector(CARD) as HTMLElement; + expect(missed.compareDocumentPosition(card) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('shows the figures when they arrive after the page opened', async () => { + const o = await build(); + expect(fixture.nativeElement.querySelector(CARD)).toBeNull(); + o.s.optimizerStats.set(report({ repeatReads: SERVED })); + fixture.detectChanges(); + expect(figures()).toHaveLength(4); + }); + + // ── the token figure ───────────────────────────────────────────────────── + + it('says how many answers the token figure covers when only some were measured', async () => { + await build(report({ repeatReads: { ...SERVED, measuredAnswers: 1_000 } })); + expect(figures()[2]).toBe( + `Tokens not re-sent ${n(4_125_000)} ${BASELINE} ` + + `Covers the ${n(1_000)} of ${n(1_540)} answers whose token count was measured.`, + ); + }); + + it('claims no token figure when no answer has a measured count', async () => { + await build(report({ repeatReads: repeats({ unchangedServed: 4 }) })); + expect(figures()[2]).toBe( + 'Tokens not re-sent — None of these answers has a measured token count, so no figure is claimed.', + ); + }); + + // ── asked for again ────────────────────────────────────────────────────── + + it('shows "0 of m" when answers were served and none was asked for again', async () => { + await build( + report({ repeatReads: repeats({ unchangedServed: 5, tokensNotResent: 900, measuredAnswers: 5 }) }), + ); + expect(figures()[3]).toBe( + 'Full result asked for again (fresh=true) 0 of 5 5 = 5 unchanged + 0 deltas + 0 asked again.', + ); + }); + + it('says "1 delta", not "1 deltas", when a single edit was served as one', async () => { + await build( + report({ + repeatReads: repeats({ unchangedServed: 2, deltasServed: 1, tokensNotResent: 90, measuredAnswers: 3 }), + }), + ); + expect(figures()[3]).toBe( + 'Full result asked for again (fresh=true) 0 of 3 3 = 2 unchanged + 1 delta + 0 asked again.', + ); + }); + + it('hides that figure when nothing was served, because there is nothing for it to be a share of', async () => { + await build(report({ repeatReads: repeats({ freshRequests: 3 }) })); + expect(said()).toContain(NONE_YET); + expect(figures()).toEqual([]); + expect(said()).not.toContain('fresh'); + }); + + // ── reads Lumen could not place in a conversation ──────────────────────── + + it('says how many reads got a full result for want of a conversation, and links to Setup', async () => { + await build(report({ repeatReads: { ...SERVED, unidentifiedReads: 3 } })); + expect(said()).toContain( + '3 reads got a full result because Lumen could not tell which conversation they came from.', + ); + expect(said()).toContain('Open Setup to run it again, then restart Claude Code.'); + const link = fixture.nativeElement.querySelector(`${CARD} a[href="/setup"]`) as HTMLAnchorElement; + expect(link.textContent?.trim()).toBe('Open Setup'); + }); + + it('says "1 read", not "1 reads", for a single one', async () => { + await build(report({ repeatReads: { ...SERVED, unidentifiedReads: 1 } })); + expect(said()).toContain( + '1 read got a full result because Lumen could not tell which conversation it came from.', + ); + }); + + it('has no such line, and no link to Setup, when every read could be placed', async () => { + await build(report({ repeatReads: SERVED })); + expect(said()).not.toContain('could not tell which conversation'); + expect(fixture.nativeElement.querySelector(`${CARD} a`)).toBeNull(); + }); + + it('shows that line beside the none-yet text when nothing was served but reads went unplaced', async () => { + await build(report({ repeatReads: repeats({ unidentifiedReads: 4 }) })); + expect(said()).toContain(NONE_YET); + expect(said()).toContain('4 reads got a full result because Lumen could not tell'); + expect(fixture.nativeElement.querySelector(`${CARD} a[href="/setup"]`)).not.toBeNull(); + }); + + // ── answers served, and the setting is off now ─────────────────────────── + + it('labels the figures as what happened so far, and says repeat reads are off now, when the file turns them off', async () => { + await build(report({ repeatReads: SERVED }), usage(), fileSays(FILE_OFF)); + expect(said()).toContain('Answered so far'); + expect(said()).toContain(OFF_FILE); + expect(said()).toContain(ENV_CAVEAT); + expect(figures()).toHaveLength(4); + }); + + it('says a settings file that could not be read is why repeat reads are off now', async () => { + await build(report({ repeatReads: SERVED }), usage(), fileSays(CORRUPT)); + expect(said()).toContain( + `Repeat reads are off now: the settings file ${SETTINGS_FILE} could not be read ` + + '(not JSON: expected value at line 1 column 1). ' + + 'The Repeat reads switch on the Context tab rewrites it.', + ); + expect(said()).toContain(ENV_CAVEAT); + expect(said()).not.toContain('the settings file says off'); + }); + + // ── the setting is not known ───────────────────────────────────────────── + + it('says nothing about on or off until the setting has been read', async () => { + const read = deferred(); + await build(report({ repeatReads: repeats() }), usage(), (b) => { + b.getRepeatReadsSetting = () => read.promise; + }); + expect(said()).toContain(NONE_YET); + expect(said()).not.toMatch(/\b(on|off)\b/i); + expect(said()).not.toContain('could not be read'); + }); + + it('says it could not read the setting, and claims nothing about it, when the read fails', async () => { + await build(report({ repeatReads: repeats() }), usage(), cannotRead); + expect(said()).toContain( + 'The repeat reads setting could not be read, so nothing is said here about its current state. ' + + 'The Context tab shows why.', + ); + expect(said()).not.toMatch(/\b(on|off)\b/i); + }); + + it('still shows the figures when the setting could not be read, with no claim about it', async () => { + await build(report({ repeatReads: SERVED }), usage(), cannotRead); + expect(figures()).toHaveLength(4); + expect(said()).not.toContain('off now'); + expect(said()).not.toContain('the settings file says'); + expect(said()).not.toContain(ENV_CAVEAT); + }); + + // ── a backend that predates the figures ────────────────────────────────── + + it('renders nothing from a backend that sends no repeat reads figures', async () => { + await build(report({ lifetimeOptimizedTokens: 900, lifetimeFullTokens: 1_000 })); + expect(fixture.nativeElement.querySelector(CARD)).toBeNull(); + expect(text().toLowerCase()).not.toContain('repeat'); + // The page is there; it is the card that is not. + expect(rendered('.savings-card--caused .savings-card__num')).toBe('900'); + }); + + it('renders nothing from such a backend even when the setting is off', async () => { + await build(report(), usage(), fileSays(FILE_OFF)); + expect(fixture.nativeElement.querySelector(CARD)).toBeNull(); + expect(text().toLowerCase()).not.toContain('repeat'); + }); + }); }); diff --git a/lumenator/src/app/pages/optimizer/optimizer.ts b/lumenator/src/app/pages/optimizer/optimizer.ts index aad6cb8..af66401 100644 --- a/lumenator/src/app/pages/optimizer/optimizer.ts +++ b/lumenator/src/app/pages/optimizer/optimizer.ts @@ -8,6 +8,7 @@ import { import { RouterLink, RouterLinkActive } from '@angular/router'; import { Firefly } from '../../components/firefly/firefly'; import { SessionService } from '../../session.service'; +import { RepeatReadsService } from '../../repeat-reads.service'; import { RATE } from '../../components/index'; import type { ChannelBreakdown, ToolBreakdown } from '../../components/index'; import { LumenTooltip } from '../../directives/tooltip.directive'; @@ -76,11 +77,14 @@ export class Optimizer implements OnInit { ); readonly s = inject(SessionService); + readonly rr = inject(RepeatReadsService); ngOnInit(): void { // Only the badge: this page's own figures already arrive through the daemon // stream, and the fault count is the one thing not on it. this.s.refreshFaultCount(); + // The repeat reads card says whether the settings file turns them off, so it reads that file. + void this.rr.load(); } // ── hero ────────────────────────────────────────────────────────────────── @@ -155,6 +159,16 @@ export class Optimizer implements OnInit { this.s.currentChannel() === 'cli' && this.s.missedReads().calls > 0, ); + // ── repeat reads (answers in none of the totals above) ──────────────────── + + /** The figures with the two sums the card needs. Null from a backend older than 1.8.0: no card then. */ + readonly repeatCard = computed(() => { + const r = this.s.optimizerStats()?.repeatReads; + if (!r) return null; + const served = r.unchangedServed + r.deltasServed; + return { ...r, served, asked: served + r.freshRequests }; + }); + // ── breakdown bar helpers ───────────────────────────────────────────────── readonly maxToolSaved = computed(() => diff --git a/lumenator/src/app/repeat-reads.service.spec.ts b/lumenator/src/app/repeat-reads.service.spec.ts new file mode 100644 index 0000000..7ab5068 --- /dev/null +++ b/lumenator/src/app/repeat-reads.service.spec.ts @@ -0,0 +1,216 @@ +import { TestBed } from '@angular/core/testing'; +import { RepeatReadsService } from './repeat-reads.service'; +import { TauriBridge } from './tauri-bridge'; +import { FakeTauriBridge } from './tauri-bridge.fake'; +import type { RepeatReadsSetting } from './components/index'; + +/** + * The service says what the settings file holds, and says so when it cannot tell. It + * never answers for the server: an env value in a Claude Code MCP config outranks the file. + */ +describe('RepeatReadsService', () => { + const FILE = '/home/me/.lumen/lumen_settings.json'; + let bridge: FakeTauriBridge; + let svc: RepeatReadsService; + + function setting(over: Partial = {}): RepeatReadsSetting { + return { on: true, source: 'default', problem: null, file: FILE, ...over }; + } + + function deferred(): { promise: Promise; resolve: (v: T) => void } { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; + } + + async function ticks(): Promise { + for (let i = 0; i < 6; i++) await Promise.resolve(); + } + + beforeEach(() => { + bridge = new FakeTauriBridge(); + TestBed.configureTestingModule({ providers: [{ provide: TauriBridge, useValue: bridge }] }); + svc = TestBed.inject(RepeatReadsService); + }); + + afterEach(() => TestBed.resetTestingModule()); + + // ── reading ──────────────────────────────────────────────────────────────── + + it('knows nothing until it has read the file', () => { + expect(svc.setting()).toBeNull(); + expect(svc.readError()).toBeNull(); + expect(svc.writeError()).toBeNull(); + expect(svc.saving()).toBe(false); + }); + + it('publishes what the settings file holds', async () => { + bridge.responses.set('get_repeat_reads_setting', setting({ on: false, source: 'file' })); + await svc.load(); + expect(svc.setting()).toEqual(setting({ on: false, source: 'file' })); + expect(svc.readError()).toBeNull(); + }); + + it('says why it could not read, and claims no state', async () => { + bridge.failures.add('get_repeat_reads_setting'); + await svc.load(); + expect(svc.readError()).toContain('fake: get_repeat_reads_setting failed'); + expect(svc.setting()).toBeNull(); + }); + + it('carries a rejection that is a plain string, as Tauri sends it, unchanged', async () => { + bridge.getRepeatReadsSetting = () => Promise.reject('cannot tell where the ledger lives'); + await svc.load(); + expect(svc.readError()).toBe('cannot tell where the ledger lives'); + }); + + it('forgets a good reading when the next one fails', async () => { + bridge.responses.set('get_repeat_reads_setting', setting()); + await svc.load(); + bridge.failures.add('get_repeat_reads_setting'); + await svc.load(); + expect(svc.setting()).toBeNull(); + expect(svc.readError()).not.toBeNull(); + }); + + it('clears the error when a later read works', async () => { + bridge.failures.add('get_repeat_reads_setting'); + await svc.load(); + bridge.failures.delete('get_repeat_reads_setting'); + bridge.responses.set('get_repeat_reads_setting', setting()); + await svc.load(); + expect(svc.readError()).toBeNull(); + expect(svc.setting()).toEqual(setting()); + }); + + it('forgets an old write error when it reads again, so a reopened window does not repeat it', async () => { + bridge.failures.add('set_repeat_reads_setting'); + await svc.set(false); + expect(svc.writeError()).not.toBeNull(); + bridge.responses.set('get_repeat_reads_setting', setting()); + await svc.load(); + expect(svc.writeError()).toBeNull(); + }); + + it.each([undefined, null, 'on', {}, { on: 'yes' }])( + 'does not take %j for a setting', + async (payload) => { + bridge.responses.set('get_repeat_reads_setting', payload); + await svc.load(); + expect(svc.setting()).toBeNull(); + expect(svc.readError()).toContain('not a setting'); + }, + ); + + // ── writing ──────────────────────────────────────────────────────────────── + + it('sends the choice and publishes what the file says afterwards', async () => { + bridge.responses.set('set_repeat_reads_setting', setting({ on: false, source: 'file' })); + const ok = await svc.set(false); + expect(bridge.lastArgsOf('set_repeat_reads_setting')).toEqual({ on: false }); + expect(ok).toBe(true); + expect(svc.setting()).toEqual(setting({ on: false, source: 'file' })); + expect(svc.writeError()).toBeNull(); + expect(svc.saving()).toBe(false); + }); + + it('is saving while the write is in flight, and not after', async () => { + const write = deferred(); + bridge.setRepeatReadsSetting = () => write.promise; + const done = svc.set(false); + await ticks(); + expect(svc.saving()).toBe(true); + write.resolve(setting({ on: false, source: 'file' })); + await done; + expect(svc.saving()).toBe(false); + }); + + it('is false, with the reason, when the write fails, and the setting stays as it was', async () => { + bridge.responses.set('get_repeat_reads_setting', setting()); + await svc.load(); + bridge.failures.add('set_repeat_reads_setting'); + const ok = await svc.set(false); + expect(ok).toBe(false); + expect(svc.writeError()).toContain('fake: set_repeat_reads_setting failed'); + expect(svc.setting()).toEqual(setting()); + expect(svc.saving()).toBe(false); + }); + + it('forgets the last write error as the next write starts, not when it ends', async () => { + bridge.failures.add('set_repeat_reads_setting'); + await svc.set(false); + expect(svc.writeError()).not.toBeNull(); + bridge.failures.delete('set_repeat_reads_setting'); + const write = deferred(); + bridge.setRepeatReadsSetting = () => write.promise; + const done = svc.set(false); + await ticks(); + expect(svc.writeError()).toBeNull(); + write.resolve(setting({ on: false, source: 'file' })); + await done; + expect(svc.writeError()).toBeNull(); + }); + + it.each([ + [false, true, 'says repeat reads are on, not off'], + [true, false, 'says repeat reads are off, not on'], + ])( + 'is not a success when asked for %j and the file afterwards says %j', + async (asked, after, words) => { + bridge.responses.set('set_repeat_reads_setting', setting({ on: after, source: 'file' })); + const ok = await svc.set(asked); + expect(ok).toBe(false); + expect(svc.writeError()).toContain(words); + expect(svc.setting()?.on).toBe(after); + }, + ); + + it('is not a success when the file cannot be read back', async () => { + bridge.responses.set( + 'set_repeat_reads_setting', + setting({ on: false, source: 'file', problem: 'not JSON: expected value at line 1' }), + ); + const ok = await svc.set(false); + expect(ok).toBe(false); + expect(svc.writeError()).toContain('not JSON: expected value at line 1'); + }); + + it('says so when the answer to a write is not a setting', async () => { + bridge.responses.set('set_repeat_reads_setting', undefined); + const ok = await svc.set(false); + expect(ok).toBe(false); + expect(svc.writeError()).toContain('not a setting'); + }); + + // ── ordering ─────────────────────────────────────────────────────────────── + + it('lets a read wait for the write in flight, so it cannot show the state from before it', async () => { + const write = deferred(); + bridge.setRepeatReadsSetting = () => write.promise; + bridge.responses.set('get_repeat_reads_setting', setting({ on: false, source: 'file' })); + const writing = svc.set(false); + const reading = svc.load(); + await ticks(); + expect(bridge.countOf('get_repeat_reads_setting')).toBe(0); + write.resolve(setting({ on: false, source: 'file' })); + await Promise.all([writing, reading]); + expect(bridge.countOf('get_repeat_reads_setting')).toBe(1); + expect(svc.setting()?.on).toBe(false); + }); + + it('lets a write wait for the read in flight, so that read cannot land after it and undo it', async () => { + const read = deferred(); + bridge.getRepeatReadsSetting = () => read.promise; + bridge.responses.set('set_repeat_reads_setting', setting({ on: false, source: 'file' })); + const reading = svc.load(); + const writing = svc.set(false); + await ticks(); + expect(bridge.countOf('set_repeat_reads_setting')).toBe(0); + read.resolve(setting()); + await Promise.all([reading, writing]); + expect(bridge.countOf('set_repeat_reads_setting')).toBe(1); + expect(svc.setting()?.on).toBe(false); + }); +}); diff --git a/lumenator/src/app/repeat-reads.service.ts b/lumenator/src/app/repeat-reads.service.ts new file mode 100644 index 0000000..a5ca4fe --- /dev/null +++ b/lumenator/src/app/repeat-reads.service.ts @@ -0,0 +1,88 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { TauriBridge } from './tauri-bridge'; +import type { RepeatReadsSetting } from './components/index'; + +/** + * The repeat-reads switch as the settings file holds it, shared by the Home toggle and the + * Optimizer card so both say the same thing about the same file. + * + * It never answers for the server. A `LUMEN_REPEAT_READS` value in a Claude Code MCP config + * outranks the file and the app cannot see it, so what is held here is the file and the + * default, nothing more. + */ +@Injectable({ providedIn: 'root' }) +export class RepeatReadsService { + private readonly bridge = inject(TauriBridge); + + /** Reads and writes run one after another, so a read cannot overtake the write before it. */ + private tail: Promise = Promise.resolve(); + + /** What the file says; null until it was read, and again when it could not be. */ + readonly setting = signal(null); + /** Why the file could not be read. While this is set nothing is known, and nothing is claimed. */ + readonly readError = signal(null); + /** Why the last switch did not take. Cleared by the next read as well, so a reopened window starts clean. */ + readonly writeError = signal(null); + readonly saving = signal(false); + + load(): Promise { + return this.enqueue(async () => { + this.readError.set(null); + this.writeError.set(null); + try { + this.setting.set(asSetting(await this.bridge.getRepeatReadsSetting())); + } catch (e) { + this.setting.set(null); + this.readError.set(describe(e)); + } + }); + } + + /** Writes the choice, then shows what the file says. Resolves false when it did not take. */ + set(on: boolean): Promise { + return this.enqueue(async () => { + this.writeError.set(null); + this.saving.set(true); + try { + const after = asSetting(await this.bridge.setRepeatReadsSetting(on)); + this.setting.set(after); + const mismatch = disagreement(on, after); + this.writeError.set(mismatch); + return mismatch === null; + } catch (e) { + this.writeError.set(describe(e)); + return false; + } finally { + this.saving.set(false); + } + }); + } + + private enqueue(job: () => Promise): Promise { + const run = this.tail.then(job, job); + this.tail = run; + return run; + } +} + +function asSetting(v: unknown): RepeatReadsSetting { + if (typeof v !== 'object' || v === null || typeof (v as RepeatReadsSetting).on !== 'boolean') { + throw new Error('the app answered with something that is not a setting'); + } + return v as RepeatReadsSetting; +} + +/** Why the file after a write is not what was asked for, or null when it is. */ +function disagreement(asked: boolean, after: RepeatReadsSetting): string | null { + if (after.problem) { + return `The settings file was written but cannot be read back: ${after.problem}.`; + } + if (after.on !== asked) { + return `The settings file says repeat reads are ${after.on ? 'on' : 'off'}, not ${asked ? 'on' : 'off'}. Something else may have rewritten it.`; + } + return null; +} + +function describe(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} diff --git a/lumenator/src/app/tauri-bridge.fake.ts b/lumenator/src/app/tauri-bridge.fake.ts index 0893ff8..55643d7 100644 --- a/lumenator/src/app/tauri-bridge.fake.ts +++ b/lumenator/src/app/tauri-bridge.fake.ts @@ -1,5 +1,6 @@ import { Observable, Subject } from 'rxjs'; import type { TauriBridge } from './tauri-bridge'; +import type { RepeatReadsSetting } from './components/index'; /** The command behind `getVersion()` in `@tauri-apps/api/app`. */ export const APP_VERSION_CMD = 'plugin:app|version'; @@ -55,6 +56,14 @@ export class FakeTauriBridge implements TauriBridge { return this.invoke(APP_VERSION_CMD); } + getRepeatReadsSetting(): Promise { + return this.invoke('get_repeat_reads_setting'); + } + + setRepeatReadsSetting(on: boolean): Promise { + return this.invoke('set_repeat_reads_setting', { on }); + } + isPermissionGranted(): Promise { return Promise.resolve(this.permissionGranted); } diff --git a/lumenator/src/app/tauri-bridge.ts b/lumenator/src/app/tauri-bridge.ts index 6b6f18b..085598c 100644 --- a/lumenator/src/app/tauri-bridge.ts +++ b/lumenator/src/app/tauri-bridge.ts @@ -9,6 +9,7 @@ import { sendNotification, } from '@tauri-apps/plugin-notification'; import { Observable } from 'rxjs'; +import type { RepeatReadsSetting } from './components/index'; /** * The whole surface Lumen uses from the Tauri runtime, behind one injectable. @@ -47,6 +48,16 @@ export class TauriBridge { return getVersion(); } + /** The repeat-reads switch as the settings file holds it (never the server's effective mode). */ + getRepeatReadsSetting(): Promise { + return this.invoke('get_repeat_reads_setting'); + } + + /** Write the switch to the settings file; resolves to what the file says afterwards. */ + setRepeatReadsSetting(on: boolean): Promise { + return this.invoke('set_repeat_reads_setting', { on }); + } + isPermissionGranted(): Promise { return isPermissionGranted(); }