Skip to content

feat: 让词典自己长——手改一次,它就记住(光标上下文 + 手改学词 + 热词预算优先级) - #917

Open
bigsongeth wants to merge 39 commits into
Open-Less:betafrom
bigsongeth:feat/cursor-context-vocab
Open

feat: 让词典自己长——手改一次,它就记住(光标上下文 + 手改学词 + 热词预算优先级)#917
bigsongeth wants to merge 39 commits into
Open-Less:betafrom
bigsongeth:feat/cursor-context-vocab

Conversation

@bigsongeth

@bigsongeth bigsongeth commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

User description

README 的词典章节里一直写着这样一句话:

The app auto-learns candidate corrections such as Claude, ChatGPT, and OpenLess from your history and offers them later.
应用会从你的历史中自动学习候选纠正,并在之后向你推荐。

这句话在 beta 上没有任何实现支撑。 搜遍 persistence/dictionary.rscoordinator/Vocab.tsx,没有任何「从历史学词」或「候选建议」的代码。它一直是一张空头支票。

这个 PR 把它兑现了——而且用了一种比「从历史里挖」更直接的方式。


今天的词典是死的

它只知道你亲手敲进去的东西。而这条路有三个断点:

  1. 你得先意识到某个词会被听错,还得记得去添加它。 绝大多数人不会。
  2. 你改错之后,这个知识没有任何地方沉淀。 下次照错不误。
  3. 就算你加了,它也可能压根没送出去。 发给 ASR 的热词预算只有 240 字符,装不下就跳过;而填充顺序是「最近添加的在最前」。真机上一份 40 条的词典里,命中 18 次、10 次、7 次的三个专有名词全部在预算外,一次都没送到过 ASR。你在词汇表里看得见它们,以为在生效。

第 3 条还顺带解释了一个长期存在的困惑:「我加了热词怎么还是识别不出来」——问题不在识别,在于那个词压根没送出去。

这个 PR 之后

你改一次词,它问一声,你点勾,它记住。

口述 → OpenLess 打出「扣德克斯」→ 你手动改成 Codex → 光标点到别处 → 屏幕右下角弹一张卡片:

要记住这个词吗?
扣德克斯 → Codex        ✓  ✗

点勾,Codex 进词典。下次它在 LLM 侧立刻生效,在 ASR 侧随着命中次数自己爬进预算。

不用打开设置页,不用填表单,不用预判自己会被哪个词坑。这是 OpenLess 第一次从"你怎么用它"里学到东西。


三块拼图

1. 光标上下文(默认关,仅 macOS)

没有它,上面那个闭环不可能存在——只有看得见自己刚打出去的文字,才可能发现用户改了哪个词。

它同时解决另一个问题:中文同音词(接口/借口、大鱼/大禹)声学模型分不出来,但上下文能分,而这条信息今天在 OpenLess 里完全缺失。开启后每次听写读光标附近约 600 字(80/20 分给上文/下文)随润色请求发出。

隐私边界(读的是别的应用里的任意文本,最终进 LLM 请求体,所以这块是硬要求):

默认状态 关闭。设置 → 隐私 → 数据存储
关闭时 一次辅助功能调用都不发,prompt 与没有这个功能的构建逐字节相同(有单测钉死)
永不读取 密码框(AXSecureTextField)、macOS Secure Input、密码管理器、终端类应用——在发出任何 AX 调用之前拦下
超时 单次 AX 调用 200ms(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 条)改动前后:

改动前  Typeless / hermes / win-shukong / 大曹 / NewAPI / songkey / PR  全部在预算外
改动后  以上全部进入,被挤掉的换成命中 0~1 的冷词

学来的词条追加到词典末尾,不占那 5 个保底席位。 保底的理由是「你刚手动加它,多半是刚被它坑过」——对着卡片点一下勾不满足这个理由,而卡片本来就可能建议出半截词。连点五个勾就能把保底席位全占掉,把常用词挤出预算,正是本节要解决的问题本身。排队尾不等于进不去:LLM 侧立刻生效,ASR 侧靠命中自己爬进来。


测试

