From c21dbb378475f58e6cf223a041f6eba47423c9db Mon Sep 17 00:00:00 2001 From: HackPoint <6758579+HackPoint@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:09:23 +0300 Subject: [PATCH 01/12] feat(stats): the optimizer report counts what repeat reads served, apart from the savings totals Served answers (unchanged and delta, from smart_read and recall_file) are not in LUMEN_ROUTES, so the token totals, by-tool, by-channel and net value never included them and still do not. The report now carries their own counts under repeatReads: unchanged and delta answers, tokens not re-sent against reading the whole file again (measured rows only), fresh=true requests, and reads that could not be tied to a conversation. --- crates/lumen-stats/src/lib.rs | 84 +++++++ crates/lumen-stats/tests/aggregates.rs | 303 +++++++++++++++++++++++++ 2 files changed, 387 insertions(+) diff --git a/crates/lumen-stats/src/lib.rs b/crates/lumen-stats/src/lib.rs index e279751..3eb5aa5 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; use sqlx::sqlite::SqlitePoolOptions; @@ -350,6 +354,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 { @@ -405,6 +441,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 { @@ -602,6 +640,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 0f7625e..15e85f2 100644 --- a/crates/lumen-stats/tests/aggregates.rs +++ b/crates/lumen-stats/tests/aggregates.rs @@ -1015,11 +1015,314 @@ 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_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(1, 1, 970 + 710, 2, 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; From 781d96f1d8e1a6915b88ca4bea2c9bbab34630ec Mon Sep 17 00:00:00 2001 From: HackPoint <6758579+HackPoint@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:09:31 +0300 Subject: [PATCH 02/12] feat(app): Tauri commands to read and write the repeat reads setting in the settings file get_repeat_reads_setting reports what the file and the default say, never the server's effective mode, since a LUMEN_REPEAT_READS value in a Claude Code MCP config outranks the file and the app cannot see it. set_repeat_reads_setting writes the choice and returns the file read back, so a write that did not take is visible instead of assumed. A corrupt file reads as off with the reason, and switching on rewrites it. --- lumenator/src-tauri/src/lib.rs | 328 +++++++++++++++++++++++++++++++++ 1 file changed, 328 insertions(+) diff --git a/lumenator/src-tauri/src/lib.rs b/lumenator/src-tauri/src/lib.rs index 8e5a7d3..3a1805b 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,225 @@ 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() { + 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(); + assert!(err.contains("cannot write"), "got {err}"); + let file = lumen_core::settings::settings_path(&db) + .display() + .to_string(); + assert!(err.contains(&file), "names {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}" + ); + } } From f3a887e84deca82ddc8e38d465e1b4d2651e0321 Mon Sep 17 00:00:00 2001 From: HackPoint <6758579+HackPoint@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:09:31 +0300 Subject: [PATCH 03/12] feat(app): a Repeat reads switch in the Home window, on by default, saved in the settings file The switch shows the settings file and the default, and says so: a LUMEN_REPEAT_READS value in an MCP config takes priority and is not visible here. A file that cannot be read shows why, that repeat reads are off, and that switching on rewrites it. A failed read claims neither state and offers Retry; a failed write puts the switch back and shows the error. Reads and writes run one after another so a read never overtakes the write before it. --- lumenator/src/app/components/index.ts | 46 ++++ lumenator/src/app/pages/home/home.css | 99 ++++++++ lumenator/src/app/pages/home/home.html | 67 +++++ lumenator/src/app/pages/home/home.spec.ts | 235 ++++++++++++++++++ lumenator/src/app/pages/home/home.ts | 12 + .../src/app/repeat-reads.service.spec.ts | 216 ++++++++++++++++ lumenator/src/app/repeat-reads.service.ts | 88 +++++++ lumenator/src/app/tauri-bridge.fake.ts | 9 + lumenator/src/app/tauri-bridge.ts | 11 + 9 files changed, 783 insertions(+) create mode 100644 lumenator/src/app/repeat-reads.service.spec.ts create mode 100644 lumenator/src/app/repeat-reads.service.ts 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..d4ec18f 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..cd9c774 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,229 @@ 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', async () => { + const write = deferred(); + await open((b) => { + b.setRepeatReadsSetting = () => write.promise; + }); + el(BOX).click(); + await settle(); + expect(el(BOX).disabled).toBe(true); + write.resolve(FILE_OFF); + await settle(); + expect(el(BOX).disabled).toBe(false); + }); + + 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..77120ad 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,13 @@ 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; + 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/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(); } From 3d4cc5c1233444ae33e1e113eb9c7c893d7f83e4 Mon Sep 17 00:00:00 2001 From: HackPoint <6758579+HackPoint@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:09:31 +0300 Subject: [PATCH 04/12] fix(doctor): the remedy for an unreadable settings file names the Repeat reads switch in the app --- crates/lumen-cli/src/doctor.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/lumen-cli/src/doctor.rs b/crates/lumen-cli/src/doctor.rs index 5b7d703..c37561b 100644 --- a/crates/lumen-cli/src/doctor.rs +++ b/crates/lumen-cli/src/doctor.rs @@ -124,7 +124,8 @@ 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 in the \ + Home window rewrites it. It applies to the next Lumen call, with no restart." ), }, } @@ -1245,6 +1246,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 in the Home 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("Home window"), "{fix}"); + } + #[test] fn a_healthy_default_install_says_on_by_default_and_reports_nothing() { let f = Facts { From 87c8bef3db04f745834007240433b72452b98585 Mon Sep 17 00:00:00 2001 From: HackPoint <6758579+HackPoint@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:42:24 +0300 Subject: [PATCH 05/12] fix(settings): the unreadable-file message points at the Repeat reads switch, which now exists The writer's doc comment said nothing in the app calls it, and the file-problem message and its test said there was no switch to go back to. The Home window has one now, and choosing a setting there rewrites a file that cannot be used. A bad environment value is still not something the switch can fix, so that message keeps not mentioning it. --- crates/lumen-core/src/settings.rs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/crates/lumen-core/src/settings.rs b/crates/lumen-core/src/settings.rs index 6ddf3b1..11cf2c0 100644 --- a/crates/lumen-core/src/settings.rs +++ b/crates/lumen-core/src/settings.rs @@ -70,8 +70,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 in the Home window of the Lumen app, \ + which rewrites it. Setting {REPEAT_READS_ENV} to on or off takes precedence \ + over the file.", file.display() ), } @@ -229,10 +231,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's Home window calls this (`set_repeat_reads_setting`). +/// 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 @@ -426,12 +426,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 Home window's switch rewrites the file, so it repairs a file that cannot be used. + assert!( + said.contains("Repeat reads switch in the Home 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("Home window"), "the same for the environment variable's message" ); } From 20ec30be6a73bcf59d5249a031d893d55bfc6b8f Mon Sep 17 00:00:00 2001 From: HackPoint <6758579+HackPoint@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:45:38 +0300 Subject: [PATCH 06/12] fix(settings,doctor): name the switch where a user finds it, the Context tab of the main window The docs and the tab bar say "main window" and "Context"; "Home" is only the component's name, so a message sending someone to the Home window sent them to nothing they can see. --- crates/lumen-cli/src/doctor.rs | 9 +++++---- crates/lumen-core/src/settings.rs | 14 +++++++------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/lumen-cli/src/doctor.rs b/crates/lumen-cli/src/doctor.rs index c37561b..a7eb858 100644 --- a/crates/lumen-cli/src/doctor.rs +++ b/crates/lumen-cli/src/doctor.rs @@ -124,8 +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. In the Lumen app, the Repeat reads switch in the \ - Home window rewrites it. 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." ), }, } @@ -1256,7 +1257,7 @@ mod tests { }; let fix = findings(&f)[0].remedy(); assert!( - fix.contains("Repeat reads switch in the Home window"), + fix.contains("Repeat reads switch on the Context tab of the main window"), "{fix}" ); assert!(fix.contains("rewrites"), "{fix}"); @@ -1267,7 +1268,7 @@ mod tests { ..healthy() }; let fix = findings(&f)[0].remedy(); - assert!(!fix.contains("Home window"), "{fix}"); + assert!(!fix.contains("Context tab"), "{fix}"); } #[test] diff --git a/crates/lumen-core/src/settings.rs b/crates/lumen-core/src/settings.rs index 11cf2c0..ca2406b 100644 --- a/crates/lumen-core/src/settings.rs +++ b/crates/lumen-core/src/settings.rs @@ -71,9 +71,9 @@ 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 choose a \ - setting with the Repeat reads switch in the Home window of the Lumen app, \ - which rewrites it. Setting {REPEAT_READS_ENV} to on or off takes precedence \ - over the file.", + 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() ), } @@ -231,7 +231,7 @@ pub fn surface(problem: &Problem, file: &Path, min_interval: std::time::Duration /// Write the persisted setting for a ledger at `db`. /// -/// The Repeat reads switch in the Lumen app's Home window calls this (`set_repeat_reads_setting`). +/// 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 @@ -426,9 +426,9 @@ mod tests { assert!(said.contains("/data/lumen_settings.json"), "{said}"); assert!(said.contains("delete the file"), "{said}"); assert!(said.contains(REPEAT_READS_ENV), "{said}"); - // The Home window's switch rewrites the file, so it repairs a file that cannot be used. + // The main window's switch rewrites the file, so it repairs a file that cannot be used. assert!( - said.contains("Repeat reads switch in the Home window"), + 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 @@ -436,7 +436,7 @@ mod tests { assert!( !Problem::Env("x".into()) .describe(file) - .contains("Home window"), + .contains("Context tab"), "the same for the environment variable's message" ); } From 341a94970ed714cc2922c5249bc47afdd2bdc96f Mon Sep 17 00:00:00 2001 From: HackPoint <6758579+HackPoint@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:04:15 +0300 Subject: [PATCH 07/12] feat(app): the Optimizer page counts what repeat reads served, apart from every total A card after the not-optimized block shows the answers served short (unchanged notes, deltas), the tokens not re-sent counted against reading the whole file again, how many full results were asked for again, and the reads Lumen could not place in a conversation. It states that these are in none of the token totals, the by-tool and by-channel breakdowns or the net value, and it says when the settings file turns repeat reads off. It never words a figure as a saving and shows no dollars or percentage; a backend older than 1.8.0 sends no figures and then there is no card. --- .../src/app/pages/optimizer/optimizer.css | 107 ++++++ .../src/app/pages/optimizer/optimizer.html | 121 +++++++ .../src/app/pages/optimizer/optimizer.spec.ts | 339 +++++++++++++++++- .../src/app/pages/optimizer/optimizer.ts | 14 + 4 files changed, 571 insertions(+), 10 deletions(-) diff --git a/lumenator/src/app/pages/optimizer/optimizer.css b/lumenator/src/app/pages/optimizer/optimizer.css index 9a71900..ddbf1ec 100644 --- a/lumenator/src/app/pages/optimizer/optimizer.css +++ b/lumenator/src/app/pages/optimizer/optimizer.css @@ -451,3 +451,110 @@ text-align: left; color: var(--lumen-text-dim); } + +/* ── repeat reads: counted apart from the totals above ── */ +.repeat-card { + width: 100%; + background: var(--lumen-bg-secondary); + border: 1px solid var(--lumen-border); + border-radius: var(--lumen-radius-sm); + padding: 0.75rem 0.85rem; + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.repeat-card__title { + margin: 0; + font-family: var(--lumen-font-sans); + font-size: 0.66rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--lumen-text-dim); +} + +.repeat-card__state, +.repeat-card__none, +.repeat-card__lead { + margin: 0; + 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__none { + color: var(--lumen-text-dim); +} + +.repeat-card__state--problem { + color: var(--lumen-warn); +} + +.repeat-card__note { + margin: 0; + font-family: var(--lumen-font-sans); + font-size: 0.64rem; + line-height: 1.45; + color: var(--lumen-text-dim); + overflow-wrap: anywhere; +} + +.repeat-card__rows { + margin: 0; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +/* 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; + column-gap: 0.75rem; + row-gap: 0.12rem; +} + +.repeat-card__name { + font-family: var(--lumen-font-sans); + font-size: 0.75rem; + color: var(--lumen-text); +} + +.repeat-card__val { + margin: 0; + font-family: var(--lumen-font-mono); + font-variant-numeric: tabular-nums; + font-size: 0.84rem; + color: var(--lumen-text); + text-align: right; + white-space: nowrap; +} + +.repeat-card__what { + grid-column: 1 / -1; + margin: 0; + font-family: var(--lumen-font-sans); + font-size: 0.64rem; + line-height: 1.45; + color: var(--lumen-text-dim); +} + +/* Amber left rule, the same device .hero__warn uses: it qualifies the figures, it is not a fault. */ +.repeat-card__unidentified { + margin: 0; + padding-left: 0.5rem; + border-left: 2px solid color-mix(in srgb, var(--lumen-warn) 60%, transparent); + font-family: var(--lumen-font-sans); + font-size: 0.68rem; + line-height: 1.4; + color: var(--lumen-text-dim); +} + +.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..e723a66 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) }} deltas + {{ 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..0501e83 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,291 @@ 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('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(() => From 3bfe2a17c05b0fa637055e1063cf87842ddc1dbf Mon Sep 17 00:00:00 2001 From: HackPoint <6758579+HackPoint@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:10:49 +0300 Subject: [PATCH 08/12] docs: the Repeat reads switch and the Optimizer card, and how to read the card's baseline README: the switch as a third way to turn repeat reads off, what it can and cannot show (the settings file and the default, never the environment), and the Repeat reads card: what each figure counts, that its tokens are counted against the whole file and so are not a saving over having repeat reads off, and that its answers are in none of the totals while the provenance counts still include their rows. CHANGELOG: one entry under Unreleased. --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ README.md | 46 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1510494..7f1be93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,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 8494ad9..bdbeed0 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) @@ -734,8 +740,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. @@ -746,6 +755,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 @@ -827,6 +844,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 From 10ecd7b3d40bc86e7489f8493f95cb5685911162 Mon Sep 17 00:00:00 2001 From: HackPoint <6758579+HackPoint@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:23:07 +0300 Subject: [PATCH 09/12] fix(app): the Repeat reads card reuses the Optimizer page's card rules, so its styles stay under the build budget With the card's own rules optimizer.css was 8.80 kB, over the 8 kB component-style error budget, and `pnpm build` failed. The card now shares the breakdown card, title, rows, value and amber-note rules and inherits its text style from the card: the file is 7.83 kB. The existing rules render as before; in the card, rows are 0.5rem apart and names are 0.74rem. --- .../src/app/pages/optimizer/optimizer.css | 103 +++++------------- 1 file changed, 27 insertions(+), 76 deletions(-) diff --git a/lumenator/src/app/pages/optimizer/optimizer.css b/lumenator/src/app/pages/optimizer/optimizer.css index ddbf1ec..ebb8020 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,43 +445,23 @@ /* 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; - text-align: left; color: var(--lumen-text-dim); } -/* ── repeat reads: counted apart from the totals above ── */ -.repeat-card { - width: 100%; - background: var(--lumen-bg-secondary); - border: 1px solid var(--lumen-border); - border-radius: var(--lumen-radius-sm); - padding: 0.75rem 0.85rem; - display: flex; - flex-direction: column; - gap: 0.55rem; -} - -.repeat-card__title { - margin: 0; - font-family: var(--lumen-font-sans); - font-size: 0.66rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 1px; - color: var(--lumen-text-dim); +.hero__warn { + max-width: 46ch; + margin: 0.5rem auto 0; + text-align: left; } -.repeat-card__state, -.repeat-card__none, -.repeat-card__lead { - margin: 0; +/* ── repeat reads: counted apart from the totals above ── */ +.repeat-card { font-family: var(--lumen-font-sans); font-size: 0.74rem; line-height: 1.45; @@ -486,28 +470,23 @@ overflow-wrap: anywhere; } -.repeat-card__none { - color: var(--lumen-text-dim); +.repeat-card :is(p, dl, dd) { + margin: 0; } -.repeat-card__state--problem { - color: var(--lumen-warn); +.repeat-card__none, +.repeat-card__note, +.repeat-card__what { + color: var(--lumen-text-dim); } -.repeat-card__note { - margin: 0; - font-family: var(--lumen-font-sans); +.repeat-card__note, +.repeat-card__what { font-size: 0.64rem; - line-height: 1.45; - color: var(--lumen-text-dim); - overflow-wrap: anywhere; } -.repeat-card__rows { - margin: 0; - display: flex; - flex-direction: column; - gap: 0.6rem; +.repeat-card__state--problem { + color: var(--lumen-warn); } /* Name and figure on one line, what the figure is counted against under them. */ @@ -515,44 +494,16 @@ display: grid; grid-template-columns: 1fr auto; align-items: baseline; - column-gap: 0.75rem; - row-gap: 0.12rem; -} - -.repeat-card__name { - font-family: var(--lumen-font-sans); - font-size: 0.75rem; - color: var(--lumen-text); + gap: 0.12rem 0.75rem; } .repeat-card__val { - margin: 0; - font-family: var(--lumen-font-mono); - font-variant-numeric: tabular-nums; - font-size: 0.84rem; - color: var(--lumen-text); text-align: right; white-space: nowrap; } .repeat-card__what { grid-column: 1 / -1; - margin: 0; - font-family: var(--lumen-font-sans); - font-size: 0.64rem; - line-height: 1.45; - color: var(--lumen-text-dim); -} - -/* Amber left rule, the same device .hero__warn uses: it qualifies the figures, it is not a fault. */ -.repeat-card__unidentified { - margin: 0; - padding-left: 0.5rem; - border-left: 2px solid color-mix(in srgb, var(--lumen-warn) 60%, transparent); - font-family: var(--lumen-font-sans); - font-size: 0.68rem; - line-height: 1.4; - color: var(--lumen-text-dim); } .repeat-card__link { From c8acb78100fb56011f8fdbe6320630e581b0338a Mon Sep 17 00:00:00 2001 From: HackPoint <6758579+HackPoint@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:26:08 +0300 Subject: [PATCH 10/12] fix(app): the Repeat reads card drops the classes that carry no style frontend_styling requires every class a template uses to be styled in its component. The card's paragraphs, names and lead now take their text style from the card instead of from classes of their own, so the template no longer names them; the one modifier left is repeat-card__problem. No spec, e2e or other file used those classes. --- lumenator/src/app/pages/optimizer/optimizer.css | 2 +- lumenator/src/app/pages/optimizer/optimizer.html | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lumenator/src/app/pages/optimizer/optimizer.css b/lumenator/src/app/pages/optimizer/optimizer.css index ebb8020..2e7e279 100644 --- a/lumenator/src/app/pages/optimizer/optimizer.css +++ b/lumenator/src/app/pages/optimizer/optimizer.css @@ -485,7 +485,7 @@ font-size: 0.64rem; } -.repeat-card__state--problem { +.repeat-card__problem { color: var(--lumen-warn); } diff --git a/lumenator/src/app/pages/optimizer/optimizer.html b/lumenator/src/app/pages/optimizer/optimizer.html index e723a66..d2dfa7a 100644 --- a/lumenator/src/app/pages/optimizer/optimizer.html +++ b/lumenator/src/app/pages/optimizer/optimizer.html @@ -239,12 +239,12 @@

@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.

} @@ -255,7 +255,7 @@

} } @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.

