Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c21dbb3
feat(stats): the optimizer report counts what repeat reads served, ap…
HackPoint Oct 12, 2026
781d96f
feat(app): Tauri commands to read and write the repeat reads setting …
HackPoint Oct 12, 2026
f3a887e
feat(app): a Repeat reads switch in the Home window, on by default, s…
HackPoint Oct 12, 2026
3d4cc5c
fix(doctor): the remedy for an unreadable settings file names the Rep…
HackPoint Oct 12, 2026
87c8bef
fix(settings): the unreadable-file message points at the Repeat reads…
HackPoint Oct 12, 2026
20ec30b
fix(settings,doctor): name the switch where a user finds it, the Cont…
HackPoint Oct 12, 2026
341a949
feat(app): the Optimizer page counts what repeat reads served, apart …
HackPoint Oct 12, 2026
3bfe2a1
docs: the Repeat reads switch and the Optimizer card, and how to read…
HackPoint Oct 12, 2026
44cd6dc
Merge remote-tracking branch 'origin/main' into agents/t4-repeat-read…
HackPoint Oct 12, 2026
10ecd7b
fix(app): the Repeat reads card reuses the Optimizer page's card rule…
HackPoint Oct 12, 2026
c8acb78
fix(app): the Repeat reads card drops the classes that carry no style
HackPoint Oct 12, 2026
b58253b
fix(app): the Repeat reads switch keeps keyboard focus while it saves
HackPoint Oct 12, 2026
31772f8
test(app): pin the Repeat reads argument key and all four served routes
HackPoint Oct 12, 2026
464efdb
Merge branch 'main' into agents/t4-repeat-reads-toggle-card
HackPoint Oct 13, 2026
2ec557a
Merge branch 'main' into agents/t4-repeat-reads-toggle-card
HackPoint Oct 13, 2026
e2839b2
Merge branch 'main' into agents/t4-repeat-reads-toggle-card
HackPoint Oct 13, 2026
f29baef
Merge branch 'main' into agents/t4-repeat-reads-toggle-card
HackPoint Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,42 @@ unless turned off. Fresh installs and upgrades from 1.7.0 both get them.
have measured the gate. The committed pre-release results were taken with off as the default,
so they are the same measurement.

### The app can turn repeat reads off, and shows what they have done

Turning repeat reads off meant an environment variable or a hand-edited JSON file, and nothing
showed what they had done. Nothing the MCP server sends changes: no tool's output, `tools/list` or
hook.