cargo test --lib      991 passed
backend-tests         125 passed
npm test              passed
tsc --noEmit          clean

真机(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

    • macOS AX read with security gates and messaging timeout
    • Large documents use AXStringForRange; UTF-16/char conversion
  • Learn new vocabulary from manual edits

    • AXObserver watches edits after insertion; minimal diff extracts changed word
    • Suggestion card confirms before dictionary entry
  • Wire cursor context into polish LLM prompts

    • Optional <cursor_context> envelope with cursor marker and injection defense
    • Fix XML tag parsing whitespace variants; providers updated
  • Fix grammar/edge cases and add tests

    • Negative caret treated as "no cursor", not document start
    • Hotword budget prioritization and selection polish asr_transcript
    • Extensive unit tests for diff and prompt composition

Diagram Walkthrough

flowchart LR
  A["User dictates"] --> B["ASR / polish"]
  C["Host app text"] --> D["Cursor context capture (AX)"]
  D --> E["Polish prompts with cursor envelope"]
  F["User manual correction"] --> G["Edit watch + minimal diff"]
  G --> H["Vocab suggestion card"]
  H --> I["Dictionary / hotwords"]
  I --> B
Loading

File Walkthrough

Relevant files
Enhancement
5 files
macos.rs
Add macOS Accessibility cursor and edit-watch capture       
+1059/-0
diff.rs
Implement minimal diff algorithm for learning edits           
+704/-0 
polish.rs
Add cursor context parameter and injection defense             
+227/-1 
selection_polish.rs
Pass no cursor context and set ASR transcript for selection polish
+7/-0     
llm_gemini.rs
Thread cursor context through Gemini provider                       
+2/-0     
Tests
1 files
correction.rs
Set RuleSource::Manual in test helper                                       
+1/-0     
Additional files
41 files
README.md +13/-3   
README.zh.md +13/-3   
cursor-context-test-plan.md +186/-0 
whisper.rs +33/-0   
dictionary.rs +18/-0   
history.rs +1/-0     
misc.rs +46/-0   
providers.rs +2/-0     
settings.rs +9/-0     
coordinator.rs +402/-0 
asr_wiring.rs +2/-2     
capsule_focus.rs +9/-77   
dictation.rs +255/-4 
polish_flow.rs +10/-0   
qa_session.rs +2/-0     
mod.rs +574/-0 
window.rs +284/-0 
lib.rs +7/-0     
selection.rs +7/-0     
correction.rs +51/-8   
dictionary.rs +94/-1   
prompt_compose.rs +19/-0   
selection.rs +76/-34 
types.rs +69/-0   
unicode_keystroke.rs +7/-2     
Capsule.tsx +23/-3   
VocabSuggestionCard.tsx +220/-0 
en.ts +21/-0   
ja.ts +21/-0   
ko.ts +21/-0   
zh-CN.ts +21/-0   
zh-TW.ts +21/-0   
index.ts +4/-1     
mock-data.ts +11/-0   
utils.ts +12/-0   
vocab.ts +22/-1   
stylePrefs.test.ts +1/-0     
types.ts +33/-0   
Vocab.tsx +105/-4 
DataStorageSection.tsx +16/-1   
DebugToolsSection.tsx +77/-1   

jisongniu and others added 19 commits August 4, 2026 19:58
…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>
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit f907c97)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 5 🔵🔵🔵🔵🔵
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Possible Issue

FULL_TEXT_MAX_UTF16 is only enforced when AXNumberOfCharacters is readable.
When that attribute is missing (common on Electron apps, as the same file
notes), read_document falls into the arm that reads the full AXValue, so a
very large document is still marshalled across the AX boundary. grab_focused_element
has the same pattern: it checks the size only after reading the full AXValue.
On a document large enough to exceed the messaging timeout, this can stall
the read/watch path for seconds instead of skipping the document, and in the
edit-watch path it also widens the race between dictation ending and the
watcher attaching to the correct focused element.

let total_utf16 = copy_index_attr(focused, b"AXNumberOfCharacters\0");

