0.24.0
Logs you can actually read — local time, full text, real anchors, follow-ups on record
This started with a reader asking for a strengths-and-weaknesses profile of themselves as a learner: a radar, which concepts they keep asking about, where they got stuck. Pulling the existing trajectory to build it turned up four data defects I was about to file as "later". The reader's answer: fix every one of them first, then do the research. This release is that fix.
What the reader saw
One session — 64 turns, 2 h 49 min, 10.7 M input tokens — landed on disk like this:
| Wanted | Actually there |
|---|---|
| When each line was said | ui_messages carried no timestamps at all; trajectory rows had ts, in UTC — reader subtracts eight hours by hand |
| The reader's own words | truncated at 240 chars; a 300-char turn about "precise English terms, I need them for interviews" lost its second half |
| What Socrates answered | a 200-char preview; the full text lived only in the session file, which retention deletes after seven days |
| Where in the handbook this turn was | 36 of 66 rows said level="封面", start_line=1 — while selected_chars was 285–873. The selection existed; the position was fabricated |
| The two follow-ups offered, and whether the reader clicked one | not recorded. A clicked follow-up and a typed message looked identical to the backend |
| The second half of an approved write-back | not recorded at all. The finally in /v1/chat/approve was _ = ok |
| Fast model or main model | not recorded |
diagnose.aggregate filters 封面 out via is_curriculum, so the existing weak-spot aggregator could not see 55 % of the conversation — the whole cassette arc, the opening and closing HTTP questions.
How the fake anchors happened
Those 36 rows were not empty selections. In Reading view, src/selection.ts mapped the highlighted rendered text back to source lines with linesFromQuote, and when it failed it fell back to {startLine: 1, endLine: 1}. 1 is a real line number. The sidecar dutifully ran idx.locate(1) → cover page.
Ported the matcher to Python and measured it against the 13 distinct selections in the trajectory on the real 32,070-line handbook:
| Matching rule | Matched |
|---|---|
| Collapse whitespace, substring search (v0.23.1) | 0 / 13 |
| Keep only letters, digits, CJK; whole quote, then first 48 chars, then last 48 | 11 / 13 |
| The other two | the handbook had been rewritten after the selection (the reader asked for a paragraph to be recast in RL terms). The original text no longer exists |
The zero is not subtle: readers highlight rendered text, but the source is full of **, backticks, table pipes, #, [link](url). Whitespace folding doesn't touch any of that.
The two unrecoverable rows prove something else: this cannot be repaired by backfilling. The handbook is written back to every day; what doesn't match today matches less tomorrow. The only fix is to record it correctly at the moment it happens.
Root causes
Four of them, each a "saved effort while recording":
- The plugin told a plausible lie (sent 1) when it couldn't find the line, instead of saying "don't know" (send 0).
- Trajectory rows were designed to be "enough for
diagnose", not "enough to analyse a reader" — 240 / 200 char truncation, no follow-ups, no route. Yet it is the only record that is never deleted or rewritten. - Timestamps were minted in three places:
session.to_dictin UTC,trajectory.append_turnin UTC, bubbles nowhere. - When one turn spans two requests, nobody logged the second one.
What changed
Time has one definition point. New pen/clock.py:now_iso(): local time, with offset, to the second (2026-09-03T10:47:06+08:00). Still an absolute instant — fromisoformat reads it back in one line, and it sorts correctly next to old +00:00 rows — but what you read is the clock on the reader's wall. Bubbles, trajectory rows, compaction notes and updated_at all take it from there.
Quote → line stays in the plugin (src/locate.ts), with the new rule: strip both sides to letters, digits and CJK; try the whole quote, then the first 48 chars, then the last 48. A probe hit reports only the lines the probe itself covers — narrow and right, never extrapolated to the full quote length. Not found returns null, and selection.ts sends 0.
What 0 means lives only in the sidecar (tutor._place): if the previous anchor in the same book is still in range, reuse it and mark located="sticky"; otherwise record line 1 and mark "none"; a normal hit is "exact". The three values ride along in the anchor so analysis can weight them by trust. The [邻域] context fed to the model follows the same rule — it used to get the cover page, which was worse than nothing.
Trajectory rows are self-sufficient (the module docstring in pen/trajectory.py says why):
| Field | Content |
|---|---|
phase |
chat / approve |
asked_at · ts · duration_s |
when asked, when answered, how long the reader waited |
user_text · assistant_text |
the full sentence, the full reply — no truncation |
offered |
the two follow-ups this reply ended with |
picked |
whether this message was a clicked follow-up (dyn / deep) or typed (empty). Decided in the sidecar, no protocol change: last turn's last_chips and the deep probe's mark_clicked were already in hand |
route |
fast / base |
anchor.located |
exact / sticky / none |
The finally in /v1/chat/approve now appends a phase="approve" row with allow, the full second-half text, and a clock.
What counts as a turn is decided only by trajectory.is_turn. diagnose.aggregate and app._footprint both ask it — otherwise every write-back doubles the hits at one spot and a single turn becomes a "weak spot". load_turns no longer silently drops rows without an anchor: a reader's data should not be filtered at read time; the consumer filters.
Gates
- New
scripts/check-locate.mjs(13 checks) bundles the realsrc/locate.ts: bold, backticks, table, heading, fenced code,<details>summary, head probe, tail probe; not-found must benull. - 8 new pytest cases (
test_trajectory.py3,test_app.py3,test_tutor.py1,test_diagnose.py1,test_compact.py1). Each was watched fail first:_placewithout a fallback → sticky test red;is_turnnot filtering → double-count test red;load_turnskeeping{}→ the oldtest_corrupt_jsonl_skippedred (so empty objects are still skipped; only anchor-less rows are kept). - Totals: pytest 1014 passed; twelve frontend gates, 488 checks, all green.
Not done
- No backfill of old rows. Explained above; and the sidecar is appending to the same file — rewriting it beside a live process is a race. The learner profile that prompted this recomputes anchors in memory with the new rule and says which rows came out that way.
- Old rows'
tsare still UTC.fromisoformataccepts them and ordering is unaffected; just read the offset. propose_fold_mdcallsllm_create_kwargs(which forcesstream=True) and then readsresp.choices[0]— guaranteed to blow up on a real endpoint. ButPOST /v1/writeback/proposehas zero callers insrc/; it has been a dead route since the initial commit. Separate release.