feat: 让词典自己长——手改一次,它就记住(光标上下文 + 手改学词 + 热词预算优先级) - #917
Conversation
…wired up) Adds `host_document/` — the one place that touches "the document the user is currently writing in". Nothing calls it yet from the product path; the only consumer is a debug command. Wiring it into LLM polish is the next step, and that is where the (default-off) user-facing switch will live. Why: ASR only gets hotwords and polish only gets the QA selection, so the doc the user is writing is invisible to both. Chinese homophones (接口/借口, 大鱼/大禹) are indistinguishable acoustically but trivial in context. - window.rs: 80/20 cursor window, pure and char-based. Unused budget on one side flows to the other, so a cursor at the top of a document still gets a full window. Slicing by bytes would split CJK chars. - mod.rs: safety gate. Secure Event Input, AXSecureTextField role/subrole, and a hardcoded bundle-prefix blocklist (password managers, keychain, terminals). Gate inputs are a plain struct so the decision is unit-tested without AX — getting this wrong means shipping a password to an LLM. - macos.rs: AX read. Sets AXUIElementSetMessagingTimeout(200ms) — the existing AX code in selection.rs and lib.rs sets none and inherits the ~6s default, which means a 6s freeze against a hung app. Runs under spawn_blocking with an outer tokio timeout, never on a tokio worker. Large documents go through AXStringForRange instead of copying the whole AXValue across processes. - AX indices are UTF-16 code units while the window algorithm is char-based; the conversion is explicit and tested against surrogate pairs. Also folds coordinator::capsule_focus's near-verbatim copy of the frontmost-app lookup into selection.rs, which now exposes structured `current_front_app_parts() -> (name, bundle_id)`. The old display-string-only form was unusable for the bundle blocklist. Non-macOS returns Unsupported: Windows has no UIAutomation code and TSF is only live at commit time; Linux fcitx5 SurroundingText is unsupported by most clients. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 02aa66ac3d303df25334f05e50e08339036539e9)
…lt off) Wires host_document into the polish path behind `cursorContextEnabled`, which defaults to false and must stay that way: turning it on means every dictation reads the foreground app's text and ships a slice of it to the user's LLM provider. That is data the user never handed us, so it is theirs to opt into. - `<cursor_context>` envelope through the existing `sanitize_for_xml_envelope`, injected in `compose_polish_prompts` — the one funnel both the OpenAI-compatible and Gemini paths go through. `assemble_polish_system_prompt` takes the same argument so the settings preview cannot drift from what actually gets sent. - The envelope carries a cursor marker, because "context" without knowing where the cursor sits doesn't distinguish finished text from the stub the user is mid-way through — and those two are worth very different amounts. Forged markers in the document are stripped before the real one goes in. - The injection-defense clause for `<cursor_context>` is a SEPARATE string appended only when context is actually present. Folding it into `polish_injection_defense()` would have changed the prompt for every user who has this switched off; off must mean the feature does not exist, down to the byte. There is a test for exactly that. - `sanitize_for_xml_envelope` now also neutralizes `< /tag>` (whitespace before the slash). It already handled `</ tag >` and `< tag >`; the gap let a forged boundary through. Not valid XML, but an LLM may not agree, and this envelope carries text from other people's documents. Read point is `end_session`, next to where front_app is read: focus is still on the target app there (the capsule is a non-activating panel) and polish is about to fire. Switch off means host_document is never called — not one AX message. Any read failure degrades to no context; never to a lost word. The toggle lives under Privacy, not under polish settings: its real cost is not tokens, it is that text from another app leaves the machine. macOS only — a switch that cannot change the outcome is worse than no switch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit f787a314075f2109b168dd3d66df06791667c7a8)
Perception only — this logs the edit and produces no rules. Getting the sensing right is the whole job here; letting it touch the user's dictionary is the next step. - `diff.rs`: minimal-edit extraction, pure and char-based. Longest common prefix, then longest common suffix on what's left; the middle is what the user actually touched. Whole-text diffs teach nothing — "大禹 → 大鱼" can become a rule, "this paragraph → that paragraph" cannot. Six rejection rules, each guarding against a class of false positive that would silently corrupt every future dictation. Pure insertion is rejected (a rule that inserts unconditionally anywhere is the worst of them); pure deletion is kept, since it is specific and cannot fire everywhere. - `edit_is_within_typed_text`: the observer watches the whole control, so a user editing their own earlier text in the same field also fires it. That edit has nothing to do with this dictation. This predicate is the only line between "learn our own mistakes" and "learn whatever moves", so it is a pure function with tests rather than an inline `contains`. - AXObserver on a dedicated thread, shape copied from `device_watch.rs` (thread → register → `run_in_mode(1s)` + exit flag → unregister → warn on failure), including why it does not use `CFRunLoopRun()` with a cross-thread stop. Uses `core_foundation`'s runloop wrapper rather than re-declaring `CFRunLoopGetCurrent`/`AddSource`, which `hotkey.rs` already declares — duplicate externs only work by luck. - Teardown has four independent guarantees, because a leaked observer means holding another app's AX reference and waking on its every keystroke: `EditWatcher` disarms on drop, the next dictation drops it, a 60s cap, and the thread kills itself when the front app changes. Baseline is `finalize_polished_text`'s return value — under streaming that is `typed_text`, what actually reached the screen, not the full LLM output. Using the full output would read every interrupted session as "the user deleted a large chunk". Adds `DictationSession.asr_transcript`: `raw_transcript` holds the text *after* local correction rules ran (dictation.rs rewrites `raw.text` in place). Telling "ASR misheard" from "the LLM broke it" needs the version from before that. Only written when the rules actually changed something. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 06f49faede8f4c47eae6be155feddb65da1b9c01)
Closes the loop: an edit the user makes by hand becomes a correction rule plus an ASR hotword. Those two do not substitute for each other — the rule guarantees this word is right next time, the hotword raises the odds we hear it right in the first place. **No pinyin dependency.** The plan called for one to power a "same reading → collect silently" tier, but the reference spec this was drawn from (§19.6) does not use phonetics at all — its six boundaries are purely textual, and the one place it does mention pinyin (IME cloud candidates) is explicitly listed as not worth copying. So: - Tier 1 (silent, tagged `learned`): cross-script only — one side pure CJK, the other pure ASCII letters (扣德克斯 → Codex). Nobody swaps a Chinese word for an English one to change their tone; that is us mishearing a loanword. - Tier 2 (confirm): everything else, including Chinese homophones. 大禹→大鱼 and 明天→后天 are textually identical in shape, so the text alone cannot separate a correction from a change of mind. Ask. Two problems found while building this, both fixed: - The minimal diff of a Chinese homophone correction is usually a SINGLE character (大禹→大鱼 strips to 禹→鱼), and a one-char rule fires everywhere — 禹州 would become 鱼州. So a rule widens outward through the stored context until it is at least 2 chars, yielding 大禹→大鱼, which is what the user meant. Tier is decided before widening, or the CJK context glued onto an ASCII pattern would hide every cross-script case. - Pure deletions never become rules. As a global replace, "delete 的" means every 的 in every future dictation disappears. The risk is nowhere near the reward. Detection still logs them. Suggestions queue in memory rather than firing a toast: at that moment the user is typing in another app, and stealing focus is the rudest thing we could do. They surface in the vocabulary page, which is also where `learned` rules carry a badge, a filter, and a bulk delete — being able to see and undo what was collected is the precondition for collecting anything at all. Dedup is by pattern and includes manual rules, so auto-collection can never duplicate or relabel something the user wrote themselves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit a603d0f8080a258449cffac20bb94e52b1fe0a3d)
Milestone 1 shipped `debug_read_cursor_context` with no way to reach it. A Tauri command with no caller is not a debug tool — the whole point was being able to see, per app, what we actually read. Only noticed after installing the build and looking for it. Settings → Advanced → Debug tools now has a probe button. The 5s countdown is the part that makes it usable: at the moment you click, the frontmost app is OpenLess itself, so a probe with no delay only ever reads our own settings window. The countdown gives you time to switch to Notes / VS Code / WeChat and click into a text field. Shows status, reason, elapsed ms, the front app, and the windowed text with the cursor marked — enough to tell "the safety gate blocked this" from "AX doesn't support this control", which is exactly the question the verification gate needs answered per app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 853f01ac145d248ad6da26ac651caa384ebeed35)
Written for a dogfooding pass: what to test, in what order, and what the answer should look like. The AX-coverage table at the top is the one that matters — which apps we can actually read is the biggest unknown in this feature, and the probe makes it answerable without dictating a word. Records what I verified on the installed build (Notes reads at 11ms with the cursor marker in the right place) so you are not re-running what is already settled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 17083a476fbdd20d3d71c33b97861b5aedd25222)
Two real bugs, both found only by installing the build and using it. **The baseline was read before our own text arrived.** `inserter.insert()` returning means the Cmd+V event was posted, not that the target app has put the text in the document — that takes tens to hundreds of ms. We read the baseline in that gap, so it captured the document as it was *before* the insertion. The first comparison then saw our own 25 inserted characters as the difference, called it a pure insertion, and dropped it. The word the user actually corrected was buried under that and never got looked at. Log from the failing run: `cursor context read OK: 44 chars` at 19:18:43, `edit watch armed` at 19:18:52, first notification `baseline=44, current=69`. The baseline now anchors when the insertion is observed to have landed — either our text shows up in the document, or 1.5s passes (apps that reshape what we typed, via smart quotes or autocorrect, would otherwise never match and the watcher would wait forever, failing silently). **Submitting a chat box was learned as a correction.** Pressing Return in Claude Desktop clears the input and shows a placeholder, which is structurally a "replace this whole sentence with that one" edit. `MAX_EDIT_CHARS` did not catch it — the sentence was 25 characters. It was suggested as a rule, so saying that sentence again would have replaced it with "Type / for commands". Rejected now by sentence boundary: an edit whose source or target contains a newline or CJK punctuation or `?!;` is not someone fixing a word. Tried a proportion guard first (edit vs document size) and threw it away — real false positives and legitimate corrections overlap on that axis, and it killed several valid cases. The boundary check deliberately ignores the ASCII period: Node.js, co.uk and v1.2 all contain one, and technical names are exactly what this feature exists to learn. Also adds the diagnostics that made this findable: a notification count on disarm (0 vs >0 separates "the observer never fired" from "it fired and something downstream ate it"), and a log line on every early return in the callback. The first debugging round produced nothing because that whole path was dark — and because the logs were at `debug` level, which this app does not record. Registers `AXSelectedTextChanged` alongside `AXValueChanged`. Not every text control emits the latter, and a user fixing a word always moves the caret, so it is a second evidence path for the same event. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 80e1691467a319331efd419a876507c9522ccee8)
There was no notion of "the user is done" at all — every notification was judged immediately. That survives a one-character fix by luck, and falls apart on anything longer. Changing 扣德克斯 to Codex is delete-four-chars, then C, o, d, e, x. Six notifications, and the intermediate states — 扣德克斯→C, →Co, →Cod — are each a structurally valid CROSS-SCRIPT edit, which is the tier that gets collected silently without asking. One correction would have dropped four pieces of garbage into the user's dictionary. The dedup set does not help: it stops the same pair repeating, not a sequence of different wrong pairs. The callback now only records that something changed and when. The watcher thread checks once per turn of its runloop and judges only after 1.2s of quiet, plus once more before disarming (a user who corrects a word and immediately switches app would otherwise lose it). 1.2s is well above the gap between keystrokes while typing and well below how long it takes to move on to the next thing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 5da1e06ec80d32a0853739ecc953ffe506009f8e)
…pires The previous commit picked "1.2s of quiet" as the definition of "the user is done." That is a guess, and it is wrong at both ends: pause to think mid-edit and it cuts you off; carry straight on to the next sentence and it judges while you are already somewhere else. We were already receiving the signal that answers this properly. Both notifications were registered but treated as interchangeable: typing → text changed AND caret moved clicking away → text unchanged, caret moved "Caret moved but the text did not" means the user left this spot — that is when the edit is final. Semantic, not a timer, and it costs nothing new: the `AXSelectedTextChanged` subscription was already there, we just never compared the text to see which kind of notification it was. So: record throughout, analyse only at a boundary. Boundaries are caret-moved, app switched, watch ended. The timer stays purely as a backstop for apps that do not emit caret events, and relaxes 1.2s → 5s — no longer the main judge, and being slower makes it *less* likely to catch a half-finished edit. The baseline advances only after a successful report. A rejected comparison has no verdict yet: delete a word, go copy something from elsewhere, come back and type the replacement — keeping the old baseline is what lets the whole edit be computed once you finish, instead of learning the deletion halfway. Design credit: the user asked what actually defines "done typing" instead of accepting the debounce, which is the question all of this hangs on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 4d9c96338db9143b4b26268a27a7a4c554b8f5db)
Real-machine log: every single keystroke logged "caret moved away; settling". The user corrected a word and nothing was learned. The two notifications arrive as a PAIR from one keystroke — AXValueChanged, then AXSelectedTextChanged milliseconds later. The callback updated last_text on the first, so the second saw "text unchanged, caret moved" and read it as a boundary. So every key press settled, every intermediate state was rejected (they are pure insertions), and each rejection consumed the pending edit. By the time the user finished typing there was nothing left to judge. The callback had the notification type in its parameters and ignored it. Now it uses it: AXValueChanged is always an edit; AXSelectedTextChanged only ends an edit if at least 300ms have passed since the last text change. Paired notifications are milliseconds apart, so they fall below the threshold; a genuine "stop typing, click elsewhere" is far above it. Erring toward missing a boundary rather than inventing one: a missed boundary is caught by the 5s backstop, an invented one learns half a word. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 347391d0cef20d780d9d50617ba5b4f214944a05)
Writing both was wrong, and the machine proved it. The dictionary held the hotword `Codex` — "I want this word" — while the learned rule said `Codex → 扣的爱思` — "replace this word". Same word, opposite meaning. Delete one and the other stays, so the behaviour was undefined. That is the overlap the user spotted. Learned knowledge does not deserve literal-replacement power: - A correction rule fires on sight. When it is wrong it is silent, global and invisible. Real logs from this session: `小鱼 → x`, `都去 → h` — half-typed intermediate states that would corrupt every future 小鱼. - A vocabulary entry is a hint. It goes to ASR to improve recognition, and into the polish prompt where the LLM decides WITH CONTEXT whether to apply it. Wrong, it merely fails to help. The dictionary already carried the correcting power through the LLM — the hotword block says "when the transcript contains a homophone of these, prefer this spelling". That path has judgement; literal replacement does not. And now it has the cursor context too. The cost, stated plainly: no deterministic correction in Raw mode, and ASR may still mishear. Acceptable — misfiring is silent, not-helping is visible. Tiering is rebuilt around the new question. It used to ask "is this replacement safe", so it inspected the source→target mapping and its direction. It now asks "is this WORD worth remembering", so it only looks at the target. Direction stops mattering, which is what dissolves the loop: whether you changed Chinese to English or the reverse, what gets remembered is the word you ended up with. - Latin-script word (Codex, Node.js, GPT-5) → collected silently. Changing a word to an English spelling is itself the evidence that it is a proper noun. - Anything else (mostly Han words) → ask. 大鱼 could be a company or could be literally "big fish"; 接口 is a term and also an extremely common word. A common word in the hotword list makes recognition over-eager for it. - Not a word at all (empty, crosses a sentence, over 12 chars) → dropped. `RuleSource` stays on CorrectionRule: nothing writes `learned` any more, but early builds already wrote some into users' files and the UI must be able to show and remove them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 5a0135c3209abac4929dfa1ddc120571476c3ecd)
The queue in the settings page was the wrong place. A suggestion matters at the moment you just corrected the word — that is when you still remember why. Buried in a settings page you never think to look, and once it fills up it starts dropping the oldest, so it was accumulating nothing. The card appears where the capsule does. That window is already a nonactivating panel, so it cannot steal the caret while you type in another app, and it is a position you are already used to watching. Reuses the capsule window rather than opening another one: multi-monitor placement, Space attachment (including the macOS 26 bug that pinned the window to a single desktop) and the nonactivating panel setup were all arrived at the hard way, and a second window would have to earn them again. One thing had to change. The capsule is normally fully click-through — it floats over other apps and must not block what is underneath. A card you can click needs that off, and a transparent window that is not click-through blocks the mouse across its TRANSPARENT area too. So while the card is up the window shrinks to the card's own size, and the blocked region is only the card itself; both are restored when it goes away. The card takes an independent event channel rather than a new CapsuleState. `emit_capsule` is the single exit for session state and carries Esc exclusivity, Space re-assertion, monitor placement and the Linux fcitx text with it — a non-session state does not belong in there. Per the design decision: appears as soon as the caret leaves the edit, gone after 10s, several edits merge into one card, and nothing is recorded when dismissed. No rejection list — an invisible list would only leave the user wondering later why a word stopped being learned. Say it again and it asks again. Vocabulary page gets the divider layout: your own entries above, auto-collected below the line with its own count and a Remove all. No per-chip badge — mixed into one wall of chips you would have to read every one; a section you take in at a glance, and "remove all" naturally governs the block beneath it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 5dfad460f5ccc23be8ea9a30382fd1f9db2946fa)
Two ways the card left the capsule broken, both reported as "the hotkey stopped working" — the hotkey was fine, there was just no visible feedback. `begin_session_as` calls `hide_vocab_suggestion_card` on every dictation. With no guard it went on to `window.hide()` unconditionally, racing `emit_capsule`'s show over the same window. Now it returns immediately unless a card is actually up. And hiding restored click-through but not the size. The card shrinks the window to its own 300×108 (a transparent window that is not click-through blocks the mouse across its transparent area too, so it has to be small), and without restoring it the next capsule was squeezed into that — effectively invisible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 4d4170f935e21ad9d57043b5e0da10a4b70fcf93)
daily 重建到 origin/beta 后,几个本地 commit 写作时上游还没有「划词润色」, 把它们放到新 beta 上就缺了三处对接: - selection_polish 调 polish_text 时少了新增的 cursor_context 参数。传 None: 选区润色的输入是用户选中的整段文字,本身就是完整上下文,光标前后文是给 「对着光标口述」用的,这里没有意义。 - 选区润色写历史时缺 asr_transcript 字段(本地新增,存「纠正规则生效前的识别 文本」)。选区润色不经过 ASR,恒为 None。 - polish.rs 里一个 beta 侧新增的单测调 compose_polish_prompts 时少了 cursor_context 参数。 仅对接,不改任何行为。cargo test --lib 990 通过,npm test 通过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 69e21eebaa0a8de024a23e743a03af4b0d4b6fb0)
The per-event logging earned its keep — it is what located the baseline bug and the paired-notification bug, neither of which was findable by reading the code. But it fires on every keystroke: a day of normal use produced 2341 cursor-context lines in a 5.5MB log. Dropped to `debug` (this app records `info` and above, so they are silent in normal use): baseline anchored, caret moved away, settled-with-no-edit, unreadable AXValue, edit-outside-our-text. What stays at `info` is one armed/disarmed pair per dictation, and the disarm line now carries both counts — notifications received and edits reported. Two numbers are enough to tell whether an app emits usable notifications at all, which is the per-app coverage data this was supposed to collect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 6d84fa54275d49d21a7b3a6ea9176d3d55db3b6c)
The old plan described correction rules and a Tier1/Tier2 split that no longer exist — the design changed twice while dogfooding it. Now covers what shipped: vocabulary entries only, the card at the capsule's position, and the divider layout. The rejection table names the two false positives that actually bit us on the machine — pressing Return in a chat box, and half-typed intermediate states — since those are the cases most worth re-testing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 7667ddd4fc4b58ee828feb8fa1fddcf580c55b3d)
真机跑两天的证据:自动收进词汇表的 5 条只有 1 条是对的。 Tailscale (was telskill) ✓ 唯一一条真纠错 ype (was ap) 逐字敲 Type 的中间态 ess (was ice) 同上 typeless (was TypeScript) 用户本来就要打这个词 " claude" (was cloud) 带前导空格,永远匹配不上 「跨文种的改动一定是专名」这条假设是错的。观察器看到的是编辑过程中的每一帧, 而中间态和一次真纠错在文本上没有任何区别——粗筛拦不住,也不该硬拦。 所以删掉 RuleTier 这一整个分级概念,只留一条路:提成建议 → 卡片 → 你点勾。 LearnedRule 不再带档位,handle_user_edit 也就没有第二条分支了。 顺带修掉那个前导空格:最小差异是按 char 剥前后缀剥出来的,边界上很容易挂着 一个空格,learned_rule 收尾补 trim。 卡片三处改动: - 位置从「屏幕正下方居中」(胶囊那儿)改到右下角。居中那块正是用户在写字的 地方,卡片停十秒直接盖住正在编辑的那一行。 - 「好 / 都不用」换成每条一勾一叉。尺寸、配色、SVG path 全部照搬胶囊上那对 确认/取消按钮——同一个产品里的同一个手势不该长成两个样子。没有批量入口: 逐条看一眼是这里唯一可靠的判据。 - 配色从写死的深色换成 --ol-capsule-* token(原来在亮色模式下是一块黑的), 描边从 0.5px solid 换成 1px + 扩散阴影。非整数边框落在半个物理像素里, 圆角边缘看着就是糊的。 逐条点完会重算窗口高度:显示卡片期间窗口不穿透鼠标,不重算的话已经空掉的 那片透明区域会继续拦住底下的点击。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 7c09724253cdac4ca62392564b67e52030546340)
发给 ASR 的词汇偏置 prompt 有 240 字符预算(whisper.rs::PROMPT_CHAR_BUDGET, 按 OpenAI 244 token 上限的 CJK 最坏情况折算),装不下的词条直接跳过。而词条是 按词典顺序填进去的,词典顺序又是「最近添加的在最前」(DictionaryStore::add 用 insert(0),为的是词汇表页面把刚加的词排在上面)。 两个各自都合理的决定撞在一起:预算永远优先喂给最新的词,最老的先掉出去——而最 老的那批恰恰是攒了最多命中的常用词。真机上一份 40 条的词典里,命中 18 次、10 次、 7 次的三个专有名词全部排在预算外,一次都没送到过 ASR。用户在词汇表里看得见它们、 以为在生效,实际上从来没生效。这也解释了为什么「加了热词还是识别不出来」——问题 根本不在识别,在于那个词压根没送出去。 改成按优先级排序再填预算: - 最近添加的前 5 条保底。刚加的词还没机会攒命中,纯按命中排会让它永远进不去, 而用户刚加它多半就是因为刚被它坑过。 - 其余按命中次数降序。命中是扫描最终文本累加的,与词条有没有进过 ASR 词表无关, 所以是个干净的信号。 - 同词异形(claude / Claude)只留命中多的那个写法,位置取最靠前那次。既省预算, 也免得两种写法一起进词表让模型无所适从。 只改 ASR 侧(听写与 QA 两处构造点)。LLM 热词块没有名额限制,仍走 enabled_phrases, 顺序不变。 另外给预算丢弃加了一行日志。之前是完全静默的,真机上排查花了很久;只在丢弃集合 变化时打,词典不变就不重复刷。 同一份真实词典(40 条)改动前后: 改动前 Typeless / hermes / win-shukong / 大曹 / NewAPI / songkey / PR 全部在预算外 改动后 以上全部进入,被挤掉的换成命中 0~1 的冷词 cargo test --lib 908 passed,含 3 个锁住排序规则的新测试。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 983b6d0cb892ff3667cd764c5eca1bab4f76d80e)
两个功能合到一起才暴露出来的相互作用: - 学习路径走 add_if_absent,原本和手动添加一样 insert(0),插在词典最前面 - ASR 词表预算把「词典最前面的 FRESH_VOCAB_SEATS(5) 条」当保底席位 保底席位的理由是「用户刚手动加这个词,多半是刚被它坑过」。这对手动添加成立, 对着建议卡片点一下勾不成立——门槛低得多,而卡片本来就会建议出半截词(真机上 见过 ap → ype 这种逐字打字的中间态)。 连点五个勾,五个保底席位就全被它们占住,把用户攒了几十次命中的常用词挤出 240 字符预算——那正是 FRESH_VOCAB_SEATS 所在的那个提交要解决的问题本身。 改成追加到末尾。不需要给「学来的」加任何特判,位置本身就把两类区分开了。 排在队尾不等于永远进不了预算: - 词条进 LLM 热词块没有名额限制,那一侧立刻生效; - 命中计数扫的是最终文本,与有没有进过 ASR 词表无关,所以这个词一旦真的开始被 用上就会按命中自己爬进预算。 add_if_absent 只有学习路径这一个调用方,手动添加的 add() 不受影响。词汇表页面 按 note 字段分上下两组,与存储顺序无关,展示不变。 顺带给 DictionaryStore 补了 new_at 测试构造器(与 CorrectionRuleStore 同形), 新增的两个用例因此不会碰用户真实的 dictionary.json。 cargo test --lib 991 passed。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR Reviewer Guide 🔍(Review updated until commit f907c97)Here are some key observations to aid the review process:
|
README 的词典章节里一直写着: The app auto-learns candidate corrections such as `Claude`, `ChatGPT`, and `OpenLess` from your history and offers them later. 应用会从你的历史中自动学习候选纠正,并在之后向你推荐。 这句话在 beta 上没有任何实现支撑——搜遍 persistence/dictionary.rs、coordinator 和 Vocab.tsx,没有任何「从历史学词」或「候选建议」的代码。它一直是一句空头支票。 这个分支把它兑现了,所以顺手把描述改成实际做出来的东西:不是「从历史里挖」, 而是「你改掉我刚打出来的词时,当场问你一句」。 三处改动: - 词典章节:换掉那句空话,写清真实的闭环(手改 → 卡片 → 点勾入库),并说明 每一条都要用户过目、没有静默入库。新增「光标上下文」小节交代它是这一切的 前提:只有看得见自己刚打出去的文字,才可能发现用户改了哪个词。同时写明默认 关闭、仅 macOS、以及永远不读的四类场景。 - 更新亮点:加一条。措辞落在「从转写工具变成认得你的词的输入法」这个转变上, 而不是罗列功能点。 - 对照表 Typeless 那一行:补上「学到的东西留在本机,且不会在你确认前加进去」。 这是同类闭源订阅产品给不了的那部分。 只改文档。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Persistent review updated to latest commit 6829779 |
CI 与机器人审阅回来的三条,全部核实成立。
## 1. Android cargo check 挂了(硬阻塞)
`set_ignore_cursor_events` 在 Android 的 tauri::WebviewWindow 上不存在,卡片
显示/收起各调了一次。仓库里 capsule_focus.rs:645 对同一个 API 早就有处理,
还专门写了注释说明 Android 不可用——照它加 #[cfg(not(mobile))]。
Android 上根本没有胶囊窗口,get_webview_window("capsule") 会先返回 None,
所以只需要让这两句在 mobile 上消失,其余逻辑不必动。
## 2. AX 调用跑在 tokio worker 上
spawn_edit_watcher 原本在调用线程上抓焦点元素、读基线全文、取 pid,注释写的
理由是「趁焦点还没跑」。但调用方 arm_edit_watch 位于 end_session(async)里,
也就是 tokio worker——而这几次 AX 调用每次都可能耗到 AX_MESSAGING_TIMEOUT_SECS,
对着一个 AX 无响应的 app(正是设这个超时要防的那种)能把一个 worker 卡住几百
毫秒。
**这条违反的是本模块自己文档里的硬约束第 2 条**,也是规划文档点名不许犯的。
改成在新线程里做(抽出 grab_focused_element)。代价是「趁焦点还没跑」的窗口从
零变成一次线程启动,几十微秒——比放进 spawn_blocking 好,那个要排 tokio 阻塞池
的队,负载高时反而更晚。
抓取失败时线程直接返回,观察器成为惰性对象;EditWatcher 的 Drop 只是置一个
flag,没有副作用。
## 3. 大文档上每条通知都整份读 AXValue
观察窗口最长 60 秒,用户每敲一个键都可能来一条通知,每条都要整份读 AXValue
再做 O(n) 比对。文档大到一定程度,这就成了「用户改一个词,每次击键都跨进程
拷贝一份文档」——卡顿,甚至把 AX 消息拖超时。同一个 PR 里的 read_document 正是
为了避免这个才做了 AXStringForRange 分支,观察路径却没有对应的保护。
加 EDIT_WATCH_MAX_CHARS = 20_000(与 FULL_TEXT_MAX_UTF16 同量级):超过就不武装,
打一行 info 说明。学不到词可以接受,让用户打字变卡不行。
cargo test --lib 991 passed;cargo check(macOS)干净。Android 本地无法验证
(本机 cargo 由 Homebrew 安装,没有 rustup 加不了 target),交给 CI。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Persistent review updated to latest commit e284d01 |
|
Persistent review updated to latest commit 380b7af |
机器人报的 Split-Edit Miss 成立,而且它命中的是**最自然的那个纠错动作**:
删掉错词 → 停顿 → 敲正确的词。
原来的时序:
删词 settle(光标移开安静 300ms,或 5 秒兜底)→ 纯删除差异被上报
→ 基线推进到「已删词」状态
敲词 相对新基线只剩一条「空 → 新词」的纯插入
→ minimal_edit 对纯插入一律返回 None
于是只要用户在删词和敲词之间停顿一下(或移动过光标),这次纠正就永远学不进去。
代码和它自己的注释是矛盾的:注释写着「被过滤掉的不推进:用户可能删掉一个词……
再回来把新词打完」,意图完全正确,但真正的过滤发生在**下游** handle_user_edit 里
(is_vocab_worthy 判 target 为空就丢弃),观察器看不到那个决定,照样把一次注定被
丢弃的改动当成「已结论」,顺手吃掉了基线。
让观察器用下游同一个判据:is_vocab_worthy 为假就既不上报也不动基线。用户把新词
敲完时,相对原基线算出来的正是完整的「错词 → 正确词」。
顺带少一类噪声日志——纯删除以前每次都会打一条 user edit detected,而它从来就
不可能变成词条(词条要求 target 非空)。真机日志里这种占了相当一部分。
cargo test --lib 1002 passed。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
成立,已在 a5c2a05 修复 —— 这条命中的是最自然的那个纠错动作,价值很高。 确认的时序只要用户在删词与敲词之间停顿一下,这次纠正就永远学不进去。 代码和它自己的注释是矛盾的原处注释写着:
意图完全正确 —— 但真正的过滤发生在下游 修法让观察器用下游同一个判据: 这比只特判纯删除更彻底 —— 任何注定学不到的改动(跨句、超长)都不再吃基线。 顺带少一类噪声日志:纯删除以前每次都打一条
🤖 Addressed by Claude Code |
|
Persistent review updated to latest commit a5c2a05 |
机器人指出:解除信号到达时观察线程可能正卡在 CFRunLoop::run_in_mode 里(最长 1 秒),这一秒内排队的 AX 通知照样派发到 value_changed_shim,而循环末尾那道 `if !stop.load(..)` 覆盖不到回调这条路径。位置属实。 但它推的后果——「一条属于上一轮的建议会在新会话里弹出卡片」——不成立:协调方那边 已经有两道防线,而且都在 handle_user_edit 之前: - 观察器代次:回调闭包的**第一行**就比对代次,对不上直接 return,连 handle_user_edit 都进不去,更不会排进 pending_corrections; - 听写不在 Idle 就不弹卡片:判据下沉在 show_vocab_suggestion_card 的主线程闭包里, 是碰窗口前的最后一个时点。 不过「循环看 stop、回调不看」本身不自洽,而且不挡的话,一次已经作废的观察还会再 去跨进程读一遍宿主 app 的全文——那既是浪费,也是多读了一次本不该再读的东西。 WatchContext 加 stop 字段,回调开头先看它,**这一刀在读 AXValue 之前**。它不是唯一 防线,是最早、最便宜的一道。 cargo test --lib 1002 passed。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
位置属实,已在 23637dd 补上;但推出的后果不成立,一并说明。 属实的部分解除信号到达时观察线程可能正卡在
不成立的部分:卡片不会在新会话里弹出协调方那边已经有两道防线,而且都在
这两道正是前几轮针对同一失败模式加的。新加的
🤖 Addressed by Claude Code |
|
Persistent review updated to latest commit 23637dd |
CFStringGetCString / CFNumberGetValue 原本声明成返回 Rust `bool`。CoreFoundation 的 `Boolean` 是 `unsigned char`,不是 C 的 `_Bool`;而 Rust 的 `bool` 要求位模式 **恰好**是 0 或 1,其余一律 UB。拿它接一个 unsigned char,等于赌 CF 永远只返回 0/1——实践中确实如此,但这是赌,不是保证。 同一个文件里 AXValueGetValue 早就是 `u8` 了,这两个是当初照抄 selection.rs 抄进来 的。而本模块开头第三条硬约束写的就是「新代码不得复制既有缺陷,哪怕仓库里的旧 AX 代码就是这么写的」——当时说的是 AX 超时,这里我自己破了同一条例。 改成 `u8` + 与 0 比较,本文件三处 CF/AX 布尔返回值口径统一。 selection.rs 那份保持不动:不在本 PR 范围内,改它要重新验证选区润色整条链路, 风险与收益不匹配。它属于模块文档里点名的那类既有缺陷,值得单独开一个 issue。 cargo test --lib 1002 passed。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
成立,已在 d3f9d3b 修复。 CoreFoundation 的 更难堪的是同一个文件里口径就不一致:
当时那句说的是 AX 超时,这里我自己破了同一条例。 改成
🤖 Addressed by Claude Code |
|
Persistent review updated to latest commit d3f9d3b |
机器人报的竞态成立,而且它指出的其实是一个更根本的设计错误。 bundle_id 由调用方在**取焦点元素之前**采样(current_front_app_parts),而每个 AX 调用都可能阻塞到 AX_MESSAGING_TIMEOUT_SECS。用户在这中间切了 app: 采样 bundle = 备忘录(放行) ...AX 调用阻塞中,用户切到终端... AXFocusedUIElement 返回的是终端的元素 闸门用「备忘录」的身份放行 → 读走终端的文本 终端、密码管理器正是靠 bundle 黑名单拦的,这条路径把它们漏了。 但把它只当竞态修就治标了。真正的问题是**判据本身选错了对象**:闸门要判的是「手里 这个元素属于哪个 app」,而前台 app 只是它的一个近似——两者本来就可能不一致,竞态 只是让这个近似失效得更明显。 改成拿元素自己的 pid 换 bundle(新增 selection::bundle_id_for_pid, NSRunningApplication.runningApplicationWithProcessIdentifier),在第二道闸门里覆盖 掉第一道用的那个前台 app 值。第一道保留——它不需要任何 AX 调用,命中就一条消息都 不发,作为省事的粗筛仍然有价值,只是不再是最终判据。 Secure Input 也顺手在第二道刷新一次:它是全局状态,同样可能在这几次 AX 调用期间 才被打开。 两条路径(上下文读取、手改观察)都从这一个入口拿元素,所以一起修好。 cargo test --lib 1002 passed。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
成立,已在 feac7a4 修复。而且这条指出的其实是一个比竞态更根本的设计错误 —— 值得说清楚。 竞态本身
终端、密码管理器正是靠 bundle 黑名单拦的,这条路径把它们漏了。 但只当竞态修就治标了真正的问题是判据选错了对象:闸门要判的是「手里这个元素属于哪个 app」,而前台 app 只是它的一个近似 —— 两者本来就可能不一致(焦点元素的归属与 frontmost 不是同一件事),竞态只是让这个近似失效得更明显。 所以没有去做「再采样一次、比对是否变化」那种补丁,而是换判据:拿元素自己的 pid 换 bundle(新增 第一道保留 —— 它不需要任何 AX 调用,命中就一条消息都不发,作为省事的粗筛仍然有价值,只是不再是最终判据。
两条路径(上下文读取、手改观察)都从这一个入口拿元素,所以一起修好 —— 这正是上一轮把「取元素」和「过闸门」焊死成单一入口的收益。
🤖 Addressed by Claude Code |
|
Persistent review updated to latest commit feac7a4 |
机器人指出:pending_since 每次文本变化都重置,用户改完词不停手继续写的话,判定 一直往后推;等他终于停下来,比对的是「原基线 vs 最终文本」——改的那个词和之后写 的所有内容被并成同一处差异。要么超长/跨句被拒(纠正白做),要么变成一条被污染的 建议。属实。 这跟用户今天自己抓到的「改完按回车撑成整句」是同一个根:minimal_edit 只能表达 一处连续差异,做两处改动时中间的字必然被卷进来。 **这版不收紧行为**,两个方向都会退化掉更重要的东西: - 把 pending_since 改成只在为 None 时设置(等于给窗口加 5 秒硬顶),会重新开始抓到 单个词改到一半的中间态——那正是 EDIT_SETTLE_TIMEOUT 当初从 1 秒放宽到 5 秒要躲开 的,而「什么时候算打完字」这个判定是花了好几轮真机才调对的; - 真正的解法是换成能识别多处改动的差异算法(LCS 之类),那是独立一件事,而且必须 有真机数据才能验证它没把已经调好的判定搞坏。本 PR 已经 20 个 commit,再塞一次 算法重写只会让它更难评审。 所以只把代价写进 EDIT_SETTLE_TIMEOUT 的文档:这条路径上的建议要么没有、要么偏长, 而每条建议都要用户点勾才入库——代价是漏学或多看一眼,不是静默写错。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
成立。已在 45d599b 把代价写进文档,但这一版不收紧行为 —— 理由如下,供 maintainer 判断。 确认的问题
要么超长/跨句被拒(这次纠正白做),要么变成一条被污染的建议。 这跟本 PR 里已经修掉的「改完按回车撑成整句」是同一个根: 为什么不在这一版收紧两个方向都会退化掉更重要的东西:
现状的代价是可接受的这条路径上的建议要么没有、要么偏长;而每条建议都要用户在卡片上点勾才入库(本 PR 已取消全部自动入库)。所以代价是「漏学一次」或「多看一眼再点叉」,不是静默写错。 代价已写进 如果 maintainer 认为该在本 PR 内一并解决,我可以接着做算法替换,但建议单独一个 PR。 🤖 Addressed by Claude Code |
|
Persistent review updated to latest commit 45d599b |
同一处 (source, target) 在一次观察窗口里出现两次是常事——听错的专名在好几句里都 出现,用户逐个改过去。第二次被 reported 去重挡掉时,函数直接 return,基线没推进。 于是基线停在「只改了第一处」的状态,而文档已经改了两处。之后用户再改任何东西, minimal_edit 都是拿这个陈旧基线去比,算出来的 span 把「已经有结论的那处重复改动」 和「新改动」搅在一起——多半过不了 edit_is_within_typed_text,新的那次纠正就被静默 丢掉了;万一过了,建议本身也是被污染的。 把基线推进挪到去重**之前**。去重管的是「别重复上报」,不是「这处改动没发生」。 判据统一成一句话:**有结论就推进,无论这个结论是不是新的。** 前面两道 return (不是我们插的文字、注定成不了词条)才是「还没有结论」,那两处保留基线是对的—— 后者正是上一轮修「删掉重打」时加的。 cargo test --lib 1002 passed。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
成立,已在 de608c6 修复。 同一处 把基线推进挪到去重之前。去重管的是「别重复上报」,不是「这处改动没发生」。 现在这三处
第二行是本 PR 前一轮修「删掉重打学不到」时加的;这一轮补上第三行,两者放在一起看语义才闭合。
🤖 Addressed by Claude Code |
|
Persistent review updated to latest commit de608c6 |
上一轮把闸门改成用元素自己的 pid 换 bundle,但只在成功时覆盖:
if 取到 pid && 查到 bundle {
gate.bundle_id = Some(owner); // 成功才覆盖
}
// 失败时保持采样时那个前台 app 的值 → 照样放行
于是取不到归属时,闸门退回按「谁在最前面」判定——正是上一轮要修的那个漏洞原样
还在。这是 fail-open,比原来更糟:代码看着已经防住了。
改成失败关闭,返回 Unavailable。
注意**不能简单地把 bundle_id 清成 None**:evaluate_gate 对缺失的元数据是放行的
(missing_metadata_does_not_block_by_itself 钉着这个行为),那只是换了一种
fail-open。所以必须显式返回,不能靠清字段。
这跟那条测试的设计意图并不冲突——它的注释写着「读不到 bundle / role 时……读不到
文档自然会走 Unavailable」。本次改动正是把那个「自然会」在这条路径上变成显式的:
之前它在这里并不成立。
代价是没有 bundle id 的进程读不到上下文。那类进程本来就很少,而「宁可不读」是这个
功能对隐私的基本承诺。
cargo test --lib 1002 passed。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
成立,已在 f907c97 修复 —— 而且这是上一轮那个修复自己留下的口子,性质比原问题更糟:代码看着已经防住了。 上一轮把闸门改成用元素自己的 pid 换 bundle,但只在成功时覆盖: if 取到 pid && 查到 bundle {
gate.bundle_id = Some(owner); // 成功才覆盖
}
// 失败时保持采样时那个前台 app 的值 → 照样放行取不到归属时,闸门退回按「谁在最前面」判定 —— 正是上一轮要修的那个漏洞原样还在。 改成失败关闭,返回 一个不能走的捷径不能简单地把 这跟那条测试的设计意图并不冲突 —— 它的注释写着「读不到 bundle / role 时……读不到文档自然会走 代价没有 bundle id 的进程读不到上下文。那类进程本来就很少,而「宁可不读」是这个功能对隐私的基本承诺 —— 与模块开头「密码框 / Secure Input / 密码管理器 / 终端一律不读」同一条原则。 这段逻辑在 AX 包装层,脱离真机无法单测;
🤖 Addressed by Claude Code |
|
Persistent review updated to latest commit f907c97 |
beta 一次进了 13 个提交,其中三条正是本地 daily 上那批私货合上游了 (Open-Less#910 历史落点+重试、Open-Less#908 翻译、Open-Less#909 概览),daily 的 patch queue 相应缩小。 冲突只有 selection.rs 一处,而且 HEAD 侧是空的:beta 的选区润色 macOS 移植 (Open-Less#926)在同一位置加了 current_front_app_pid 与 Windows/其他平台的 current_front_app,本分支在附近加了 bundle_id_for_pid(闸门按元素归属判定用)。 两者互不相干,取 beta 侧即可。 解完核对:bundle_id_for_pid 仍在(1 处),current_front_app 三个定义分属 macOS / Windows / 其他平台且 cfg 互斥,current_front_app_pid 无重复。 验证:cargo check 干净、cargo test --lib 1032 passed(beta 带来 30 个新用例)、 tsc --noEmit 干净、npm test 通过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…重复定义 上一个合并 commit 解 selection.rs 冲突时,我看到 HEAD 侧是空的,就当成「beta 纯 新增」取了 theirs。判断错了:**空的 HEAD 侧也可能意味着「这一侧删掉了它」**。 本分支在里程碑 1 把 current_front_app 重构成了 current_front_app_parts 之上的通用 包装(无 cfg,一份顶所有平台);beta 没有这个重构,那边仍是按平台各写一份。git 看 到的是「我删了平台版本、beta 改了平台版本」,取 theirs 等于把被重构掉的两份又请了 回来: 1017 pub(crate) fn current_front_app() 无 cfg,所有平台 1066 #[cfg(windows)] fn current_front_app() ← 重新带回来的 1094 #[cfg(not(macos),not(windows))] fn ... ← 重新带回来的 macOS 上恰好不冲突(那两个 cfg 都不成立),所以本地全绿;Linux 上 1017 与 1094 撞 成 E0428,Windows 会以同样方式撞 1017 与 1066——CI 先报了 Linux,Windows 当时还在 排队。 删掉重新带回来的那两份,保留通用包装。beta 真正新增的 current_front_app_pid(选区 润色 macOS 移植 Open-Less#926 要用)留下。 核对平台覆盖:current_front_app_parts 三个实现互斥且全覆盖;current_front_app_pid 是 macOS 专属,唯一调用点也在 macOS 分支内。 cargo check 干净、cargo test --lib 1032 passed(macOS)。Linux / Windows 交给 CI。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
User description
README 的词典章节里一直写着这样一句话:
这句话在
beta上没有任何实现支撑。 搜遍persistence/dictionary.rs、coordinator/和Vocab.tsx,没有任何「从历史学词」或「候选建议」的代码。它一直是一张空头支票。这个 PR 把它兑现了——而且用了一种比「从历史里挖」更直接的方式。
今天的词典是死的
它只知道你亲手敲进去的东西。而这条路有三个断点:
第 3 条还顺带解释了一个长期存在的困惑:「我加了热词怎么还是识别不出来」——问题不在识别,在于那个词压根没送出去。
这个 PR 之后
你改一次词,它问一声,你点勾,它记住。
口述 → OpenLess 打出「扣德克斯」→ 你手动改成
Codex→ 光标点到别处 → 屏幕右下角弹一张卡片:点勾,
Codex进词典。下次它在 LLM 侧立刻生效,在 ASR 侧随着命中次数自己爬进预算。不用打开设置页,不用填表单,不用预判自己会被哪个词坑。这是 OpenLess 第一次从"你怎么用它"里学到东西。
三块拼图
1. 光标上下文(默认关,仅 macOS)
没有它,上面那个闭环不可能存在——只有看得见自己刚打出去的文字,才可能发现用户改了哪个词。
它同时解决另一个问题:中文同音词(接口/借口、大鱼/大禹)声学模型分不出来,但上下文能分,而这条信息今天在 OpenLess 里完全缺失。开启后每次听写读光标附近约 600 字(80/20 分给上文/下文)随润色请求发出。
隐私边界(读的是别的应用里的任意文本,最终进 LLM 请求体,所以这块是硬要求):
AXUIElementSetMessagingTimeout),整次读取 1.2s 封顶,跑独立线程不占 tokio worker<cursor_context>信封 + 独立的防御条款(与既有 polish 防御分开,避免改变没开这个功能的用户的 prompt)非 macOS 返回
Unsupported,接口形状保持一致。2. 从手改中学词
落字后武装一个
AXObserver,用户改掉我们插入的文字时提成建议。「什么时候算改完了」用的是光标离开,不是定时器。 定时器会把逐字打字的每个中间态都判成一次编辑;改用
AXSelectedTextChanged判定边界,配 300ms 静默阈值区分「打字自己带出的光标事件」和「用户真把光标点走了」。观察器有三重解除保险:60 秒硬超时、前台 app 切换、下一次听写开始,外加 RAII。
只写词条,不写纠正规则。 早期版本两个都写,真机上撞出一个环:词典里的
Codex热词让识别把中文听成英文,用户改回中文,系统又学一条Codex → 扣的爱思把它换掉。词条只是提示,错了最多没帮上忙;纠正规则是字面替换,错了是静默的、全局的。学来的东西配不上后者那份权力。每一条都要用户点勾,没有静默入库这条路。 早期版本让「中文改成英文」这一档自动收,理由是「没人为了换语气把中文改成英文」。真机跑两天,自动收进去 5 条只有 1 条对:
Tailscale(was telskill)ype(was ap)Type的中间态ess(was ice)typeless(was TypeScript)claude(was cloud)观察器看到的是编辑过程中的每一帧,中间态和一次真纠错在文本上没有区别。分不出来就别猜——所以不提供任何批量入口,一条一个勾一个叉。
3. 让词典真的送得出去
热词预算改成按优先级排序:最近手动添加的前 5 条保底 + 其余按命中次数降序 + 同词异形(
claude/Claude)只留命中多的那个写法。只改 ASR 侧;LLM 热词块没有名额限制,顺序不变。同一份真实词典(40 条)改动前后:
学来的词条追加到词典末尾,不占那 5 个保底席位。 保底的理由是「你刚手动加它,多半是刚被它坑过」——对着卡片点一下勾不满足这个理由,而卡片本来就可能建议出半截词。连点五个勾就能把保底席位全占掉,把常用词挤出预算,正是本节要解决的问题本身。排队尾不等于进不去:LLM 侧立刻生效,ASR 侧靠命中自己爬进来。
测试
真机(macOS)验过:AX 覆盖率(备忘录 / 微信 / Chrome / Electron 各类应用)、安全闸门(密码框与终端确实被拦住)、上下文确实进了 prompt(
effective_prompt_chars前后对比)、手改学词端到端、热词排序(真实 40 条词典改动前后对比)。未在真机验过(如实列出):Windows / Linux 编译(本地无交叉编译目标,交给 CI;
EditWatcher在非 macOS 是空结构体,两个入口都有not(macos)分支)、卡片上点 ✗ 的路径、逐条点完后卡片重算高度、韩文与繁中文案渲染。装机测试步骤见
openless-all/app/docs/cursor-context-test-plan.md。文档
README(中英)的词典章节改写了:换掉开头引用的那句空话,写清真实闭环,新增「光标上下文」小节交代默认关闭 / 仅 macOS / 永不读取的四类场景。更新亮点加了一条,对照表 Typeless 那一行补上「学到的东西留在本机,且不会在你确认前加进去」。
🤖 Generated with Claude Code
PR Type
Enhancement, Bug fix
Description
Capture cursor context from host app for polish
AXStringForRange; UTF-16/char conversionLearn new vocabulary from manual edits
Wire cursor context into polish LLM prompts
<cursor_context>envelope with cursor marker and injection defenseFix grammar/edge cases and add tests
asr_transcriptDiagram Walkthrough
File Walkthrough
5 files
Add macOS Accessibility cursor and edit-watch captureImplement minimal diff algorithm for learning editsAdd cursor context parameter and injection defensePass no cursor context and set ASR transcript for selection polishThread cursor context through Gemini provider1 files
Set RuleSource::Manual in test helper41 files