// 小文档(绝大多数情况):整篇读回来,按 char 精确截窗。
let full_text = match total_utf16 {
    Some(total) if total > FULL_TEXT_MAX_UTF16 => None,
    _ => copy_string_attr(focused, b"AXValue\0"),
};
if let Some(text) = full_text {
    let cursor = utf16_offset_to_char_offset(&text, cursor_utf16);
    return ReadOutcome::Window(window_around_cursor(&text, cursor, budget_chars));
}

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>
@bigsongeth bigsongeth changed the title feat: 光标上下文 + 手改学词,以及别让新词把常用词挤出 ASR 预算 feat: 让词典自己长——手改一次,它就记住(光标上下文 + 手改学词 + 热词预算优先级) Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

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>
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit e284d01

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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>
@bigsongeth

Copy link
Copy Markdown
Contributor Author

成立,已在 a5c2a05 修复 —— 这条命中的是最自然的那个纠错动作,价值很高。

确认的时序

删词   settle(光标移开安静 300ms,或 5 秒兜底)→ 纯删除差异被上报
       → 基线推进到「已删词」状态
敲词   相对新基线只剩一条「空 → 新词」的纯插入
       → minimal_edit 对纯插入一律返回 None

只要用户在删词与敲词之间停顿一下,这次纠正就永远学不进去。

代码和它自己的注释是矛盾的

原处注释写着:

被过滤掉的不推进:那还没有结论。用户可能删掉一个词、跑去别处复制点东西、再回来把新词打完

意图完全正确 —— 但真正的过滤发生在下游 handle_user_editis_vocab_worthytarget 为空就丢弃),观察器看不到那个决定,于是把一次注定被丢弃的改动当成了「已结论」,顺手吃掉了基线。注释描述的是意图,代码实现的是另一回事。

修法

让观察器用下游同一个判据is_vocab_worthy 为假就既不上报、也不动基线。用户把新词敲完时,相对原基线算出来的正是完整的「错词 → 正确词」。

这比只特判纯删除更彻底 —— 任何注定学不到的改动(跨句、超长)都不再吃基线。

顺带少一类噪声日志:纯删除以前每次都打一条 user edit detected,而它从来就不可能变成词条(词条要求 target 非空)。真机日志里这种占了相当一部分。

cargo test --lib 1002 passed。

🤖 Addressed by Claude Code

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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>
@bigsongeth

Copy link
Copy Markdown
Contributor Author

位置属实,已在 23637dd 补上;但推出的后果不成立,一并说明。

属实的部分

解除信号到达时观察线程可能正卡在 CFRunLoop::run_in_mode 里(最长 1 秒),这一秒内排队的 AX 通知照样派发到 value_changed_shim,而循环末尾那道 if !stop.load(..) 覆盖不到回调这条路径。「循环看 stop、回调不看」本身不自洽。

WatchContext 加了 stop 字段,回调开头先看它,这一刀在读 AXValue 之前 —— 不挡的话,一次已经作废的观察还会再去跨进程读一遍宿主 app 的全文,那既是浪费,也是多读了一次本不该再读的东西。

不成立的部分:卡片不会在新会话里弹出

协调方那边已经有两道防线,而且都在 handle_user_edit 之前

  1. 观察器代次 —— 回调闭包的第一行就比对代次(dictation.rsarm_edit_watch),对不上直接 return,连 handle_user_edit 都进不去,更不会排进 pending_correctionsarm_edit_watch / begin_session_as / disarm_edit_watch 三条解除路径统一走 coordinator::disarm_edit_watch,每条都推进代次。
  2. 听写不在 Idle 就不弹卡片 —— 判据下沉在 show_vocab_suggestion_card 的主线程闭包里,是碰窗口之前的最后一个时点。

这两道正是前几轮针对同一失败模式加的。新加的 stop 检查是第三道,也是最早、最便宜的一道,但不是唯一防线。

cargo test --lib 1002 passed。

🤖 Addressed by Claude Code

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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>
@bigsongeth

Copy link
Copy Markdown
Contributor Author

成立,已在 d3f9d3b 修复。