@@ -269,12 +269,12 @@

those answers are counted here.

} @else { -

+

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

-
+
Unchanged answers served
@@ -285,7 +285,7 @@

-
+
Edits served as a delta
@@ -296,7 +296,7 @@

-
+
Tokens not re-sent
@@ -316,7 +316,7 @@

-
+
Full result asked for again (fresh=true)
From b58253b24214491bfbcbaadc5a77a483b684a31c Mon Sep 17 00:00:00 2001 From: HackPoint <6758579+HackPoint@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:41:29 +0300 Subject: [PATCH 11/12] fix(app): the Repeat reads switch keeps keyboard focus while it saves The switch is no longer disabled during a write, which dropped the focus it held; it is marked aria-disabled and a second click is ignored until the first write settles. The Optimizer card says "1 delta", not "1 deltas". New tests, each shown to fail without its fix: a reader holding the settings file open still sees the whole old one after a write (the rename is atomic); every command the window invokes is registered in the Tauri handler list; a failed write names the file and the operating-system cause. --- crates/lumen-core/src/settings.rs | 16 +++ lumenator/src-tauri/src/lib.rs | 108 +++++++++++++++++- lumenator/src/app/pages/home/home.html | 2 +- lumenator/src/app/pages/home/home.spec.ts | 26 ++++- lumenator/src/app/pages/home/home.ts | 5 + .../src/app/pages/optimizer/optimizer.html | 2 +- .../src/app/pages/optimizer/optimizer.spec.ts | 11 ++ 7 files changed, 160 insertions(+), 10 deletions(-) diff --git a/crates/lumen-core/src/settings.rs b/crates/lumen-core/src/settings.rs index ca2406b..b0dcb5e 100644 --- a/crates/lumen-core/src/settings.rs +++ b/crates/lumen-core/src/settings.rs @@ -511,6 +511,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/lumenator/src-tauri/src/lib.rs b/lumenator/src-tauri/src/lib.rs index 3a1805b..7f0d5b3 100644 --- a/lumenator/src-tauri/src/lib.rs +++ b/lumenator/src-tauri/src/lib.rs @@ -1551,18 +1551,23 @@ mod tests { } #[test] - fn a_write_that_fails_is_an_error_naming_the_file() { + 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(); - assert!(err.contains("cannot write"), "got {err}"); let file = lumen_core::settings::settings_path(&db) .display() .to_string(); - assert!(err.contains(&file), "names {file}: got {err}"); + 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] @@ -1649,4 +1654,101 @@ mod tests { "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"]); + } } diff --git a/lumenator/src/app/pages/home/home.html b/lumenator/src/app/pages/home/home.html index d4ec18f..0505524 100644 --- a/lumenator/src/app/pages/home/home.html +++ b/lumenator/src/app/pages/home/home.html @@ -186,7 +186,7 @@

Repeat reads

type="checkbox" aria-label="Repeat reads" [checked]="st.on" - [disabled]="rr.saving()" + [attr.aria-disabled]="rr.saving() ? 'true' : null" (change)="onRepeatReads($event)"> diff --git a/lumenator/src/app/pages/home/home.spec.ts b/lumenator/src/app/pages/home/home.spec.ts index cd9c774..23414af 100644 --- a/lumenator/src/app/pages/home/home.spec.ts +++ b/lumenator/src/app/pages/home/home.spec.ts @@ -639,17 +639,33 @@ describe('Home', () => { expect(el(BOX).checked).toBe(false); }); - it('holds the switch still while the write is in flight', async () => { + 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 = () => write.promise; + b.setRepeatReadsSetting = () => { + writes += 1; + return write.promise; + }; }); - el(BOX).click(); + const box = el(BOX); + box.focus(); + box.click(); await settle(); - expect(el(BOX).disabled).toBe(true); + 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(el(BOX).disabled).toBe(false); + 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 () => { diff --git a/lumenator/src/app/pages/home/home.ts b/lumenator/src/app/pages/home/home.ts index 77120ad..f25869a 100644 --- a/lumenator/src/app/pages/home/home.ts +++ b/lumenator/src/app/pages/home/home.ts @@ -129,6 +129,11 @@ export class Home implements OnInit { 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. diff --git a/lumenator/src/app/pages/optimizer/optimizer.html b/lumenator/src/app/pages/optimizer/optimizer.html index d2dfa7a..cc74b07 100644 --- a/lumenator/src/app/pages/optimizer/optimizer.html +++ b/lumenator/src/app/pages/optimizer/optimizer.html @@ -324,7 +324,7 @@

{{ fmtTokens(c.asked) }} = {{ fmtTokens(c.unchangedServed) }} unchanged - + {{ fmtTokens(c.deltasServed) }} deltas + {{ fmtTokens(c.freshRequests) }} asked again. + + {{ fmtTokens(c.deltasServed) }} delta{{ c.deltasServed === 1 ? '' : 's' }} + {{ fmtTokens(c.freshRequests) }} asked again.
diff --git a/lumenator/src/app/pages/optimizer/optimizer.spec.ts b/lumenator/src/app/pages/optimizer/optimizer.spec.ts index 0501e83..6fab2b5 100644 --- a/lumenator/src/app/pages/optimizer/optimizer.spec.ts +++ b/lumenator/src/app/pages/optimizer/optimizer.spec.ts @@ -699,6 +699,17 @@ describe('Optimizer', () => { ); }); + 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); From 31772f8dfdc5df76024f618d5956ea29816517b2 Mon Sep 17 00:00:00 2001 From: HackPoint <6758579+HackPoint@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:43:42 +0300 Subject: [PATCH 12/12] test(app): pin the Repeat reads argument key and all four served routes The window sends set_repeat_reads_setting its argument as { on } and the handler takes `on`, and nothing compared the two: renaming either end compiled and passed every test. A test now reads the argument keys from tauri-bridge.ts and the parameters from the handlers in lib.rs and requires them to match, for both commands, and a second test pins the scan itself. The test that served answers stay out of every total seeded two of the four served routes, so adding smart_read_delta or recall_file_unchanged to any of the six route lists in lumen-stats passed it. It now seeds all four. Each is proved to fail without it: the old test passed under all 12 additions of the two unseeded routes and the new one fails under all 24 single additions; the new pair fails with the key sent as { enabled: on }, with a key sent to the get command, with the handler parameter renamed, and with the scan keeping the spaces around a key. --- crates/lumen-stats/tests/aggregates.rs | 7 ++- lumenator/src-tauri/src/lib.rs | 61 ++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/crates/lumen-stats/tests/aggregates.rs b/crates/lumen-stats/tests/aggregates.rs index 6b8c086..bbc5d91 100644 --- a/crates/lumen-stats/tests/aggregates.rs +++ b/crates/lumen-stats/tests/aggregates.rs @@ -1287,6 +1287,8 @@ async fn served_answers_are_in_none_of_the_totals_above_them() { 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; @@ -1304,7 +1306,10 @@ async fn served_answers_are_in_none_of_the_totals_above_them() { 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(1, 1, 970 + 710, 2, 0, 0)); + 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. diff --git a/lumenator/src-tauri/src/lib.rs b/lumenator/src-tauri/src/lib.rs index 7f0d5b3..4b1fe23 100644 --- a/lumenator/src-tauri/src/lib.rs +++ b/lumenator/src-tauri/src/lib.rs @@ -1751,4 +1751,65 @@ mod tests { 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"]); + } }