KRouter

Auto-capture: what is written down, when, and what it costs

How KRouter turns sessions into “what was decided” lines, how it lives next to another collector, and what moving the work from the nightly run to the end of each session costs. Code: import_sessions.py, session_hook.py, answer_cards.py. Measured on the author’s machine on 2026-10-30 unless said otherwise.

1. Answer cards the checkup lists as unfaithful or too broad

The 2026-10-28 checkup listed 8 cards whose answers held a number or path not found in the page, and 18 questions too broad to tell which page they were about. All 8 were written on 2026-10-10, the day the whole-token check (4d02772) went in, and all 8 had passed the model check (v: 1). Two causes:

Dry run on the author’s vault after the change: 16 pages re-asked once (about 35 000 characters), the 2 remaining unfaithful cards and the broad ones among them. Tests: tests/test_recall_index.py.

2. Living next to another collector (collect_day.py)

The host’s daily job (collect_day.py + distill_llm.py) writes one type: daily-log page per day under 05 时间日志/YYYY-MM/: a stub that lists every session as `claude:<id>`, then a distilled day summary that cites sessions by an 8-character prefix. It reads session metadata (first question, turn count), not the turns: its distilled logs say “索引未提供” where the content would be. KRouter’s pages are the turn-by-turn record of one session-day. They are not the same content, so the plan is to link them and remove true duplicates, not to pick one.

Not done here, on purpose: turning on --consent on the author’s machine. Suggested line, once the model lane works: import_sessions.py --consent --distill --cli /opt/homebrew/bin/claude --overlap link --days 1. Tests: tests/test_import_sessions.py.

3. Distilling at session end instead of at night

session_hook.py end already imports the session that ended, in a detached process, and distills it when consent included --distill. So “real time” needed no new hook; it needed three fixes before it is reasonable to leave on:

  Before Now
A session that ends several times (window closed, /clear, resumed and ended again) the whole excerpt re-sent each time its turn count changed only the turns since the last digest, plus 2 turns of context and the earlier lines for the change-of-mind pass
Two imports at once (two windows, hook and nightly) both send the same session one at a time (flock); the second finds the first one’s digest
Model lane whatever the session’s shell held: llm.plan tries *_API_KEY first, so a DeepSeek key in the shell sent the excerpt to DeepSeek while the nightly job used claude the CLI named at --consent --cli goes first

Cost. Last 7 days (2026-10-24 to 30): 27 sessions, 41 session-days, 316 turns, median 4 turns and 3 400 characters per page. Distilling the 32 session-days with at least 2 turns sends 444 000 characters (about 14 000 per session-day, 63 000 a day; for comparison the 2026-10-28 nightly sent 138 000 for topic dossiers and 54 000 for answer cards). With the incremental digest, moving this to session end costs about the same as doing it at night: the extra per additional end is the prompt templates, 2 context turns (at most about 1 500 characters each) and the earlier lines, so a few thousand characters at most, instead of the whole excerpt again. Each end runs one python process and, for a session that grew, 2 or more model calls in the background; nothing waits on it.

Privacy. What is sent does not change: redacted questions (up to 600 characters) and closing answers (up to 900), no tool output, no thinking. What changes:

Keeping a session out, built since: #不记 (or #nocapture) in any question of a session keeps it out of import and distill, and removes a page already written for it; --exclude-root <dir> (repeatable, saved with --consent) does the same for every session under that project root.

3a. When a session “ends” is not a schedule

A desktop window often stays open for days and is archived long after the work in it is done, so SessionEnd fires late or not at all; it is a top-up, not the trigger. What runs the work on a schedule instead:

3b. Observable, not approved

Capture writes memory without a yes: a yes/no gate on every digest line was one more chore that did not get done, so nothing ever took effect. In its place every run that changes something is visible and reversible:

4. Decision-capture bench (bench/decision_capture/)

Set 6 (bench/real_questions/capture_bench.py) measures capture on the author’s real sessions with model judges; it is private and its numbers depend on the judge. This bench is the small public counterpart: 14 written sessions (10 Chinese, 4 English) with 14 labeled decisions, 3 of them changes of mind, and 4 sessions where nothing was decided (discussion, a one-off, steps only, “I’ll think about it”). Scored by rule: each decision lists the words a line must hold; forbid lists steps and discussion. Reported: recall, change recall (a line with “→”), false-extraction rate, steps-or-discussion lines, lines on the no-decision sessions.

python3 bench/decision_capture/run.py --oracle          # scorer check: recall 1.0, false 0.0
python3 bench/decision_capture/run.py --cli /opt/homebrew/bin/claude --save lines.jsonl --show
python3 bench/decision_capture/run.py --replay lines.jsonl

No baseline yet: on 2026-10-30 the claude CLI answered “OAuth session expired and could not be refreshed”. Lexical scoring undercounts a correct line worded with none of the listed alternatives; --show prints misses and false lines to read. Tests: tests/test_decision_capture_bench.py.