- **A Repeat reads switch** on the Context tab of the main window, on by default. It writes the
`lumen_settings.json` described above (atomically, readable by its owner only, the other keys of
a valid file kept), so a change applies to the next Lumen read in every running Claude Code
session with no restart, and it survives a restart, unlike the alert thresholds beside it.
- **It shows the settings file, not the server.** The window shows what the file says, or the
default when there is no file. It does not read `LUMEN_REPEAT_READS`: a value in a Claude Code
MCP config outranks the file and the app cannot see it, nor a `LUMEN_DB` there that makes the
server read another ledger's settings file. Its text says both.
- **A file that cannot be used is shown as off, with the reason**: the same off the server
applies, which it records as a `repeat_reads_config` fault. Switching on rewrites the file. A
setting that cannot be read shows neither state and offers Retry; a change that cannot be saved
puts the switch back and says why. Nothing is shown as saved that was not.
- **`lumen doctor` and the server's message about an unusable settings file** name the switch as
the way to repair it, where they said only to edit or delete the file.
- **A Repeat reads card on the Optimizer tab**: the unchanged answers and the deltas served, the
tokens they did not re-send, and how often the full result was asked for again with `fresh=true`,
as n of m (m is the unchanged answers, the deltas and the `fresh` requests together; only
requests made while repeat reads were on are counted, so it stops moving when they are off).
It is counted apart from every total on the tab: in none of the token totals, the by-tool and
by-channel breakdowns or the net value (the provenance counts do include these rows). It shows
no dollar figure and no percentage. The tokens are counted against reading the whole file again,
which is more than the outline a read returns with repeat reads off, so the card does not call
them a saving and says what they are counted against. The figure covers only answers whose token
count was measured, says how many when that is fewer than all of them, and is a dash when none
was. The card says when the settings file turns repeat reads off, and counts the reads answered in
full because Lumen could not tell which conversation they came from, with a link to Setup (the
app install's remedy; on the plugin, update or reinstall it, as the upgrade notes above say).
- **Stats.** The optimizer report gains `repeatReads`; there is no schema change. A backend that
does not send it gets no card.

### Repeat reads follow the conversation, not the process (#32)

`/clear` and `/resume` keep `lumen-mcp` running, and it read `CLAUDE_CODE_SESSION_ID` once at
Expand Down
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -740,8 +746,11 @@ each `(session, request)` and answers a repeat with less. The built-in Read is n
Claude Code already dedups it. It was an opt-in flag in 1.7.0 (`LUMEN_REPEAT_READS=on`); from
1.8.0 it is on unless you turn it off.

**Turning it off.** Either of these, and the environment variable wins when both are set:
**Turning it off.** Any of these, and the environment variable wins when it says something:

- the **Repeat reads** switch on the Context tab of the Lumen app's main window. It writes the
settings file below, so a change applies to the next Lumen read in every running Claude Code
session, with no restart, and it is still there after Lumen or Claude Code restarts.
- the environment variable, in the `env` block of `~/.claude/settings.json` (or wherever Claude
Code gets its environment): `"env": { "LUMEN_REPEAT_READS": "off" }`. It is read when Claude
Code starts the server, so it applies from the next Claude Code session.
Expand All @@ -752,6 +761,14 @@ Claude Code already dedups it. It was an opt-in flag in 1.7.0 (`LUMEN_REPEAT_REA
it on every Lumen call (about 30 microseconds), so a change applies to the next call, with no
restart. `lumen doctor` prints the path it is using and the state.

The switch shows what the settings file says, or the default (on) when there is no file, and
nothing else. It cannot see a `LUMEN_REPEAT_READS` in the environment Claude Code gives the server,
which wins over the file, nor a `LUMEN_DB` there that points the server at a ledger elsewhere, whose
own settings file is then the one that counts; its text says so. A file it cannot use is shown as
off, with the reason, and switching on replaces it; the other keys of a file that is valid are kept.
When the setting cannot be read, or a change cannot be saved, the window says that. It does not
show on or off in the meantime.

With it off every read is answered as it always was, nothing is remembered, and the `fresh`
option is not advertised. Switching from off to on in the middle of a session leaves the
client's cached tool list without `fresh` until the next session; that is harmless, because the
Expand Down Expand Up @@ -833,6 +850,33 @@ The size of a delta is measured per delta served against the outline it replaces
the `fresh=true` sentence included; it is not an overall saving. Overall effect, follow-up
rate and the `fresh=true` rate are measured live, in the `ab` split.

**What the Optimizer tab shows (1.8.0).** A Repeat reads card, below the other figures, counts
what repeat reads have done over the ledger's lifetime. It is counted apart from the rest: these
answers are in none of the token totals (Lumen optimized, Today, This week), the by-tool and
by-channel breakdowns or the net value, and the card never calls anything a saving. (The
provenance counts at the top of the tab, how many metered events have unverified token
provenance, still include these rows: they count every row that carries a token count.)

- *Unchanged answers served* and *Edits served as a delta*: how many repeat reads got the
one-sentence note and how many got a delta.
- *Tokens not re-sent*: for those answers, the whole file's tokens minus the answer's, added up.
Read its baseline carefully: it is counted against reading the whole file again. With repeat
reads off a read returns the outline (for `recall_file`, the symbols asked for), which is
usually far smaller than the file, so this is more than repeat reads gain over having them off,
and it is not shown as a saving. It counts only answers whose token count was measured: when
that is fewer than all of them the card says how many, and when it is none the figure is a dash.
- *Full result asked for again (`fresh=true`)*: n of m, where m is the unchanged answers, the
deltas and the `fresh` requests together, so it is how often the model did not take a repeat
answer. Only requests made while repeat reads were on are counted (under `ab`, the half that
takes the new path), so the figure stops moving when they are turned off.
- When the settings file turns repeat reads off, the card says so, and that a
`LUMEN_REPEAT_READS` in a Claude Code MCP config would outrank the file unseen. It never says
what the server will do, for the same reason the switch cannot.
- Reads answered in full because Lumen could not tell which conversation they came from (the plan
`unidentified`, counted while repeat reads were on): the count, with an **Open Setup** link
to the app's Setup screen, which runs Setup, after which Claude Code needs a restart. That
remedy is for the app install; on the plugin, update or reinstall it, as in the table above.

### Experimental: ranked outline (1.3.0, off by default)

`smart_read`'s outline can be sized by the economics of the call rather than by a fixed
Expand Down
28 changes: 27 additions & 1 deletion crates/lumen-cli/src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ impl Finding {
),
lumen_core::settings::Problem::File(_) => format!(
"fix {file} so it reads {{\"repeat_reads\": true}} (or false), or delete it \
to get the default, on. It applies to the next Lumen call, with no restart."
to get the default, on. In the Lumen app, the Repeat reads switch on the \
Context tab of the main window rewrites it. It applies to the next Lumen \
call, with no restart."
),
},
}
Expand Down Expand Up @@ -1245,6 +1247,30 @@ mod tests {
assert!(render(&f).contains("\"enabled\""), "{}", render(&f));
}

#[test]
fn the_remedy_for_an_unreadable_file_names_the_switch_in_the_app() {
use lumen_core::settings::FileState;
let f = Facts {
repeat_reads: Some(resolved(None, FileState::Text("{broken".into()))),
repeat_reads_file: Some("/data/lumen_settings.json".into()),
..healthy()
};
let fix = findings(&f)[0].remedy();
assert!(
fix.contains("Repeat reads switch on the Context tab of the main window"),
"{fix}"
);
assert!(fix.contains("rewrites"), "{fix}");

// A bad variable is not something the switch can fix: the variable wins over the file.
let f = Facts {
repeat_reads: Some(resolved(Some("enabled"), FileState::Absent)),
..healthy()
};
let fix = findings(&f)[0].remedy();
assert!(!fix.contains("Context tab"), "{fix}");
}

#[test]
fn a_healthy_default_install_says_on_by_default_and_reports_nothing() {
let f = Facts {
Expand Down
39 changes: 30 additions & 9 deletions crates/lumen-core/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,10 @@ impl Problem {
),
Problem::File(why) => format!(
"{} cannot be used ({why}): repeat reads are off, every read is in full. \
Fix or delete the file (deleting it gives the default, on), or set \
{REPEAT_READS_ENV} to on or off, which takes precedence over the file.",
Fix or delete the file (deleting it gives the default, on), or choose a \
setting with the Repeat reads switch on the Context tab of the main window \
in the Lumen app, which rewrites it. Setting {REPEAT_READS_ENV} to on or off \
takes precedence over the file.",
file.display()
),
}
Expand Down Expand Up @@ -264,10 +266,8 @@ pub fn surface(problem: &Problem, file: &Path, min_interval: std::time::Duration

/// Write the persisted setting for a ledger at `db`.
///
/// Nothing in the app calls this yet: there is no toggle, so today the file is edited by hand
/// or `LUMEN_REPEAT_READS` is set (the README and `lumen doctor` say so). It is the writer a
/// toggle would use, kept beside the reader so the two agree on the format, and the tests
/// round-trip it.
/// The Repeat reads switch in the Lumen app (`set_repeat_reads_setting`) calls this.
/// It sits beside the reader so the two agree on the format, and the tests round-trip it.
///
/// Atomic (written beside, renamed into place) so a reader never sees half a file, and
/// private to this user. Other keys in a valid existing file are kept. A file that cannot be
Expand Down Expand Up @@ -461,12 +461,17 @@ mod tests {
assert!(said.contains("/data/lumen_settings.json"), "{said}");
assert!(said.contains("delete the file"), "{said}");
assert!(said.contains(REPEAT_READS_ENV), "{said}");
// There is no switch in the app to go back to: do not send anyone looking for one.
assert!(!said.contains("in the app"), "{said}");
// The main window's switch rewrites the file, so it repairs a file that cannot be used.
assert!(
said.contains("Repeat reads switch on the Context tab of the main window"),
"{said}"
);
// It writes the file, and the variable outranks the file: a bad value there is not
// something it can fix, so that message must not send anyone to it.
assert!(
!Problem::Env("x".into())
.describe(file)
.contains("in the app"),
.contains("Context tab"),
"the same for the environment variable's message"
);
}
Expand Down Expand Up @@ -541,6 +546,22 @@ mod tests {
assert_eq!(mode & 0o077, 0, "{mode:o}");
}

#[cfg(unix)]
#[test]
fn a_reader_holding_the_file_open_still_sees_the_whole_old_one_after_a_write() {
use std::io::Read;
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("ledger.db");
write_repeat_reads(&db, false).unwrap();
let before = std::fs::read_to_string(settings_path(&db)).unwrap();
let mut held = std::fs::File::open(settings_path(&db)).unwrap();
write_repeat_reads(&db, true).unwrap();
let mut seen = String::new();
held.read_to_string(&mut seen).unwrap();
assert_eq!(seen, before);
assert_eq!(read_file_mode(&db), Mode::On);
}

#[test]
fn the_writer_creates_a_missing_directory() {
let dir = tempfile::tempdir().unwrap();
Expand Down
84 changes: 84 additions & 0 deletions crates/lumen-stats/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -345,6 +349,38 @@ pub struct ToolBreakdown {
pub full_tokens: i64,
}

/// What repeat reads did, counted apart from everything else in [`OptimizerReport`].
///
/// A served answer is a row whose route is one of the four repeat routes. None of them is in
/// [`LUMEN_ROUTES`], so the token totals, the breakdowns and the net value above do not include
/// these rows; the provenance counts, which take every row that carries a count, do.
///
/// Served rows are found by route and by nothing else. In the A/B experiment a control row
/// records the plan the server WOULD have run (`unchanged`, `delta`) under an ordinary route:
/// nothing was served, so counting by plan would count answers nobody was given.
#[derive(Serialize, Debug, Default, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RepeatReads {
/// Reads answered "unchanged since an earlier read" instead of with the result again.
pub unchanged_served: i64,
/// Reads of an edited file answered with only what changed.
pub deltas_served: i64,
/// `saved_tokens` of the served answers whose count was measured: what each did not send,
/// against reading the whole file again. Not against the outline the same call would have
/// returned with the feature off, which is a smaller baseline.
pub tokens_not_resent: i64,
/// The served answers behind `tokens_not_resent`. Fewer than `unchanged_served +
/// deltas_served` means the others carry no measured count and add nothing to it.
pub measured_answers: i64,
/// Calls that passed `fresh=true` while repeat reads were answering (the treatment arm): the
/// whole result asked for again. A control call, or one made while the feature was off, was
/// not a request to bypass an answer.
pub fresh_requests: i64,
/// Calls answered in full because Lumen could not tell which conversation they came from,
/// in either arm: it says something about the install, not about the feature.
pub unidentified_reads: i64,
}

#[derive(Serialize, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct OptimizerReport {
Expand Down Expand Up @@ -400,6 +436,8 @@ pub struct OptimizerReport {
/// Rounds each intercept actually costs. Measured at 1.604 — 60.4% of `smart_read`
/// calls are followed by a `recall_file` on the same file.
pub pair_multiplier: f64,
/// Repeat reads, lifetime. Counted apart from every figure above.
pub repeat_reads: RepeatReads,
}

pub async fn get_optimizer_stats(pool: &SqlitePool) -> Result<OptimizerReport, String> {
Expand Down Expand Up @@ -597,6 +635,8 @@ pub async fn get_optimizer_stats(pool: &SqlitePool) -> Result<OptimizerReport, S
};
let gross = if round_cost == 0.0 { 0.0 } else { gross };

let repeat_reads = repeat_reads(pool).await?;

Ok(OptimizerReport {
lifetime_optimized_tokens,
lifetime_full_tokens,
Expand All @@ -615,6 +655,50 @@ pub async fn get_optimizer_stats(pool: &SqlitePool) -> Result<OptimizerReport, S
round_cost_usd: round_cost,
value_rounds: VALUE_ROUNDS,
pair_multiplier: PAIR_MULTIPLIER,
repeat_reads,
})
}

async fn repeat_reads(pool: &SqlitePool) -> Result<RepeatReads, String> {
// 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,
})
}

Expand Down
Loading
Loading