CoreFoundation 的 Booleanunsigned char,不是 C 的 _Bool;Rust 的 bool 要求位模式恰好是 0 或 1,其余一律 UB。拿它接一个 unsigned char 等于赌 CF 永远只返回 0/1 —— 实践中确实如此,但那是赌,不是保证。

更难堪的是同一个文件里口径就不一致AXValueGetValue 早就是 u8 并用 != 0 比较,只有这两个是 bool。它们是当初照抄 selection.rs 抄进来的,而本模块开头第三条硬约束写的正是:

不在 tokio worker 上同步调 AX …(同段)新代码不得复制既有缺陷,哪怕仓库里的旧 AX 代码就是这么写的

当时那句说的是 AX 超时,这里我自己破了同一条例。

改成 u8 + 与 0 比较,本文件三处 CF/AX 布尔返回值口径统一。

selection.rs 那份保持不动:不在本 PR 范围内,改它要重新验证选区润色整条链路,风险与收益不匹配。它属于模块文档点名的那类既有缺陷,适合单独开 issue —— 如果 maintainer 觉得该在本 PR 里一并处理,我可以加上。

cargo test --lib 1002 passed。

🤖 Addressed by Claude Code

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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>
@bigsongeth

Copy link
Copy Markdown
Contributor Author

成立,已在 feac7a4 修复。而且这条指出的其实是一个比竞态更根本的设计错误 —— 值得说清楚。

竞态本身

bundle_id 由调用方在取焦点元素之前采样(current_front_app_parts),而每个 AX 调用都可能阻塞到 AX_MESSAGING_TIMEOUT_SECS

采样 bundle = 备忘录(放行)
...AX 调用阻塞中,用户切到终端...
AXFocusedUIElement 返回的是终端的元素
闸门用「备忘录」的身份放行 → 读走终端的文本

终端、密码管理器正是靠 bundle 黑名单拦的,这条路径把它们漏了。

但只当竞态修就治标了

真正的问题是判据选错了对象:闸门要判的是「手里这个元素属于哪个 app」,而前台 app 只是它的一个近似 —— 两者本来就可能不一致(焦点元素的归属与 frontmost 不是同一件事),竞态只是让这个近似失效得更明显。

所以没有去做「再采样一次、比对是否变化」那种补丁,而是换判据:拿元素自己的 pid 换 bundle(新增 selection::bundle_id_for_pid,走 NSRunningApplication.runningApplicationWithProcessIdentifier:),在第二道闸门里覆盖掉第一道用的前台 app 值。

第一道保留 —— 它不需要任何 AX 调用,命中就一条消息都不发,作为省事的粗筛仍然有价值,只是不再是最终判据。

secure_input 也顺手在第二道刷新一次:它是全局状态,同样可能在这几次 AX 调用期间才被打开。

两条路径(上下文读取、手改观察)都从这一个入口拿元素,所以一起修好 —— 这正是上一轮把「取元素」和「过闸门」焊死成单一入口的收益。

cargo test --lib 1002 passed。

🤖 Addressed by Claude Code

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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>
@bigsongeth

Copy link
Copy Markdown
Contributor Author

成立。已在 45d599b 把代价写进文档,但这一版不收紧行为 —— 理由如下,供 maintainer 判断。

确认的问题

pending_since 每次文本变化都重置,用户改完词不停手继续写的话,判定一直往后推。等他终于停下来,比对的是「原基线 vs 最终文本」:

基线    我们用扣德克斯写代码
最终    我们用 Codex 写代码,然后还要接着写很多别的
差异    扣德克斯写代码 → Codex 写代码,然后还要接着写很多别的

要么超长/跨句被拒(这次纠正白做),要么变成一条被污染的建议。

这跟本 PR 里已经修掉的「改完按回车撑成整句」是同一个根minimal_edit 只能表达一处连续差异,用户做两处改动时中间的字必然被卷进来。

为什么不在这一版收紧

