Skip to content

0.24.0

Choose a tag to compare

@github-actions github-actions released this 03 Sep 06:31
· 10 commits to main since this release

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":

  1. The plugin told a plausible lie (sent 1) when it couldn't find the line, instead of saying "don't know" (send 0).
  2. 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.
  3. Timestamps were minted in three places: session.to_dict in UTC, trajectory.append_turn in UTC, bubbles nowhere.
  4. 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 real src/locate.ts: bold, backticks, table, heading, fenced code, <details> summary, head probe, tail probe; not-found must be null.
  • 8 new pytest cases (test_trajectory.py 3, test_app.py 3, test_tutor.py 1, test_diagnose.py 1, test_compact.py 1). Each was watched fail first: _place without a fallback → sticky test red; is_turn not filtering → double-count test red; load_turns keeping {} → the old test_corrupt_jsonl_skipped red (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' ts are still UTC. fromisoformat accepts them and ordering is unaffected; just read the offset.
  • propose_fold_md calls llm_create_kwargs (which forces stream=True) and then reads resp.choices[0] — guaranteed to blow up on a real endpoint. But POST /v1/writeback/propose has zero callers in src/; it has been a dead route since the initial commit. Separate release.