两个方向都会退化掉更重要的东西:

  • 给窗口加硬顶pending_since 只在为 None 时设置)会重新开始抓到「单个词改到一半」的中间态 —— 那正是 EDIT_SETTLE_TIMEOUT 从 1 秒放宽到 5 秒要躲开的。「什么时候算打完字」这个判定花了好几轮真机才调对(定时器 → 光标语义 → 配对通知门槛),不该在没有真机数据的情况下再动它。
  • 换成能识别多处改动的差异算法(LCS 之类)才是真正的解法,但那是独立一件事,而且必须有真机数据验证它没把已调好的判定搞坏。本 PR 已经 20 个 commit,再塞一次算法重写只会让它更难评审。

现状的代价是可接受的

这条路径上的建议要么没有、要么偏长;而每条建议都要用户在卡片上点勾才入库(本 PR 已取消全部自动入库)。所以代价是「漏学一次」或「多看一眼再点叉」,不是静默写错

代价已写进 EDIT_SETTLE_TIMEOUT 的文档注释,连同两个方向各自会退化什么 —— 下一个动这块的人能直接看到权衡,不用重新踩一遍。

如果 maintainer 认为该在本 PR 内一并解决,我可以接着做算法替换,但建议单独一个 PR。

🤖 Addressed by Claude Code

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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>
@bigsongeth

Copy link
Copy Markdown
Contributor Author

成立,已在 de608c6 修复。

同一处 (source, target) 在一次观察窗口里出现两次是常事 —— 听错的专名在好几句里都出现,用户逐个改过去。第二次被 reported 去重挡掉时函数直接 return,基线没推进:基线停在「只改了第一处」,而文档已经改了两处。之后任何一次新纠正,minimal_edit 都是拿这个陈旧基线去比,算出来的 span 把「已有结论的那处重复改动」和「新改动」搅在一起 —— 多半过不了 edit_is_within_typed_text,新纠正被静默丢掉;万一过了,建议也是被污染的。

把基线推进挪到去重之前。去重管的是「别重复上报」,不是「这处改动没发生」。

现在这三处 return 的语义能用一句话说清 —— 有结论就推进基线,无论这个结论是不是新的

位置 推进基线? 为什么
不是我们插的文字 还没有结论,这处跟本次听写无关
注定成不了词条(is_vocab_worthy 为假) 还没有结论 —— 用户可能刚删完词、正准备把新词打上去
与已上报的重复 已有结论,只是不必再报一次

第二行是本 PR 前一轮修「删掉重打学不到」时加的;这一轮补上第三行,两者放在一起看语义才闭合。

cargo test --lib 1002 passed。

🤖 Addressed by Claude Code

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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>
@bigsongeth

Copy link
Copy Markdown
Contributor Author

成立,已在 f907c97 修复 —— 而且这是上一轮那个修复自己留下的口子,性质比原问题更糟:代码看着已经防住了。

上一轮把闸门改成用元素自己的 pid 换 bundle,但只在成功时覆盖:

if 取到 pid && 查到 bundle {
    gate.bundle_id = Some(owner);   // 成功才覆盖
}
// 失败时保持采样时那个前台 app 的值 → 照样放行

取不到归属时,闸门退回按「谁在最前面」判定 —— 正是上一轮要修的那个漏洞原样还在。

改成失败关闭,返回 Unavailable

一个不能走的捷径

不能简单地把 bundle_id 清成 Noneevaluate_gate 对缺失的元数据是放行的(missing_metadata_does_not_block_by_itself 钉着这个行为),那只是换了一种 fail-open。所以必须显式返回,不能靠清字段。

这跟那条测试的设计意图并不冲突 —— 它的注释写着「读不到 bundle / role 时……读不到文档自然会走 Unavailable」。本次改动正是把那个「自然会」在这条路径上变成显式的:之前它在这里并不成立,因为路径上还留着一个可用的陈旧值。

代价

没有 bundle id 的进程读不到上下文。那类进程本来就很少,而「宁可不读」是这个功能对隐私的基本承诺 —— 与模块开头「密码框 / Secure Input / 密码管理器 / 终端一律不读」同一条原则。

这段逻辑在 AX 包装层,脱离真机无法单测;evaluate_gate 自身的行为未变,其单测全部照旧通过。

cargo test --lib 1002 passed。

🤖 Addressed by Claude Code

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit f907c97

bigsongeth and others added 2 commits August 6, 2026 05:13
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant