weflow-cli 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +2108 -2
  3. package/OPERATIONS.md +810 -1
  4. package/README.en.md +96 -10
  5. package/README.md +34 -9
  6. package/bin/weflow-cli.ts +937 -28
  7. package/dist/bin/weflow-cli.js +955 -28
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.js +39 -13
  10. package/dist/mcp-server/index.js.map +1 -1
  11. package/dist/src/core/appMsgFormat.d.ts +37 -0
  12. package/dist/src/core/appMsgFormat.d.ts.map +1 -0
  13. package/dist/src/core/appMsgFormat.js +106 -0
  14. package/dist/src/core/appMsgFormat.js.map +1 -0
  15. package/dist/src/core/ntCore.d.ts +19 -2
  16. package/dist/src/core/ntCore.d.ts.map +1 -1
  17. package/dist/src/core/ntCore.js +11 -1
  18. package/dist/src/core/ntCore.js.map +1 -1
  19. package/dist/src/core/sqlcipherCore.d.ts +0 -12
  20. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  21. package/dist/src/core/sqlcipherCore.js +5 -64
  22. package/dist/src/core/sqlcipherCore.js.map +1 -1
  23. package/dist/src/core/wcdbCore.d.ts +1 -1
  24. package/dist/src/core/wcdbCore.d.ts.map +1 -1
  25. package/dist/src/core/wcdbCore.js +5 -1
  26. package/dist/src/core/wcdbCore.js.map +1 -1
  27. package/dist/src/panel/client.d.ts +25 -0
  28. package/dist/src/panel/client.d.ts.map +1 -0
  29. package/dist/src/panel/client.js +34 -0
  30. package/dist/src/panel/client.js.map +1 -0
  31. package/dist/src/panel/endpoint.d.ts +45 -0
  32. package/dist/src/panel/endpoint.d.ts.map +1 -0
  33. package/dist/src/panel/endpoint.js +132 -0
  34. package/dist/src/panel/endpoint.js.map +1 -0
  35. package/dist/src/panel/launch.d.ts +50 -0
  36. package/dist/src/panel/launch.d.ts.map +1 -0
  37. package/dist/src/panel/launch.js +74 -0
  38. package/dist/src/panel/launch.js.map +1 -0
  39. package/dist/src/panel/server.d.ts +23 -0
  40. package/dist/src/panel/server.d.ts.map +1 -0
  41. package/dist/src/panel/server.js +413 -0
  42. package/dist/src/panel/server.js.map +1 -0
  43. package/dist/src/panel/userId.d.ts +37 -0
  44. package/dist/src/panel/userId.d.ts.map +1 -0
  45. package/dist/src/panel/userId.js +56 -0
  46. package/dist/src/panel/userId.js.map +1 -0
  47. package/dist/src/services/assistantDaemon.d.ts +20 -3
  48. package/dist/src/services/assistantDaemon.d.ts.map +1 -1
  49. package/dist/src/services/assistantDaemon.js +46 -9
  50. package/dist/src/services/assistantDaemon.js.map +1 -1
  51. package/dist/src/services/assistantEval.d.ts +141 -0
  52. package/dist/src/services/assistantEval.d.ts.map +1 -0
  53. package/dist/src/services/assistantEval.js +497 -0
  54. package/dist/src/services/assistantEval.js.map +1 -0
  55. package/dist/src/services/assistantMemory.d.ts +106 -4
  56. package/dist/src/services/assistantMemory.d.ts.map +1 -1
  57. package/dist/src/services/assistantMemory.js +396 -61
  58. package/dist/src/services/assistantMemory.js.map +1 -1
  59. package/dist/src/services/assistantPrivacy.d.ts +15 -2
  60. package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
  61. package/dist/src/services/assistantPrivacy.js +25 -6
  62. package/dist/src/services/assistantPrivacy.js.map +1 -1
  63. package/dist/src/services/assistantRouter.d.ts +41 -0
  64. package/dist/src/services/assistantRouter.d.ts.map +1 -0
  65. package/dist/src/services/assistantRouter.js +151 -0
  66. package/dist/src/services/assistantRouter.js.map +1 -0
  67. package/dist/src/services/assistantService.d.ts +209 -3
  68. package/dist/src/services/assistantService.d.ts.map +1 -1
  69. package/dist/src/services/assistantService.js +599 -75
  70. package/dist/src/services/assistantService.js.map +1 -1
  71. package/dist/src/services/assistantTools.d.ts +217 -1
  72. package/dist/src/services/assistantTools.d.ts.map +1 -1
  73. package/dist/src/services/assistantTools.js +1116 -59
  74. package/dist/src/services/assistantTools.js.map +1 -1
  75. package/dist/src/services/assistantTrace.d.ts +47 -0
  76. package/dist/src/services/assistantTrace.d.ts.map +1 -0
  77. package/dist/src/services/assistantTrace.js +150 -0
  78. package/dist/src/services/assistantTrace.js.map +1 -0
  79. package/dist/src/services/chatService.d.ts +1 -1
  80. package/dist/src/services/chatService.d.ts.map +1 -1
  81. package/dist/src/services/chatService.js +5 -2
  82. package/dist/src/services/chatService.js.map +1 -1
  83. package/dist/src/services/configService.d.ts +9 -0
  84. package/dist/src/services/configService.d.ts.map +1 -1
  85. package/dist/src/services/configService.js +15 -8
  86. package/dist/src/services/configService.js.map +1 -1
  87. package/dist/src/services/pythonBridge.d.ts +41 -0
  88. package/dist/src/services/pythonBridge.d.ts.map +1 -0
  89. package/dist/src/services/pythonBridge.js +119 -0
  90. package/dist/src/services/pythonBridge.js.map +1 -0
  91. package/dist/src/services/syncService.d.ts +8 -2
  92. package/dist/src/services/syncService.d.ts.map +1 -1
  93. package/dist/src/services/syncService.js +26 -7
  94. package/dist/src/services/syncService.js.map +1 -1
  95. package/dist/src/services/wechatMessageService.d.ts +25 -0
  96. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  97. package/dist/src/services/wechatMessageService.js +69 -7
  98. package/dist/src/services/wechatMessageService.js.map +1 -1
  99. package/dist/src/services/wereadService.d.ts +33 -6
  100. package/dist/src/services/wereadService.d.ts.map +1 -1
  101. package/dist/src/services/wereadService.js +27 -2
  102. package/dist/src/services/wereadService.js.map +1 -1
  103. package/dist/src/utils/atomicWrite.d.ts +4 -0
  104. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  105. package/dist/src/utils/atomicWrite.js +60 -0
  106. package/dist/src/utils/atomicWrite.js.map +1 -0
  107. package/dist/src/utils/dateRange.d.ts +15 -0
  108. package/dist/src/utils/dateRange.d.ts.map +1 -1
  109. package/dist/src/utils/dateRange.js +47 -0
  110. package/dist/src/utils/dateRange.js.map +1 -1
  111. package/dist/src/utils/text.d.ts +18 -0
  112. package/dist/src/utils/text.d.ts.map +1 -0
  113. package/dist/src/utils/text.js +20 -0
  114. package/dist/src/utils/text.js.map +1 -0
  115. package/docs/AI_INTERFACE.md +2 -0
  116. package/docs/BRANCHES.md +13 -4
  117. package/docs/DECISIONS.md +1424 -0
  118. package/docs/EXTENDING.md +187 -0
  119. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  120. package/docs/MCP.md +23 -4
  121. package/docs/PROJECT_STATE.md +210 -9
  122. package/docs/SETUP.md +22 -0
  123. package/docs/SYNC_CONTRACT.md +31 -3
  124. package/mcp-server/index.ts +681 -657
  125. package/package.json +84 -83
  126. package/resources/panel/ball-position.cjs +153 -0
  127. package/resources/panel/index.html +46 -0
  128. package/resources/panel/main.cjs +483 -0
  129. package/resources/panel/mascot-happy.png +0 -0
  130. package/resources/panel/mascot-sorry.png +0 -0
  131. package/resources/panel/mascot-thinking.png +0 -0
  132. package/resources/panel/mascot-tired.png +0 -0
  133. package/resources/panel/mascot.png +0 -0
  134. package/resources/panel/package.json +7 -0
  135. package/resources/panel/panel.css +415 -0
  136. package/resources/panel/preload.cjs +79 -0
  137. package/resources/panel/quick-menu.cjs +197 -0
  138. package/resources/panel/renderer.js +470 -0
  139. package/resources/panel/tray-menu.cjs +40 -0
  140. package/resources/panel/tray.png +0 -0
  141. package/scripts/_utils.py +425 -54
  142. package/scripts/annual_report.py +4 -2
  143. package/scripts/article_notes.py +423 -0
  144. package/scripts/backfill_articles.py +438 -0
  145. package/scripts/biz_daily.py +590 -105
  146. package/scripts/chat_notes.py +470 -0
  147. package/scripts/classify_daily.py +10 -18
  148. package/scripts/compile_wiki.py +1060 -46
  149. package/scripts/create_reading_notes.py +199 -14
  150. package/scripts/decide.py +253 -0
  151. package/scripts/draft_reply.py +671 -0
  152. package/scripts/export_chat_html.py +35 -33
  153. package/scripts/extract_todos.py +31 -5
  154. package/scripts/fav_notes.py +318 -0
  155. package/scripts/fix_topics.py +2 -1
  156. package/scripts/generate_ai_report.py +214 -9
  157. package/scripts/generate_html.py +18 -3
  158. package/scripts/generate_review.py +2 -2
  159. package/scripts/jev_client.py +294 -0
  160. package/scripts/jev_probe.py +440 -0
  161. package/scripts/nt_common.py +82 -0
  162. package/scripts/nt_decrypt.py +1755 -1460
  163. package/scripts/pipeline.py +27 -2
  164. package/scripts/promote_ideas.py +19 -4
  165. package/scripts/quality_eval.py +707 -0
  166. package/scripts/rag_chat.py +4 -4
  167. package/scripts/read_image.py +187 -0
  168. package/scripts/reply_debt.py +488 -0
  169. package/scripts/route_cards.py +453 -0
  170. package/scripts/semantic_search.py +208 -49
  171. package/scripts/user_notes.py +315 -0
  172. package/scripts/vault_rag.py +13 -7
  173. package/scripts/vault_search.py +21 -4
  174. package/scripts/wiki_lint.py +429 -0
  175. package/src/core/appMsgFormat.ts +117 -0
  176. package/src/core/ntCore.ts +27 -2
  177. package/src/core/sqlcipherCore.ts +5 -61
  178. package/src/core/wcdbCore.ts +6 -2
  179. package/src/panel/client.ts +49 -0
  180. package/src/panel/endpoint.ts +144 -0
  181. package/src/panel/launch.ts +101 -0
  182. package/src/panel/server.ts +424 -0
  183. package/src/panel/userId.ts +72 -0
  184. package/src/services/assistantDaemon.ts +57 -9
  185. package/src/services/assistantEval.ts +611 -0
  186. package/src/services/assistantMemory.ts +413 -57
  187. package/src/services/assistantPrivacy.ts +25 -6
  188. package/src/services/assistantRouter.ts +191 -0
  189. package/src/services/assistantService.ts +681 -74
  190. package/src/services/assistantTools.ts +1149 -58
  191. package/src/services/assistantTrace.ts +175 -0
  192. package/src/services/chatService.ts +6 -2
  193. package/src/services/configService.ts +403 -385
  194. package/src/services/pythonBridge.ts +139 -0
  195. package/src/services/syncService.ts +37 -9
  196. package/src/services/wechatMessageService.ts +73 -7
  197. package/src/services/wereadService.ts +43 -7
  198. package/src/utils/atomicWrite.ts +56 -0
  199. package/src/utils/dateRange.ts +46 -0
  200. package/src/utils/text.ts +20 -0
package/CHANGELOG.md CHANGED
@@ -4,29 +4,2135 @@ The npm package is published separately from GitHub. It may lag behind the `mast
4
4
 
5
5
  All notable user-facing changes are recorded here. This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## 1.8.1
8
+
9
+ ### Changed
10
+
11
+ - **The floating ball's "busy" face is now a thinking face, not a contemplative one.** `busy` is
12
+ exactly "a request went out and we are waiting" (`renderer.js`'s `ask()` sets it on entry), so the
13
+ face is drawn as thought: eyes glancing up-left, level brows, a flat mouth line, a paw under the
14
+ chin. The file was renamed `mascot-focus.png` -> `mascot-thinking.png` rather than overwritten -
15
+ a file named `focus` holding a thinking face is the next misreading - and all four places that
16
+ name it were updated together (CSS, the static whitelist, the packaging list, the state-to-file
17
+ assertion). The two reconciliation tests in `panel-packaging.test.ts` went red when the wiring
18
+ landed before the image did, which is how we know they actually watch that gap.
19
+
20
+ It is generated with `gpt-image-2-ca` through `/images/edits`, using the existing mascot as the
21
+ base so only the expression changes - a text-only redraw takes the style and framing with it. The
22
+ first prompt asked for "thinking" and produced **worry** (downturned mouth, lowered brows), which
23
+ collided with the offline face; the second spells out "level brows, flat mouth line, not sad, not
24
+ worried". Geometry is checked rather than eyeballed, because the ball clips to a circle: content
25
+ box 171x212 (the existing faces are 170x214), farthest solid pixel 125.2 against a 125.4 limit,
26
+ and **0 solid pixels outside the inscribed circle**.
27
+
28
+ ### Fixed
29
+
30
+ - **The panel ball went blank after the assistant restarted, and it now repairs itself.** The
31
+ daemon generates a fresh token on every start, and the window's cookie is planted when it opens -
32
+ so a window that outlives a restart holds dead credentials. Every `/panel/*` request then answers
33
+ 401, and because those responses carry `Cache-Control: no-store` the ball's face (`panel.css`
34
+ loads it as a background image) fails to load: **a blank ball, with the window otherwise looking
35
+ normal**. Measured: the window had been open 25 hours against a daemon 25 hours younger, which is
36
+ how this was found - the user reported "my agent icon is gone".
37
+
38
+ The window can now recover by itself. Its status poll already distinguishes the case, and on
39
+ `UNAUTHORIZED` it asks the main process to re-read the endpoint file, refresh the cookie and
40
+ reload. The credentials still never reach the renderer - the main process does the reading, which
41
+ is where they belong; the preload's exposed surface grows by exactly one method and
42
+ `test/panel-packaging.test.ts` pins that list, so widening it stays a deliberate act. Two guards
43
+ keep the cure from being worse than the disease: **the reload only happens when the file's token
44
+ actually differs** from the cookie (otherwise an unrelated 401 would reload forever), and there is
45
+ a five-second floor plus a three-attempt cap in the page, because **a reload storm is harder to
46
+ diagnose than a blank ball**. Verified end to end by restarting the daemon and watching the
47
+ window reload on its own: the log's load counter went 73 → 74 with nobody touching the window.
48
+
49
+ - **The assistant denied that unconfigured capabilities existed.** A tool whose prerequisite key is
50
+ missing is deliberately left out of the tool table - a tool that cannot run makes the model try it
51
+ and apologise with errors in the answer. But the model then has no way to know it exists, so
52
+ asking "what am I reading" produced **"I don't have a get_weread tool"**. That is true about the
53
+ tool table and false about the product: the feature was built, it just was not configured, and the
54
+ user reasonably concluded it had never been built. The system prompt now carries a
55
+ `[本机没启用的能力]` line naming each missing prerequisite and telling the model to answer "this
56
+ part is not configured" with the setting to change, **not** "I don't have that tool". The tools
57
+ stay unavailable - this only stops the model from denying the feature exists. Verified by asking:
58
+ the answer is now "can't do it, the only reason is dashscopeApiKey isn't configured".
59
+
60
+ - **`get_reading_stats`'s description claimed a question it cannot answer.** It said to use it for
61
+ "what have I been reading lately" - which is exactly the WeRead question, so with `get_weread`
62
+ hidden the model reached for this tool and returned public-account push counts ("the most active
63
+ account is 红星新闻"). The tool was right; the sentence had taken over another capability's
64
+ question space. It now says what it covers (public-account pushes and daily-report processing) and
65
+ that it carries no reading data.
66
+
67
+ - **`weflow-cli weread ...` read the key from a different place than the assistant did, so
68
+ configuring it satisfied one and not the other.** The CLI read `WEREAD_API_KEY` from the
69
+ environment (which is what WeRead's own setup page tells you to export); the assistant's
70
+ `get_weread` availability check reads the **config key** `wereadApiKey`. Configure the config key -
71
+ as the CLI's own error message invites you to - and `weread shelf` still answers "not configured".
72
+ Both are accepted now, environment variable first because the official guidance is not to hand the
73
+ key to an AI. The resolution is a pure exported function with a test, rather than a line inside the
74
+ CLI's closure where the last change to it could not be tested.
75
+
76
+ The same session turned up two smaller things. **A tool hidden for a missing key is invisible to
77
+ the model, so the user gets "I don't have that tool"** - which is what happened here: the answer
78
+ said the capability did not exist when it did, unconfigured. That is now written into
79
+ `OPERATIONS.md` next to the filtering rule it belongs to, because "no key" and "no such feature"
80
+ read the same from the outside. And **`wereadService`'s header pointed at
81
+ `~/.claude/skills/weread-skills/`, which does not exist on this machine** (checked, 2026-09-28);
82
+ it now points at `https://weread.qq.com/r/weread-skills`, the page that actually issues the key -
83
+ the key's origin had been recorded nowhere in the repo, which is why finding it took reading a
84
+ stored article from May. `OPERATIONS.md` gained the setup steps, including that **`configService`
85
+ reads the file once at construction, so a running assistant needs a restart** to see a new key.
86
+
87
+ - **Fixing the CLI was not enough, and only an end-to-end run showed that.** With the key configured
88
+ and the assistant restarted, asking "what am I reading" still produced "WEREAD_API_KEY is not set".
89
+ The assistant tool was reading the secret a **third** way: its availability check loads the config
90
+ key - which is why the tool appeared in the table at all - and then calls the module singleton
91
+ `new WereadService()`, whose constructor read only the environment variable. A tool that is visible
92
+ but cannot run is the worst of both designs. The no-argument constructor now resolves both sources
93
+ through the same `resolveWereadApiKey`, which fixes every user of the singleton at once.
94
+
95
+ - **`get_weread`'s notebooks mode printed `undefined` for every book title.** `/user/notebooks` nests
96
+ the title and author inside `item.book`; the item itself carries only `bookId`, `noteCount`,
97
+ `bookmarkCount` and friends. The code read `item.title`, so the output was still well-formed - it
98
+ just had no names in it, which is why nothing failed. Verified against the gateway's real response
99
+ and pinned with a test that stubs **that** shape: the previous test for this branch stubbed an
100
+ empty list, so it could not have caught it.
101
+
102
+ All three of these were found in one session by configuring the key and then actually asking the
103
+ assistant a question, not by reading the code. Two of the three were invisible from any single
104
+ file: the secret had three readers and they disagreed.
105
+
106
+ - **`wiki lint`'s duplicate-title report was rebuilt rather than re-tuned, and the two tiers now
107
+ differ by an order of magnitude in how much you should trust them.** The old rule - a common
108
+ substring of ≥5 characters covering ≥40% of the shorter title - was reasonable at 5,062 pages of
109
+ long Chinese headlines and had become useless at 22,374: it reported **424,697 groups**, starting
110
+ with coincidences like `AI Agent开发框架` against `生物信息学LLM Agent综述` (both contain "Agent").
111
+ A 20-pair sample showed the rule firing correctly on every pair, which is the point: the threshold
112
+ had stopped discriminating, so the implementation was never the problem.
113
+
114
+ Two directions were measured and rejected before settling. **Keeping the long-common-substring
115
+ rule** fails because `Claude Code 上下文窗口` and `Claude Code 联网能力` share six characters and are
116
+ nonetheless different concepts - sharing a product name is not the same as being the same thing,
117
+ and that class dominated the false positives, so no ratio value separates them. **Containment
118
+ without a guard** fails because `DeepSeek` is swallowed by `DeepSeek 融资`, `DeepSeek-V4 涨价` and so
119
+ on; that is a *hub*, not a duplicate (measured: `agent` is contained in 731 names, `模型` in 643).
120
+
121
+ What it reports now: **314 groups that differ only in spacing, hyphens, case or an English plural**
122
+ (`GLM 5.1` / `GLM-5.1`, `AI 编程` / `AI编程`), scored by hand at 24 of 24 being the same node - and
123
+ these use `_utils.normalize_concept_name`, the **same function** `wiki compile --merge-duplicates`
124
+ uses, so the list is directly executable. Measured on the real vault, every group the lint reports
125
+ is one that tool would merge (the extra 14 groups it would act on are same-filename collisions,
126
+ which the lint reports separately). Plus **379 groups where one name is the other plus a qualifier**,
127
+ explicitly labelled a lead rather than a conclusion because hand-sampling puts its precision near
128
+ half - the other half being "hub vs its own subtopic" (`国家自然科学基金` /
129
+ `国家自然科学基金申请书`), which should not be merged.
130
+
131
+ The normaliser moved to `_utils` so the two readers cannot drift apart, and a test now pins the
132
+ **function object** rather than "two copies that look alike". The old caveat that the rule could not
133
+ catch the case it was written for still stands and is still written down: `打虎!陈勇被查` and
134
+ `中建集团副总经理陈勇被查` are the same event with different headlines and share only four characters,
135
+ so no substring rule reaches them - the tier-1 rule replaces "wrong and noisy" with "narrow and
136
+ right", not with "complete".
137
+
138
+ Two silent-failure traps were caught by mutation testing rather than by reading. **The bucketing
139
+ width must stay at or below the shortest shared substring the rule can accept**, or the report
140
+ quietly loses pairs; the first version of the equivalence test read the module constant for both, so
141
+ changing the bucketing width moved the reference implementation with it and the test stayed green -
142
+ it now uses literal contract values. And **the hub filter's count is computed from the same 8-gram
143
+ index** rather than an O(n²) scan, which keeps the whole pass at 0.1s.
144
+
145
+ - **The two tiers compare within a directory, not across the whole vault.** The two concept
146
+ directories are two knowledge bases and `--merge-duplicates` runs per directory, so a group spanning
147
+ them can never be acted on by the tool the report points at. The first version compared across both
148
+ and, after the merge, was still listing `AI 工具` and `GLORIA` as "groups that tool will merge" -
149
+ which was simply false. Cross-line same names are a separate report (`duplicateTitles`).
150
+
151
+ - **`--merge-duplicates` was silently dropping card names from the surviving page's `sources:`.** It
152
+ carried that key over from the main page only, so every merged group lost the absorbed page's
153
+ entries - measured on the real vault, **all 315 groups** lost at least one. The body's source lines
154
+ were always correct (those were unioned); only the frontmatter side was short, and that key is what
155
+ `--relabel` feeds to `source_kinds_for`, so a page could lose a `来源/文章|聊天|收藏` tag. It now
156
+ unions. The 315 pages were repaired from a pre-merge backup, and the `output/` vault is not under
157
+ version control, which is why that backup existed. **The loss turned out to have changed no tag**:
158
+ 0 of 22,033 pages carry sources from more than one line, so this was a latent defect rather than a
159
+ live one - recorded that way rather than as a rescue.
160
+
161
+ - **Merging was run on the 314 same-node groups**: 341 pages absorbed (338 article-line, 3 chat-line)
162
+ out of 22,374, leaving 0 groups in that tier. `wiki lint` still reports **0 broken links** - the
163
+ `aliases` written by the merge are what make that true, and it is why no other file had to change.
164
+
165
+ ## 1.8.0
166
+
167
+ ### Added
168
+
169
+ - **The assistant can walk the knowledge graph more than one hop at a time.** `search_knowledge` used to return
170
+ the page plus its **direct** neighbours, and the tool description told the model it could "walk them one at a
171
+ time" - which meant deciding, on its own, which neighbour to look up next. It now takes a `depth` (default 1,
172
+ max 3) and returns the layers: from `RAG`, one hop is `向量化模型` / `LangChain` / `大语言模型` /
173
+ `上下文窗口`, and two hops reaches `向量数据库` / `Milvus` / `语义搜索`. Measured on the real vault: `RAG` has 4
174
+ one-hop neighbours and the second layer is reachable only through them.
175
+
176
+ Three rules, each because the obvious version misbehaves: **deduplicate** (a concept reachable by several paths
177
+ keeps only the first arrival, since BFS guarantees that is the shortest - otherwise the same name shows up at
178
+ several depths), **cap the nodes** (4 neighbours × 4 × 4 is 64; an uncapped third hop drowns the output, and a
179
+ graph the model cannot finish reading is worthless - and when it truncates it **says so**), and keep counting
180
+ neighbours that have no page yet rather than swallowing them.
181
+
182
+ Pinned by a scenario that asserts the **arguments**, not the tool name: `argsMatch: /depth=2/` - because
183
+ "called `search_knowledge`" and "called it with two hops" are different things. That is what the field is for,
184
+ and its format is `key=value` (the existing `time-window` uses `/since=/`), not JSON - my first attempt wrote
185
+ `/"depth"\s*:\s*2/` and the scenario duly failed while the assistant was doing exactly the right thing. The
186
+ soft assertion deliberately does not name a specific concept: asking about `RAG` actually matches `2-Step RAG`
187
+ (the name match takes the first hit), so what the second hop returns is not predictable - it checks that the
188
+ layers were laid out at all. Full evaluation **23/23**.
189
+
190
+ - **The chat cards are now in the Vault, under `Sources/Chat/`, and the assistant can search them.** They were only
191
+ in `output/chat-notes/`, which meant a person browsing Obsidian could see the chat *concepts* but never the
192
+ conversation that produced them (the timeline, the people, what is owed) - and, measurably, **1,176 of the chat
193
+ concept pages' "source" links pointed at nothing** plus **30 pointed at the page itself**. Both numbers are now
194
+ zero (1,822 resolve).
195
+
196
+ **The cards had to be renamed on the way in**: a chat card is named after its conversation (`白马非马`,
197
+ `老表亲戚群`), and a concept page can carry the same name. Two files with the same stem make `[[白马非马]]`
198
+ ambiguous in Obsidian, which would have broken links that currently work - so the Vault copies carry a
199
+ `会话-` prefix, and both the copy step and the link fixer read that prefix **from one shared constant**
200
+ (`_utils.CHAT_CARD_PREFIX`), because two copies of it is exactly the "same fact written twice" failure this repo
201
+ keeps hitting. `compile_wiki --fix-source-links` rewrote 1,825 links across 1,520 pages; `vault_search
202
+ --type note` now reads `Sources/Chat` too, so "what did we talk about in that group" reaches the cards.
203
+
204
+ - **The chat line is now part of the knowledge graph, and it arrives as a *people* graph.** `chat-notes` over a
205
+ 400-day window produced **132 conversation cards** (one call per conversation) and `wiki compile` turned them
206
+ into **1,519 concept pages** - `Wiki/Concepts` went from 2,115 to **3,634 pages**, and the graph from 4,162 to
207
+ **5,538 concept-to-concept edges**. The chat corpus holds **572 distinct people** and **992 topics**, and only 4
208
+ concepts overlap with the article line - a conversation is a genuinely different knowledge space from a feed of
209
+ articles, and its natural nodes are people (龙老师 ×31, 耿哥 ×21, 闫学昊师兄 ×17, 董老师 ×15 …). The pages carry
210
+ `来源/聊天`, which the graph view already colours separately from `来源/文章`, so the two sources are one graph
211
+ and still tellable apart.
212
+
213
+ **The first attempt at this was wrong in a way worth recording: it read 19% of the text and said nothing.**
214
+ `NOTE_MESSAGES = 120` was a per-conversation cap, so across 138 conversations holding 80,246 messages only
215
+ **9,010** were ever sent - and `--days 400` did almost nothing, because fetching the newest 120 messages and then
216
+ filtering by date can only ever pass messages inside the window. That version produced 254 people; the corrected
217
+ one produces 572. Fixing it meant **paging through the whole conversation** (`get_messages` takes an `offset`),
218
+ against the measured ceiling: the user's requirement was that **one group chat stays in one context** (splitting
219
+ would hide cause and effect), so the question was whether it fits - and the API's own error answered it:
220
+ `maximum context length is 1048576 tokens. However, you requested 1052622`. Two fixes then: the per-card output
221
+ cap had to go from 2,000 to **16,000** tokens (34 conversations were failing with `finish_reason=length`, JSON
222
+ truncated mid-object, reported only as "the model did not return usable JSON") and the prompt is trimmed from the
223
+ **oldest** end against a character budget when it would not fit. The second run: **132/132, 0 failures.**
224
+
225
+ - **`[[name]]` now carries which section it came from, because dropping it made the model overwrite the material
226
+ with its own prior.** A chat card separates `### 话题` from `### 人`, and aggregation used to treat every link
227
+ identically. Measured consequence: a contact whose nickname is **白马非马** got a page about 公孙龙's classical
228
+ paradox - and the material handed to the model said, verbatim, "对话对方,准备面试和申请博士,学生证在我这里".
229
+ The refs now carry `人物` / `话题` into the reference line (`- [白马非马](人物 · 白马非马 · 聊天):…`) and the
230
+ prompt says explicitly that a `人物` row means a **person** from the user's chats and must not be written as a
231
+ same-named thing, work or allusion. Rebuilt, that page now reads "白马非马是用户聊天记录中的一位联系人,正处
232
+ 最后一学年,准备面试和申请博士", tagged 联系人 / 求职升学 / 私人对话. "Not annotated" and "annotated as a topic"
233
+ stay distinct - an article note has neither section, so it gets neither label.
234
+
235
+ This is a **contract change**: `scan_articles`' `wikilinks` went from `(name, desc)` to `(name, desc, kind)`.
236
+ The four downstream tests that read it as pairs were updated to the new shape rather than loosened.
237
+
238
+ - **`search_knowledge` now returns a concept's neighbours, so the assistant can walk the graph.** The Obsidian
239
+ graph view is a human artefact - nothing on the assistant's side ever read "the graph", and the tool returned a
240
+ single page in isolation. But a concept page already lists its neighbours under `## 相关概念`, and **measured,
241
+ every page is shorter than the tool's 2,000-character slice** (median 730), so that list was already inside the
242
+ returned text - it was simply never pointed at, and the neighbour definitions were never resolved. The tool now
243
+ appends each neighbour that has a page **with its one-line definition**, plus a count of the ones that do not
244
+ (so the model does not chase a name it cannot fetch), and says in its own description that it can be called again
245
+ for any neighbour. One lookup becomes "this concept plus a small subgraph of what it connects to": verified
246
+ against the real vault - `提示工程` returns 上下文学习 / 思维链 / 工具调用 / 智能体, `Claude Code` returns
247
+ Codex / Vibe Coding / 生物信息分析, each with a definition.
248
+
249
+ Two details are pinned by test. **Neighbour names are sanitised before being joined into a path** - they come out
250
+ of file *content*, so `../../etc/passwd` has to stay a filename (same rule as `compile_wiki`; a mutation check
251
+ removing the sanitiser fails the traversal test). And the "no page" case reports a count rather than a name,
252
+ because a name the model cannot fetch is worse than no name.
253
+
254
+ - **`backfill_articles --vault-copy / --vault-sync`: the raw articles reach the Vault's `Sources/` without their
255
+ images.** The daily pipeline copies a whole day into `Sources/WeChat/<date>/` with `copytree`, images included -
256
+ ~200 MB per day, which is why the historical import deliberately skipped it (165 days would have been ~33 GB).
257
+ But skipping it left the Vault **inconsistent in a way only the user could see**: `002_Literature/` held the
258
+ reading notes for all 175 days while `Sources/` held raw material for eleven, so the raw-material layer looked
259
+ stale and nothing in the vault said why. `copy_to_vault()` copies the `.md` files only (~14 KB per article,
260
+ ~24,500 files, ~400 MB for the 165 days) and copies them **file by file rather than replacing the directory** -
261
+ `copytree`'s semantics would have removed whatever else was in that day's folder, and this is the user's Vault.
262
+ `--vault-sync` runs the copy alone, without fetching and without calling a model, because the days were already
263
+ on disk from the earlier import; that is how the gap was closed.
264
+
265
+ The honest note on this one: I had flagged the omission **in conversation only**, and a caveat that lives only in
266
+ chat is not a caveat. Nothing in the vault or the docs recorded that `Sources/` was deliberately incomplete.
267
+
268
+ - **The right-click menu gained a second section: eight one-click functions.** The first section is still the
269
+ `quickReplyContacts` list (draft a reply for that person); below it now sit 谁在等我回话 / 我的待办 / 今日日报 /
270
+ 最近统计 / 阅读统计 / 最近会话 / 朋友圈 / 微信读书. The admission rule for that section is one thing only:
271
+ **the action has to be completable by one fixed sentence**, because a native menu cannot collect input. Anything
272
+ needing a query, a target, or a path (search, export, read-one-message) is therefore excluded rather than shipped
273
+ as an item that does nothing when clicked. All eight map to tools the assistant already has, and each prompt names
274
+ its tool (`用 get_todos …`) - that is a **request to the model, not a hard binding**: the assistant still picks,
275
+ from 19 tools, and a wrong pick is possible; adding a parameter to force it is a separate decision for when that
276
+ actually happens.
277
+
278
+ Three design points, each with a failure it prevents. **The prompt travels with the menu item** rather than the
279
+ page keeping its own id→prompt table: two tables drift, and the drift shows up as "the menu says A, clicking does
280
+ B", silently. A test asserts `renderer.js` contains no action id at all. **The cost line is derived from the
281
+ actions** (`costNote()`), not written down: the hand-written version goes stale the moment a function is added,
282
+ and a stale cost warning is worse than none - it makes people think they were told. It now names exactly which
283
+ items call a cloud model (起草回复 and 谁在等我回话) and says the rest are local reads, so the free ones are
284
+ visibly free. **An empty contact list no longer produces a nearly-empty menu**: the functions and the close item
285
+ are always there, so a user who never configured `quickReplyContacts` still gets a usable menu.
286
+
287
+ The pick payload changed shape (`{kind:'contact'|'action', …}` instead of a bare name), and the page ignores
288
+ anything that is not that shape - during an upgrade the menu and the page are two pieces of code in two processes,
289
+ and a bare name from the old contract would otherwise be dispatched as a *draft nobody asked for*.
290
+
291
+ - **`article-notes --topic` - and the number that makes it worth having.** Concept extraction is the only step in
292
+ this pipeline that costs money per article, so the useful question is not "does it support filtering" but "how
293
+ much does filtering save". Measured rather than estimated, on the 3,498 backfilled articles: news is **57%**, AI
294
+ 28%, academia 13%. Carding only AI therefore removes more than half the spend. The filter is applied **before**
295
+ `--limit`, so `--limit 500 --topic AI` means "500 AI articles", not "the newest 500, of which keep the AI ones" -
296
+ the latter silently under-delivers as you page backwards and looks like the corpus is that small. Both spellings
297
+ of the topic field are accepted because the Vault's existing 1,633 notes use `hasTopic: [[AI]]` while newer
298
+ writers use `topic: AI`; the read path is `compile_wiki.article_topic`, unchanged. The confirmation prompt now
299
+ shows the resolved topic list, because the number a person is agreeing to is the filtered one.
300
+
301
+ **The per-call cost was measured against the account balance, not a price table.** DeepSeek's published pricing
302
+ has at least three mutually inconsistent versions in circulation (¥3/6, $0.14/0.28 and $0.22/0.66 per million for
303
+ the same model), so 120 probe calls were run and the balance delta read: **¥0.22 for 120 calls, ¥0.0018 each,
304
+ ≈¥30 for 16,600 articles**. The balance endpoint resolves in ¥0.01, so that figure carries a few percent of
305
+ rounding error, and it is the account's real spend rather than a rate card's. It also surfaced something no price
306
+ table could: the balance was ¥27.12, i.e. the unfiltered run would have run out of money partway through. Two
307
+ cheaper-looking knobs were measured and rejected: `MAX_ARTICLE_CHARS` (3000) never binds, because a real call
308
+ sends ~1,394 characters, so lowering it saves nothing; and the input is the larger half (803 of the ~945 tokens
309
+ per call), so trimming what is sent is the only lever that could matter - which is what `--topic` does, by sending
310
+ nothing at all for the articles that are not wanted.
311
+
312
+ ### Fixed
313
+
314
+ - **`search_knowledge`'s fallback reported source titles as body text.** When no page *name* matches, the tool
315
+ falls back to "the file contains this word" - and since pages can now list 300+ article titles in their `## 来源`
316
+ section (the 2026-09-27 change), a word appearing in some *source title* was reported as "concept pages whose
317
+ **body** mentions it". Measured with `Grok-4`: 4 pages hit, **3 of them do not contain it anywhere in the body**
318
+ (only `AdsMind` does). The fallback now searches the body only (`pageBody` cuts at `## 来源`), and returns the
319
+ matching **sentence** rather than just the page name - the assistant had been filling the gap itself, answering
320
+ "Grok-4 was one of the models under test" from its own knowledge rather than from the page.
321
+
322
+ **This was caught by a scenario that then had to be deleted, and the deletion is the interesting part.** The
323
+ natural assertion - forbid the false-positive page names - does not hold: those three names appear **inside the
324
+ real page's own body** (its 「相关概念」 section), so a model mentioning them is *right*, and any page-name
325
+ blacklist misfires. The accuracy question therefore moved down a layer: `pageBody` is a pure function, and it is
326
+ pinned in `test/knowledge-neighbors.test.ts` (mutation-checked: returning the full text turns it red). That is
327
+ "put the test where it can hold", not "skipped".
328
+
329
+ Verified end to end: before the fix the answer listed four pages and invented detail; after it, it says there is
330
+ **one** place and quotes the sentence. The eval re-run after the change is **22/22**.
331
+
332
+ - **`--pages-from-cards` checked the wrong name and produced 5 broken links.** The guard that skips names the
333
+ linter cannot resolve (square brackets, or a title ending in `.md`) was applied to the **concept** name but not
334
+ to the **source** name - so `IPCC图件`, an ordinary-looking concept whose single card carries square brackets,
335
+ got a source line pointing at nothing. Caught by running the health check right after, not by reasoning: the
336
+ broken-link count went 0 -> 5. Fixed, the five pages removed, and a test pins it (with the mutation checked:
337
+ dropping the guard turns it red).
338
+
339
+ - **Every embedded Dataview query in the Vault was broken, for two independent reasons.** The reading-note and
340
+ daily-note templates embed `dataview` code blocks - **25,676 reading notes and 176 daily notes, 100% of both** -
341
+ and a user checking their vault found them rendering as raw code. Two separate faults, either of which alone
342
+ would have broken the feature:
343
+
344
+ - **The plugin was not installed.** `.obsidian/plugins/` did not exist and `community-plugins.json` was absent,
345
+ while five places in the code write `dataview` blocks and one CLI message tells the user to use "the Obsidian
346
+ Dataview plugin". Dataview 0.5.68 is now installed and enabled in this vault (it is a community plugin, so it
347
+ is per-machine and **not** covered by the repo - `output/` is gitignored, and the 2.4 MB `main.js` is
348
+ deliberately not committed).
349
+ - **The queries themselves were wrong**, which is the part that would have survived installing the plugin.
350
+ `SORT date DESC` sorts by a field **that does not exist** - the reading notes carry `published`, not `date`.
351
+ The daily note used `WHERE created = date(...)`, and `created` is the **generation** date: one backfill wrote
352
+ `created: 2026-09-26` into all 25,676 notes (measured), so 175 of the 176 daily notes listed nothing and the
353
+ one for the generation date would have listed twenty-five thousand. The related-articles table also listed the
354
+ note itself, and led with `rating` - a field that is empty across the whole corpus because it is the user's to
355
+ fill, so ten rows of blanks.
356
+
357
+ Both templates now use `published`, exclude `this.file`, and lead with `published`/`source`. **Fixing the
358
+ templates alone would have fixed nothing**: `create_reading_note()` skips a note that already exists, so the
359
+ 25,852 existing files needed a rewrite pass - `create_reading_notes --refresh-queries` rewrites only the code
360
+ block, is idempotent, leaves notes without one alone (counted, not silently skipped), and reports the notes whose
361
+ topic is empty separately rather than inventing one. Applied: 25,845 + 7 rewritten, 0 stale queries left.
362
+
363
+ - **`vault_search --type note` searched a directory that has never existed in this repo.** It read `Vault/Notes/`,
364
+ while the notes live in `002_Literature` (25,676 reading notes), `001_Daily`, `003_Ideas` and `008_MOC`. The
365
+ failure mode is the quiet one: the directory simply does not exist, so the type returned nothing and the command
366
+ still reported "found N results" - **the largest layer of the vault was invisible to search**, which is the layer
367
+ an agent would most want. Now it reads the four real directories (4.6s for a query over 26k notes). Pinned by
368
+ `test/vault_search_test.py`, including a regression test for the empty result, verified by a mutation check that
369
+ puts `Notes/` back.
370
+
371
+ The article half of the same function keeps its `--days 90` default, which **now hides most of the corpus**: the
372
+ vault spans 175 days (March-September), so anything older than ~90 days is unreachable unless `--days` is raised.
373
+ Reported, not changed - it is a default, and changing it changes how long every search takes.
374
+
375
+ - **The graph view drew 10,466 ghost nodes.** `.obsidian/graph.json` had `hideUnresolved: false`, so the concept
376
+ graph (`search: path:"Wiki/Concepts"`) rendered not only the 1,963 concept pages but every link target that has no
377
+ page - 10,466 of them, against 2,794 real concept-to-concept edges. Verified by screenshot after the change: the
378
+ graph now shows only real concepts (`Github Copilot`, `GPT-6`, `Claude Code Skills`, `MCP 权限边界`, …). It is
379
+ also **sparse** - 1.4 real edges per page, 498 pages with no concept-to-concept link at all - because most of each
380
+ page's "related concepts" are concepts that have no page yet. Building the remaining 3,725 candidate concepts
381
+ would densify it; that is a spend decision, not a defect.
382
+
383
+ - **The generated MOCs and the concept pages' source links had defects that only show up in Obsidian.** The user's
384
+ bar was "the knowledge base should present properly in Obsidian", so this pass looked at what renders rather than
385
+ at what runs:
386
+
387
+ - **`008_MOC` had a file called `MOC-.md`.** The 9 notes with an empty topic produced a malformed filename (and
388
+ the same guard was missing in the ideas generator, which would have written `想法-.md`). Empty-topic notes now
389
+ produce nothing on either path; "no topic" is not a topic.
390
+ - **MOC links were written as `[[<date>/<filename>.md|<title>]]`** while the notes' own links are
391
+ `[[<date>-<title>|<title>]]`. Neither full path nor bare filename - Obsidian resolves by shortest unique path,
392
+ so these were at best accidental. Links are now the file stem, which is what the rest of the vault uses; verified
393
+ against the filesystem: 4,497 links, 0 ambiguous stems, 0 pointing at a file that does not exist.
394
+ - **The MOC's own dataview query carried the same two faults fixed in the reading notes** (`SORT date` on a corpus
395
+ that has `published`; `contains(hasTopic, …)` on a nested list).
396
+ - **Concept pages linked their sources by the *card's* filename, not the reading note's.** Cards are named
397
+ `{date}-{full title}` and reading notes `{date}-{title truncated to 50}`, so for short titles (98%) they coincide
398
+ and the link resolved **by luck**; measured, **125 of 5,470 source links pointed at nothing**. The card already
399
+ records the right name in its `from` field - unused until now. `compile_wiki --fix-source-links` repairs the
400
+ existing pages locally (no model calls, ~20 lines, idempotent, only touches lines with the ` — ` form that the
401
+ source section uses, so the "related concepts" list is unaffected). Applied: **125 -> 17**, and the 17 remaining
402
+ point at the user's own notes, which are deliberately not copied into the Vault.
403
+ - Those same source links also carried a **`.md` extension** - the only place in the whole vault that does. Every
404
+ other link (note-to-note, daily notes, MOCs) omits it. Whether Obsidian tolerates the extension is not something
405
+ I verified, so the code now emits the form that is known to work and is used everywhere else.
406
+
407
+ - **Three more Vault directories were declared but never written to, and the existing Vault had never been
408
+ initialized at all.** Auditing the layout after the `007_Wiki` bug found the same shape twice more: `Wiki/Entities/`
409
+ and `Wiki/Topics/` are created by `vault init` **and described in the generated `README.md` as if they existed**
410
+ ("实体页(公众号、作者等)", "主题总览页"), while nothing in the codebase writes either; and `.obsidian/app.json`
411
+ set `attachmentFolderPath: 'Assets'` while the layout creates `_attachments` - so attachments would have gone to
412
+ a folder that is never created. That `Assets` literal appeared exactly **once** in the whole codebase, which is
413
+ the same tell as before: a name that exists in one place and contradicts the two places that matter. Both are
414
+ fixed, and the generated README's directory table now describes the real layout - including an explicit note that
415
+ the `000`-`008` series is for the user's own notes and stays empty until `vault promote`, because "empty by design"
416
+ and "empty because nobody built it" are indistinguishable to whoever is reading the vault.
417
+
418
+ The two layout lists live in different languages (the TS CLI and a Python script) so they cannot share a
419
+ constant; the agreement is pinned by test instead - the pattern this repo already uses for its four copies of
420
+ `TOPIC_ORDER`. `test/create_reading_notes_test.py` parses the CLI's `dirs` array and asserts that it creates
421
+ everything `VAULT_DIRS` expects, declares no `Wiki/*` subdirectory besides `Concepts`, and that
422
+ `attachmentFolderPath` names a directory the layout really creates. Three mutation checks.
423
+
424
+ **The Vault itself had never been initialized.** It had been built by `create_reading_notes`'s `mkdir` loop, so
425
+ `README.md`, `Templates/article.md`, `.gitignore` were missing and `.obsidian/app.json` was `{}`. One
426
+ `vault init --yes` fixed it - **with Obsidian closed first**, because Obsidian holds `app.json` in memory and
427
+ writes it back on exit, which would have silently reverted the corrected attachment path.
428
+
429
+ - **The Vault declared its concept directory twice, and one of the two was never written to.** `VAULT_DIRS`
430
+ (created by `vault init` / `create_reading_notes`) listed `007_Wiki/Concepts`, while the pages are actually
431
+ written to the top-level `Wiki/Concepts` - which is also what `vault_rag`, `vault_search`, `wiki_lint`, the
432
+ assistant's knowledge search and two CLI options read: **six places versus two**. So every init created a
433
+ `007_Wiki/` folder that no code ever filled, and a user looking at their vault sees an empty directory in the
434
+ numbered series and concludes the knowledge base was not updated. That is exactly how it was reported. The
435
+ template now declares `Wiki/Concepts` (the real one), the CLI's init preview lists it once instead of twice, and
436
+ the empty directory is gone from the vault. `test/compile_wiki_test.py` pins that whatever the template declares
437
+ for concepts **must equal** where `compile_wiki` actually writes - verified by a mutation check (putting
438
+ `007_Wiki/Concepts` back fails the test).
439
+
440
+ ### Changed
441
+
442
+ - **The assistant eval now actually exercises the knowledge base.** Of its 19 scenarios, the only
443
+ knowledge-related one was `which-store-knowledge` - "does it know *which* store to search" - and none made it
444
+ read a page. That was fine when the vault held 5,062 pages; it now holds **22,374**, and the knowledge line is
445
+ the part of the assistant that grew most. Three scenarios added: a concept from the article line, one from the
446
+ chat line (whose answer - "project lead / coordinator / ABaCAS" - is **only** in the page, so the assertion
447
+ cannot be satisfied from general knowledge), and one concept the vault does not contain at all.
448
+
449
+ **The third is the valuable one**: it asks about something with zero matches, and the point is not that it
450
+ answers, but that it **does not invent**. It calls `search_knowledge` twice, finds nothing, and says so -
451
+ asserted with `toolEmpty` rather than "the answer must contain one of these words", because the wording of an
452
+ honest "I don't have that" is unbounded. That reasoning was already written next to `EvalCase`; this is the
453
+ first scenario to use it for its intended purpose.
454
+
455
+ **The eval immediately caught something, and it was my scenario, not the assistant.** The first version asked
456
+ "who is 龙老师?" - and the model reasonably read that as a question about a person *in the chats* and went to
457
+ `search_chats` (it made no claims about the failure, which is the `tool-failure-honesty` behaviour working).
458
+ The question now names the store ("in the concept pages I organised, who is 龙老师?") because that is the path
459
+ the scenario exists to measure. All three pass three runs in a row (9/9) and the full suite is **22/22** - the
460
+ first baseline this line has had.
461
+
462
+ Note these three **depend on this machine's vault** (the pages are real files, not synthesised like the
463
+ sessions and favourites), so they will fail elsewhere or after a rebuild - and that would not be an assistant
464
+ problem.
465
+
466
+ - **The WeChat reader residue in note summaries is cleaned up - 1,484 of them, and the scope mistake is recorded
467
+ too.** The cleaner runs *at read time* (`create_reading_notes` calls `strip_wx_ads` while writing a summary), so
468
+ notes written before that logic existed were never cleaned: 1,484 of 25,676 carried page-button text inside their
469
+ summary (`继续滑动看下一个`, `去阅读`, `轻触阅读原文`). New notes are already clean, so this is a one-off
470
+ backfill: `create_reading_notes --clean-summaries`, local and idempotent. Re-measured afterwards: **0 notes left
471
+ with residue in the summary**.
472
+
473
+ **The first version swept the whole vault** (`Path(vault).rglob('*.md')` instead of the notes layer) and cleaned
474
+ **10,949 raw articles under `Sources/WeChat`** as well - material, which this command has no business touching.
475
+ The damage was checked with a diff rather than assumed: what was removed is the page-button phrases and
476
+ underscore rules, plus 167 lines of whitespace normalisation; **no article content was lost**. `Sources/` has no
477
+ backup, but it can be re-copied from `output/biz-daily`, so it is reversible - and it was left as is, because
478
+ reverting would put the interface noise back while leaving the 4,809 articles that have no summary section
479
+ uncleaned, i.e. a larger inconsistency. The scope is now pinned by a test (the "sweep the whole vault" mutation
480
+ fails).
481
+
482
+ One more criterion worth recording: the check is "this summary section **contains** one of the eight known
483
+ phrases", **not** "the text changed" - `strip_wx_ads` also normalises whitespace and strips, so any paragraph
484
+ with a trailing newline "changes", and using that as the trigger rewrites everything (measured while
485
+ investigating: the criterion was always true and reported all 25,676, against a true answer of 1,484).
486
+
487
+ - **Two more duplicate pairs merged by hand, out of the 741 Jev had proposed.** The loose "similar name" set
488
+ was narrowed by rule to 27, and reading each one's actual definition left exactly **two** that are the same
489
+ thing (`TRAE IDE Linux 版` / `TRAE IDE Linux 版本`; `100 万 token 上下文窗口` / `100万token上下文`). The
490
+ other twenty-five were rejected on meaning rather than on spelling - "日均Token调用量" is a rate where
491
+ "Token调用量" is a total, "2026世界人工智能大会" is one year's event against the series, "Token 节省" is a
492
+ concept where "Token 节省技巧" is a set of methods. Merged with aliases; nothing linked to the dropped
493
+ names, and the health check reports **0 broken links** across 22,374 pages.
494
+
495
+ That is the useful form of this result: a decision model proposed 741 merges, a string rule then cut them to
496
+ 27, and a human reading the definitions kept 2. The graph's name-level duplication is now settled.
497
+ - **`wiki lint` is fast again at the new scale - and the "similar names" count it prints is now mostly noise.**
498
+ The near-duplicate check compared every page with every other (5,062 pages took 1-2 minutes; 22,445 is twenty
499
+ times the pairs). It now builds a 5-gram inverted index and compares only within a bucket, which is **the same
500
+ result by construction**: the criterion is "longest common substring >= 5 characters", and that is equivalent
501
+ to "shares a 5-character substring", so a pair that shares none cannot pass. Verified two ways - brute-force
502
+ comparison on random data (2,258 groups either way, identical as sets) and the real vault: **22,381 pages in
503
+ 4.2 seconds**.
504
+
505
+ The fix also made something visible that the slowness had been hiding: at this size the check reports
506
+ **425,187 pairs**, up from 13,367. Pages grew 4.4x, pairs grow quadratically, and the new pages are
507
+ single-article fragments whose names overlap heavily (`Agent…`, `Token…`). That number is no longer a signal -
508
+ the one time it was investigated properly, roughly three pairs out of 741 candidates were genuinely the same.
509
+ The report still prints only the first ten, so it stays readable. Loosening or tightening the criterion would be
510
+ a change of meaning, and there is no evidence yet for what it should become, so it is recorded rather than
511
+ adjusted.
512
+
513
+ - **17,383 pages built without calling a model** - `wiki compile --pages-from-cards`. The candidate backlog was
514
+ priced at about **¥31** and the user's verdict was "that's a bit expensive", which is right: every candidate is
515
+ mentioned by exactly **one** article, so there is nothing to synthesise - a model would only be rewording a
516
+ single sentence. The definition is taken from the `desc` the card already carries, related concepts come from
517
+ **co-occurrence** (the other concepts in the same article, median 3), and the source is that article.
518
+
519
+ **The honest description is "reusing output already paid for", not "no model involved".** Those `desc` lines
520
+ were written by `article_notes` when it extracted concepts, so the quality is that line's quality: most read
521
+ like definitions (`招聘陷阱` - "job seekers should be wary of high-salary bait"), a few are meta-descriptions
522
+ (`免疫生态位` - "the other thing the article title splits out alongside cell types"). Pages carry
523
+ `summary_by: card` to say where the text came from, following the `summary_by: model` convention that
524
+ `user_notes` established.
525
+
526
+ Verified: **0 broken links** among the 17,319 new pages' source lines, only 40 ended up with an empty
527
+ "related concepts" section, and a re-run builds 0. Two of the four mutations that had to be caught were about
528
+ coverage, not correctness - and one guard was added after the first dry-run showed it building **18,885** pages
529
+ across every card directory at once, which would have put chat-line concepts into `Wiki/Concepts`. The flag now
530
+ requires explicit `--cards` and prints the exact commands when it is missing.
531
+
532
+ - **A scale problem the new pages exposed: `wiki lint` no longer finishes in reasonable time.** Its
533
+ near-duplicate check compares every page with every other (5,062 pages took 1-2 minutes; 22,445 is **20x** the
534
+ pairs). The check is the one that produced the 13,367 "similar names" and, through that, the 74 duplicate
535
+ groups worth merging - so it cannot simply be dropped. It needs bucketing (compare only within a name-length
536
+ band or a normalised prefix) rather than all-pairs. Not fixed here; recorded so the next person does not
537
+ discover it by waiting.
538
+
539
+ - **One concept, one node: 74 groups of duplicate pages merged** - `wiki compile --merge-duplicates`, local.
540
+ The user's read was that the graph was piling up rather than getting better: "some of them are the same thing
541
+ but drawn as different ones". Measured: 74 groups, 80 redundant pages, where the same concept had two or three
542
+ nodes because of a hyphen, a space, or a plural (`GPT 5.6` / `GPT-5.6` / `GPT5.6`, `AI skill` / `AI skills`,
543
+ `AI 工具` / `AI工具`).
544
+
545
+ **The trick that makes it cheap is `aliases`.** The group keeps the page with the most sources (`claude code`
546
+ has 411, `ClaudeCode` has 7), the other names go into that page's `aliases`, their source lines are merged in,
547
+ and the extras are deleted. **98 links pointed at the names that were deleted and every one of them still
548
+ resolves** - because Obsidian honours aliases, nothing else in the vault had to change. Verified by listing
549
+ all 80 dropped names and checking each is an alias on some page: 80/80, and 98/98 links covered.
550
+
551
+ The criterion is a normal form (strip spaces/hyphens/punctuation, lowercase, drop an English plural tail), not
552
+ the lint's "5-character common substring" rule - that one calls `Claude Code` and `Claude 4.8` a pair, and the
553
+ merge deliberately does not. The lint now reads `aliases` too, so those names stop being reported as "not built
554
+ yet, run compile with a higher limit" (that count fell 9,421 -> 9,384).
555
+
556
+ - **500 pages built from the 17,774-candidate backlog, as a trial.** The candidates are all "mentioned by exactly
557
+ one article", which is why the earlier `--min-refs`/`--limit` filters had left them alone. Sampling them: the
558
+ definitions are specific and readable (`TRAE SOLO 模式` - "AI-driven development mode that plans and runs the
559
+ whole flow from requirements to preview"), each links 3-4 related concepts, and each has exactly one source.
560
+ The other 17,274 were left alone pending a look.
561
+
562
+ With them came a fix that was written down but not implemented: `generate_concept` truncated the page's source
563
+ list to five (`for r in refs[:5]`), which is the *prompt* budget's job (`build_ref_lines`), not the page's. A
564
+ page that lists 5 sources while the index says 301 is exactly the gap `--refresh-sources` was written to close,
565
+ so new pages would have been born needing it.
566
+
567
+ - **Old concept pages can grow new edges now** - `wiki compile --refresh-sources`, local, no model calls.
568
+ The gap it closes was measured: `wiki compile` only creates pages for concepts that do not have one yet, so
569
+ an existing page is never touched - a new article that mentions `DeepSeek` does not add a line to that page.
570
+ Meanwhile `OPERATIONS.md` claimed the concept pages were "a ledger". One run of the new flag rewrote the
571
+ source section of **324 article-line pages (+3,164 edges)** and **34 chat-line pages (+148)**.
572
+
573
+ Three things it deliberately does not do, each because the obvious version is silently wrong:
574
+
575
+ - **It does not touch the frontmatter.** The body and `sources:` carry *deliberately different* names: the
576
+ body needs what Obsidian can resolve (chat cards are `会话-<name>`, pointing at `Sources/Chat/`), while
577
+ `sources:` needs what `source_kinds_for` can find under `output/<line>/<card>.md` (chat cards there have
578
+ **no** prefix). Syncing either side breaks `来源/聊天` tag inference - and since tags only ever grow, the
579
+ page still *looks* right afterwards.
580
+ - **It only adds.** Source lines that no current card accounts for stay exactly as they are (322 of them on
581
+ the article line); they are counted, not deleted. So a page can legitimately hold more sources than the
582
+ index reports - the index says what the cards say *now*, the page holds every reference it ever had.
583
+ - **It skips names that cannot be written.** Six with square brackets (Obsidian itself cannot resolve them)
584
+ and one whose *title* ends in `.md` - that last one would have turned the lint's "0 broken links" into 1,
585
+ because `wiki_lint.resolve` treats a `.md`-suffixed name as a complete filename while the real card is
586
+ `….md.md`.
587
+
588
+ Ordering is normalised with `sorted()` (a spec, not a history - measured: that costs 29 extra pages). Two
589
+ mistakes were made and caught here rather than reasoned away: a lenient reader that was off by one character
590
+ (`line[3:]` instead of `line[4:]`) manufactured **6,721 phantom missing edges** and would have driven the
591
+ whole change, and the `.md`-suffix case above put a real broken link into `Codex.md`. The first was found by
592
+ printing one concrete page when the numbers looked wrong; the second by running the lint afterwards.
593
+
594
+ - **The three real states each have a face now.** The panel has always tracked `busy` / `offline` / `quota`
595
+ (that is what the coloured halo reads), so this adds three faces to classes that already existed - **no new
596
+ state machine**. Busy is a calm half-closed eye with a small mouth (focused), offline is a closed sad eye
597
+ with a squiggly mouth (apologetic), quota-out is a heavy-lidded eye with an oval sigh (tired). The halo stays:
598
+ it is the ambient light you catch peripherally, the face is the detail you only see by looking, and they say
599
+ the same thing at two distances rather than repeating each other.
600
+
601
+ **The order of two CSS rules is a contract here.** `ball-happy` must come *after* the three state rules,
602
+ because with equal specificity the later rule wins - otherwise poking the ball while the assistant happens to
603
+ be busy shows the focused face and the poke does nothing visible. That is asserted, not commented.
604
+
605
+ All three are generated through the same `/images/edits` + mask pipeline as the grin, with the mask widened to
606
+ a third hole over the mouth (a resigned face cannot keep grinning). Each was checked for the thing that
607
+ actually breaks a circular ball: **0 solid pixels outside the inscribed circle**. The busy face took two tries
608
+ - the first came back with angled slits that read as *suspicious* rather than *concentrated*.
609
+
610
+ - **Poking the ball makes it grin.** Press it and it switches to a second face - the same cat with its eyes
611
+ closed in a happy arc - and it goes back to the normal face the instant you let go. The first version held
612
+ the grin for 900ms after release, on the theory that a click lasts a few tens of milliseconds and an
613
+ expression that short is one nobody sees; **the user tried it and said the delay felt stuck**, so there is
614
+ no hold at all now, and a test asserts the constant is gone rather than weakened. `pointercancel` also
615
+ reverts, so a pointer stolen by the system cannot leave the ball grinning forever.
616
+
617
+ The second face is a **generated** image, and both halves of that needed measuring. It was produced with the
618
+ same endpoint's `/images/edits` (image-in, not text-in - `/images/generations` would have drawn a *similar
619
+ cat* rather than *this* cat) with a mask whose transparent area was the two eyes and the eyelids. **The model
620
+ still repainted the whole canvas**: mean colour distance inside the hole 197.8, outside 10.9 - so the shipped
621
+ file takes the generated pixels **only inside the hole**, feathered at the edge, and keeps the original
622
+ everywhere else, which is also why switching faces cannot shift the colours of the face. It came back **RGB,
623
+ with the transparent background filled black** (the second time this endpoint has done that), so the alpha
624
+ channel is taken from the source art.
625
+
626
+ Both faces are cut and scaled from the same source with the same parameters, so they are pixel-aligned: the
627
+ two 256px assets differ by 3.5% of their pixels, all of them in the eye strip. Switching is a hard cut, not a
628
+ fade - an expression change is one frame long, and a fade reads as an image loading.
629
+
630
+ One gap this closed rather than stepped around: `resources/panel`'s file list was hand-written and nothing
631
+ checked it, so **a new asset could be added without a single test going red** (measured: deleting
632
+ `mascot-happy.png` from the list kept the whole file green). The list is now a constant, and a test
633
+ reconciles it against the actual directory in both directions.
634
+
635
+ - **The ball is a quarter bigger and answers the mouse.** `BALL_SIZE` 76 -> 96 (the window grows with it;
636
+ the expanded window is now 528 wide instead of 508), and the artwork is re-derived with a 2% margin instead
637
+ of 4%, so the cat itself is ~28% larger than before.
638
+
639
+ The interactions existed but were too quiet to notice: hover only changed the shadow and brightness, and
640
+ pressing gave `scale(.95)` with an `ease` transition - a 5% squeeze that snaps back rather than bounces.
641
+ Now hover scales to 1.02, press squashes to 0.92, and the transition is `cubic-bezier(.34, 1.56, .64, 1)` so
642
+ releasing overshoots back. **The enlarged-on-hover factor is 1.02 and cannot be raised on its own**: it is
643
+ paired with the margin the asset is generated with (measured: at 1.02x, one solid pixel falls outside the
644
+ circle; at 1.03x, ten - the window clips the ball to a circle and would shave its edge). A test pins both
645
+ halves. Idle stays perfectly still, so "not moving" still means idle and the halo keeps being the only
646
+ status light - and `prefers-reduced-motion` now stops the **deformation** too, not just the keyframes:
647
+ disabling the transition alone made hover/press jump instantly to another shape, which reads as a glitch.
648
+
649
+ - **The ball wears the mascot's illustrated version; the pixel version is gone.** Same character, drawn
650
+ rather than pixel-art. The new source is 1254x1254 with its content filling 98% of the canvas, which is the
651
+ part that needed care: the ball clips its image to a **circle** (`border-radius: 50%` on both `#ball` and the
652
+ face layer), so pasting it in as-is put the **green speech bubble entirely outside the circle** (10,368 solid
653
+ pixels) along with the paws. The shipped asset is therefore cropped to the alpha bounding box and scaled so
654
+ that the furthest solid pixel sits within 96% of the radius - 1226x1247 becomes 210x213 centred on a 256
655
+ canvas - which measures **0 solid pixels outside the circle**, at 41KB (the source is 813KB). For scale: the
656
+ previous asset filled 80% x 91% of its canvas, the new one 82% x 83%, so the character does not shrink.
657
+ `serveStatic` reads the file per request with `Cache-Control: no-store`, so a running panel only needs a
658
+ **page reload** - no restart. The tray icon (`tray.png`) is a separate image and still shows the old
659
+ pixel-art mascot on its disc.
660
+
661
+ - **The two knowledge bases now live in one Vault, in two directories.** The chat line had grown to 1,519
662
+ concept pages inside `Wiki/Concepts` alongside the article line's 2,115 - one graph, correctly, but the user
663
+ asked for the two to be kept apart. `Chat/` now holds the chat side (`Chat/Concepts/`, `Chat/00-Overview.md`),
664
+ `Wiki/` holds the article side, and no page mixes sources. `Sources/Chat/` (the conversation cards) sits next to
665
+ `Sources/WeChat/` by the same rule.
666
+
667
+ **The half that matters is not the move, it is that every reader had to learn about the second directory.**
668
+ Five places read concept pages - `vault_search`, `vault_rag`, the assistant's `search_knowledge` and
669
+ `conceptNeighbors`, and the MCP `wechat.get_concept` - and each of them reading one directory would have been
670
+ **silently wrong rather than broken**: half the hits missing, no error, the tool still looking like it works.
671
+ They now all read both, the CLI's `vault init` preview creates both, and the generated `README.md` describes
672
+ both. The list itself cannot be shared across the Python/TypeScript boundary (and the MCP package is separate
673
+ again), so `test/concept-dirs-agreement.test.ts` pins all five declarations to one; the two scripts that
674
+ `import` it instead are checked for still iterating it.
675
+
676
+ **A name still belongs to one page.** Splitting the directories immediately created 11 concepts that had a page
677
+ on *both* sides (`DeepSeek`, `智谱`, `Agent Skills` …) - and two files with the same stem make `[[DeepSeek]]`
678
+ ambiguous in Obsidian, which is the same failure the `会话-` prefix prevents for conversation cards. The
679
+ second line now skips a name the first already has, and says so ("跳过 1530 个已有概念(其中 11 个在另一条线
680
+ 已有同名页)") instead of folding it into the re-run count. `wiki lint` reports **0** duplicate titles; those
681
+ 11 concepts live on the article side and the chat pages link to them, so the overlap still shows up as edges.
682
+
683
+ Three things broke while doing it and were caught by running the suites rather than by reasoning:
684
+ `conceptNeighbors` took a directory and began taking a list, and a caller still passing a string made
685
+ `for (const dir of wikiDirs)` walk the path **one character at a time** and return "no neighbours" - a wrong
686
+ answer, not a crash, so it now throws on a string; and `wiki lint` went from 7,872 broken links to **0** once it
687
+ was told that a bare `[[name]]` may resolve to a card or a raw source as well as to a page.
688
+
689
+ - **`compile_wiki` generates concept pages concurrently too, and filters existing pages before spending.** Same
690
+ pattern as `article-notes` and for the same reason: 1,893 pages took minutes at 8 workers instead of the ~53
691
+ minutes the serial loop would have needed (it deliberately slept 0.5s between calls - a per-article throttle that
692
+ only makes sense when one request is in flight, so the concurrent path does not sleep). Writing stays serial and
693
+ **streams**: a page is written as soon as its answer arrives, so a killed run keeps what it already produced.
694
+
695
+ One trap belongs to this step specifically, and it is a money trap rather than a correctness one: the existing
696
+ pages were previously skipped inside the serial loop (`if out_file.exists(): continue`), which is safe by
697
+ construction. Handing that check to the thread pool - submit everything, discard the results you did not want -
698
+ would **pay for a page that already exists** and show up only on the bill. `build_jobs()` therefore filters
699
+ before submission, and the test asserts the skipped concept's name never reaches `generate_concept`.
700
+
701
+ - **`article-notes` asks concurrently, which is 6.5x faster for the same money.** The loop was one blocking call
702
+ per article; measured, that is 49 cards/minute against **319 cards/minute at 8 workers** - and the token count is
703
+ unchanged, so the bill is identical (~¥11.5 for 6,409 articles). Only the *asking* is parallel: the writing stays
704
+ serial, because a call is ~1.2s (pure network wait) while writing a card is sub-millisecond, so concurrency there
705
+ buys nothing and only makes "who wrote which card" harder to trace. The default is 6, matching `biz_daily`'s
706
+ `JEV_WORKERS` / `SUMMARY_WORKERS` / `IMAGE_WORKERS`, which measure the same upstream.
707
+
708
+ **Why not batch several articles into one call** - it is the other obvious saving, and it was measured too: the
709
+ fixed instructions are 459 characters (32% of a 1,394-character prompt), so 5-per-call cuts prompt tokens to 74%
710
+ and would save roughly ¥2.3. It was **rejected on two grounds**. First, batching does not save *time*: latency is
711
+ dominated by output length, so five times fewer calls each four times slower is a wash - concurrency is the lever,
712
+ and the two are orthogonal anyway. Second, and the reason that matters: `compile_wiki.build_ref_lines` feeds the
713
+ concept-page model up to five references collected **from different articles**, each being that article's `desc`
714
+ line - so cross-article synthesis already happens, downstream and deterministically, and the `desc` line is its raw
715
+ material. A `desc` means "what **this** article said about it"; five articles in one prompt makes "this article"
716
+ ambiguous and blurs exactly the input the fusion depends on. **Not tested**: whether batched extraction yields
717
+ *better* concepts. That needs human labels, and this project does not record calibration conclusions without a
718
+ gold standard - what could be measured is how much the concept sets differ, which is consistency, not quality.
719
+
720
+ - **The floating ball is just the cat while idle.** The coloured halo behind it (`#ball .glow`) used to be painted
721
+ at all times, which was the point when it was added - the user asked for a background that moves. Two costs only
722
+ became visible after living with it: the ball always had a coloured disc behind it, so **"this image has a
723
+ transparent background" was impossible to see**; and that same layer doubles as the status light (`offline` turns
724
+ it red, `quota` amber, `busy` speeds it up and makes it breathe), so having it always on meant having no status
725
+ light - offline, busy and quota-out all looked identical. Now the glow is painted **only** under
726
+ `body.busy` / `body.offline` / `body.quota`: still = the cat, lit = something to say. This is the earlier request
727
+ **narrowed, not cancelled** - the hue drift is still there, it just happens while busy rather than always.
728
+ `test/panel-packaging.test.ts` was rewritten to pin the new contract rather than loosened: idle must be
729
+ `background: none`, and each of the three states must appear **in the selector of the rule that carries the
730
+ radial-gradient** - the first version searched the whole stylesheet for `body.quota #ball .glow`, which also
731
+ matches that state's unrelated `animation: none` rule, so deleting quota from the gradient selector passed. A
732
+ mutation check confirmed both halves of the new assertion fail when broken.
733
+
734
+ - **`scripts/backfill_articles.py` - pulling months of articles into a knowledge base, without the 250-hour path.**
735
+ The existing `pipeline run --date` cannot do this, for three measured reasons rather than guessed ones: the CLI
736
+ wraps the whole pipeline in a hardcoded 10-minute timeout (`bin/weflow-cli.ts:3906`) that one historical day
737
+ already exceeds, so the run dies with zero output; `biz_daily` fetches serially *and* downloads 13-21 images per
738
+ article (~30s each - a 25-minute pilot processed 47 articles); and none of that is needed downstream, because
739
+ `create_reading_notes` reads only the md frontmatter (`title`/`source`/`url`/`topic`/`date`) plus the first ten
740
+ lines of the body, and `compile_wiki` reads only the `## AI 摘要` section. So this script reuses `biz_daily`'s
741
+ collection, topic-normalisation, md-writing and `.articles.json` functions - one implementation of each, not a
742
+ second copy - and changes exactly three things: fetch concurrently, do not download images, do not emit the HTML
743
+ reader. Measured over 2026-03-01..2026-08-31: 164 days, 25,603 articles, of which **12,386 actually need a
744
+ fetch** (the other half already carry usable text in the database), ~2.5-3 articles/second at 10 workers.
745
+
746
+ Two limits are stated rather than discovered later. **Image-only articles yield nothing**: measured on 3/4 and
747
+ 7/4, 27/40 and 44/60 carried text, and the rest are 图文 messages whose content is in the pictures - the same
748
+ `内容过短` gate `biz_daily` applies, not a fetch failure. And the topic comes from `_guess_topic` (the keyword
749
+ fallback `biz_daily` uses when AI classification fails) rather than a model, because this step makes no AI calls
750
+ at all; the concept extraction that does is a separate, later command.
751
+
752
+ - **`article-notes --since / --until` - a date window, because "do all of September" is how a person says it.**
753
+ `--limit` can only express "the newest N", so using it to cover a month either misses articles (a busy month
754
+ exceeds the limit) or drags in the neighbouring month (a quiet one does not reach it). Measured on the real vault:
755
+ September holds 710 article notes, 120 already carded, so the exact job is 590 calls - a number `--limit` could only
756
+ have approximated. With the default incremental behaviour the already-carded 120 are skipped rather than re-asked.
757
+
758
+ - **Your own notes are now a source: the AI reads what you write and generates notes back.** The three existing
759
+ lines all read *material* - articles, conversations, favourites. This one reads **notes the user wrote**, which
760
+ changes what has to be guaranteed:
761
+
762
+ - **your notes are read-only.** Nothing in this line writes to, moves or renames them; the output is a separate
763
+ card under `output/user-notes/`. The failure mode here is asymmetric - corrupting someone's own writing is worse
764
+ than any of the other three lines going wrong - so the promise is stated in the command's own description.
765
+ - **the card's summary is marked `summary_by: model`** and the body says it in words: *this is the model's reading
766
+ of your note, not your words*. A model's paraphrase mistaken for the user's own writing is the worst confusion
767
+ this line could produce.
768
+ - **links the user wrote win.** If the note contains `[[权限边界]]`, that concept name is kept, ranked first, and
769
+ the model only fills in what the note did not mention - the user's own vocabulary is what they will search with
770
+ later. Verified on a demo note: 5 concepts came out, one of them the user's own link.
771
+ - **the prompt forbids completing the user's thoughts.** "Do not fill in what they left open, and do not pretend
772
+ to be certain where they were unsure" is the one instruction that differs most from the other three lines - a
773
+ note is where someone thinks out loud, and a model that tidies that up destroys the signal.
774
+
775
+ It reads only the human layers (`000_Inbox`, `003_Ideas`, `004_Permanent`, `005_Reference`, `006_Projects`,
776
+ `008_MOC`, `999_Archive`) plus loose `.md` files in the vault root, and never `Wiki/` (generated),
777
+ `001_Daily`/`002_Literature` (pipeline) or `Sources/` (raw material) - otherwise the AI would read its own output
778
+ back and drift. Pages derived from these notes are tagged `来源/我的笔记`, so `tag:#来源/我的笔记` separates
779
+ "what I thought" from "what I read" in the graph.
780
+
781
+ The loop closes: write a note → the AI makes concepts → edit a generated page and it is overwritten on the next
782
+ run, so anything worth keeping belongs in `004_Permanent` (a human layer), where the next run will read it back as
783
+ a source rather than discard it.
784
+
785
+
786
+ - **The generated vault now says four things it should have said all along**, borrowed from a local Obsidian vault
787
+ built for exam prep (studied at the user's request; its own usage doc is a good example of a generated knowledge
788
+ base that explains itself):
789
+
790
+ - **"This page is generated"** - re-running `wiki compile` overwrites the overview and every concept page, so a
791
+ note edited by hand disappears on the next run. Their vault states this in the first line of its usage doc; ours
792
+ said nothing, which is how a user loses an afternoon of editing.
793
+ - **"Model-generated, not human-verified"** - every concept page now carries `verified: false`. The borrowed
794
+ vault's README states the same rule ("unverified information must be marked, never presented as settled"), which
795
+ is also this project's own rule for drafts (`judged: false`); concept pages had no such marker.
796
+ - **A nested type tag** (`知识/概念`) so `tag:#知识/概念` finds every knowledge page - their vault uses nested tags
797
+ (`资料分析/增长`) exactly this way, and it costs nothing to make our pages filterable by kind.
798
+ - **How to open the graph without hanging**: the vault holds thousands of source notes, so a global graph is
799
+ unusable; the index now names the filter string (`path:"Wiki/Concepts"`) and the better habit (local graph).
800
+ Their doc does this with a table of filter strings, including one row that says "the panorama is slow, use with
801
+ care" - an honest note this project is happy to copy. The Obsidian config file itself is **not** written: our
802
+ vault has no `.obsidian` directory, and guessing a config schema would be worse than giving the user a string to
803
+ paste.
804
+
805
+ - **`wiki compile --relabel`** rewrites only the frontmatter of existing pages (type tag, verification marker) with no
806
+ model calls - the same trick as `article-notes --refresh-summaries`: metadata is computable locally, so a
807
+ convention change should not cost 51 regenerations. Idempotent, and tested as such.
808
+
809
+ ### Fixed
810
+
811
+ - **The index page reported "0 references" for pages that had them.** Concept titles are written as `title: "甲"`
812
+ (YAML-safe) while the aggregation keys them bare, so the lookup missed and the page looked merely un-cited rather
813
+ than mis-counted.
814
+ - **The index could only ever show one source's view.** It is rewritten by every compile, and the three lines
815
+ (articles, conversations, favourites) are compiled separately - so the table was rebuilt each time against
816
+ whichever source ran last, and a favourite-derived concept showed 0 while its card was right there. Counts are now
817
+ taken across **all** `output/*-notes` directories, which also fixes the first bug's symptom.
818
+
819
+
820
+ - **WeChat favourites are the third source feeding the knowledge base.** The daily line covers what the user *read*;
821
+ favourites are what they deliberately **kept**, which includes articles outside the daily window and is a stronger
822
+ signal of interest. `fav-notes` reads the favourites (locally), takes each one's text where the record carries it and
823
+ otherwise fetches the article, then writes the same card shape the other two producers write - so the aggregator,
824
+ the health check and the assistant's search needed no changes. The fetch **reuses the daily line's cached
825
+ fetcher** rather than growing a second downloader: the WeChat-UA/WAF/gzip retry logic was hard-won once, and the
826
+ cache means favourites the daily line already fetched cost no network at all - measured on the first real run, all
827
+ four fetched bodies came from the cache.
828
+
829
+ One thing this line does differently and says so on the card: the article and chat lines **copy** an existing
830
+ summary, while a favourite record has none, so this summary is **model-written** and marked `summary_by: model`.
831
+ `saved` (when the user kept it) is deliberately not called `published`.
832
+
833
+ - **`wiki lint` finds card directories with a glob now** (`output/*-notes`), because hard-coding the two directories
834
+ meant a third source's inbound links would be silently missed - and the visible symptom of that is "every new page
835
+ is an orphan", which is worse than no check at all.
836
+
837
+ ### Fixed
838
+
839
+ - **`fav-notes --dry-run` was fetching articles.** The preview's own description says "local only", but it classified
840
+ each favourite by calling the function that fetches - so a preview quietly made four HTTP requests. Classification
841
+ is now a separate, side-effect-free question (`local` / `needs-fetch` / `none`), the preview reports what *would*
842
+ be fetched without fetching it, and a test asserts that classifying an unreachable URL does not resolve it. Found by
843
+ running the preview, not by reading it.
844
+
845
+
846
+ - **The chat knowledge line was failing on long conversations, and the failure said nothing useful.** Two of three
847
+ conversations came back as "the model did not return usable JSON" - a reason that fits both "the output was
848
+ truncated" and "the model answered in prose", so it points at neither. The cause was truncation: the JSON a long
849
+ conversation has to produce (summary, timeline, topics, people) is token-hungry in Chinese and the call was capped
850
+ at 1200 tokens, so the JSON ended mid-object. The cap is now 2000 with a longer timeout, and a failed call records
851
+ the **tail of what the model actually returned**, so the next occurrence is diagnosable instead of guessable.
852
+ Measured after the change: 3 of 3 conversations succeed.
853
+
854
+ - **A partly-successful build was reported as a total failure.** Both `chat-notes` and `article-notes` exited
855
+ non-zero if *anything* failed, so "2 of 3 cards written" arrived at the CLI as a failure - and the CLI then
856
+ replaced the script's own JSON (which listed which one failed and why) with a bare `(exit 1)`. Partial success is
857
+ now exit 0 with the failures listed as data, only "nothing was written" is a failure, and the CLI's error path
858
+ carries the child's output so a real failure keeps its reason. (This is the same principle as the earlier fix to
859
+ the assistant prompt: an error that does not carry its cause invites a guess that is wrong.)
860
+
861
+ ### Added
862
+
863
+ - **The knowledge base can now be checked, and the check found three things worth fixing.** `wiki lint`
864
+ (`scripts/wiki_lint.py`, `weflow-cli wiki lint`) reports broken links, orphan pages, empty pages and duplicate
865
+ titles - locally, with no model calls. The gap was recorded in D-049 as deliberately not done; running it against
866
+ the real 39 pages is what justified closing it, because two judgements in it are not obvious:
867
+
868
+ - **It separates "a concept the pages link to that has no page yet" from a break.** `## 相关概念` sections name
869
+ concepts the model proposed; only the most-referenced ones have pages. Reporting both kinds as "dead links"
870
+ gave "40 dead links" on a healthy knowledge base - the fastest way to teach a reader to ignore a tool. It now
871
+ reports expansions as candidates (131 of them) and breaks as breaks (0).
872
+ - **Card-side links count as inbound.** Without that, every single page was reported as an orphan, because pages
873
+ mostly link *forward* to concepts that do not exist yet - so page-to-page inlinks are nearly empty by
874
+ construction. Card links are the real inbound edges, and counting them turned "10 orphans" into the truth.
875
+
876
+ - **`wiki compile --min-refs` - a gate on what deserves a page.** Measured on the real corpus: 120 article notes
877
+ produce **387 concepts, of which only 37 are mentioned by more than one** - the rest are the people, companies and
878
+ single events of one news story. Writing a page each is not a knowledge base, it is a clippings file. `--min-refs 2`
879
+ keeps only the durable ones, and it is what the default recommendation is based on, not a guess.
880
+
881
+ - **`article-notes` is incremental by default.** It used to re-ask the model about every article on every run:
882
+ thirty articles meant thirty wasted calls, and the full 1633 would have meant a whole run's money. A card newer
883
+ than its source note now means "skip"; a changed source note means "redo"; `--refresh` forces everything. The
884
+ chat line keeps rewriting its cards on purpose - those are rolling snapshots.
885
+
886
+ - **`wiki lint` reports near-duplicate titles too**, because the same event arrives from several accounts. The real
887
+ run produced a pair of *pages for one event* (`尼泊尔热索瓦泥石流` and `热索瓦泥石流灾害`) - which the orphan check
888
+ cannot catch, since both have cards linking to them - and a four-source cluster around one news story, which is what
889
+ inflates a concept's rank (visible directly in a page's `sources`). Report only, no automatic merging: two accounts
890
+ writing about one event may genuinely carry different information, and deciding which page to drop is a person's
891
+ call.
892
+
893
+ - **`wiki lint` also reports degenerate fields** - a metadata column that is nearly one value. Measured: all 39
894
+ concept pages carry `topics: [学术]`, i.e. the field looks like metadata and says nothing, and it is the kind of
895
+ thing that gets trusted. (An independent measurement on the same corpus: of 224 notes whose titles read as news,
896
+ 40 are labelled `学术`.) This reports the degeneracy rather than fixing the classifier - that classifier belongs to
897
+ the daily pipeline, and changing its criteria without labels would be a guess.
898
+
899
+
900
+ - **The article line can now produce knowledge pages, and this was the missing half of the pipeline.** Running the
901
+ aggregator over the whole Vault had shown that the 1633 article notes contain no concept links at all - only their own
902
+ topic and `## 关联网络` path links - so `wiki compile` had nothing to aggregate, no matter how it was invoked.
903
+ `article-notes` is the step that was missing: one model call per article, **asking only for concepts** (the summary is
904
+ copied from the existing note rather than regenerated, which removes one chance to invent something), writing a card
905
+ into `output/article-notes/` in exactly the shape the aggregator reads. Measured on this machine: 30 articles -> 110
906
+ concepts -> 10 concept pages plus an index (`Wiki/00-Overview.md`), and those pages are now searchable locally
907
+ (`vault search`, and the assistant's `search_knowledge`, which until today could only answer "the knowledge base has
908
+ not been generated yet"). The concepts that come out are specific ("标签配料表一致性", "椰子水全覆盖风险排查") rather than
909
+ taxonomy words, which is what the prompt asks for in so many words.
910
+
911
+ Two noise sources in the *upstream* material surfaced in that trial and are deliberately not papered over here: some
912
+ article notes carry the WeChat reader's own UI text inside their summary (`在小说阅读器读本章 去阅读`), which the card
913
+ copies verbatim because copying is the point; and the topic classifier puts unrelated stories in one bucket (a murder
914
+ case and a food-safety notice both came out as `学术`). Both belong to the daily pipeline rather than this one, and both
915
+ are recorded in `docs/PROJECT_STATE.md`.
916
+
917
+ - **The knowledge-card helpers are shared rather than copied.** `chat_notes` and `article_notes` produce the same kind of
918
+ card, so `note_path`, `plain` (the `[[...]]` stripper) and `SEPARATOR` now live in `_utils.py` - one implementation,
919
+ two callers - and `_utils` is deliberately dependency-light so the CI job that installs only `zstandard pycryptodome`
920
+ can still import it (importing `chat_notes` would have dragged in `nt_decrypt`).
921
+
922
+ - **Chat conversations can now feed the knowledge base, as notes the existing wiki pipeline aggregates.**
923
+ Until now the knowledge side had one source - `output/biz-daily`, the article line, over ten thousand files -
924
+ while conversations could only be searched, never condensed. `chat-notes` closes that: one knowledge card
925
+ per conversation (summary, timeline, topics and people, what is owed), written to `output/chat-notes/`, and
926
+ `wiki compile --source ./output/chat-notes` turns the `[[topic]]` and `[[person]]` links into concept pages,
927
+ because `compile_wiki` already takes a `--source` and already aggregates wikilinks. The three shapes asked
928
+ for therefore come out of **one** producer plus the aggregator that was already there: the card is the
929
+ per-conversation shape, and topic pages and people/timeline pages are what the aggregator makes of it. No
930
+ second aggregator, no new concept format, and backlinks and search keep working.
931
+
932
+ Four things in it are deliberate. **The `[[...]]` links are rendered by our code, never by the model**: the
933
+ model returns structured lists plus free text, and any `[[...]]` inside that free text is stripped, because
934
+ `scan_articles` collects *every* wikilink in a body and cannot tell who wrote it - a stray one would invent
935
+ a concept. **Thin conversations get no card** (under 3 messages or 60 characters) and are reported as
936
+ skipped rather than silently dropped: a three-line chat yields a card saying "they said two things", at the
937
+ cost of a model call and one contentless source in a concept page. **Cards roll, concepts accumulate** - one
938
+ file per conversation, rewritten on each run with its window recorded, so repeated runs cannot multiply; the
939
+ cards are a snapshot and the concept pages are the ledger. And the prompt tells the model it is a compiler
940
+ rather than a writer, so contradictions stay side by side instead of being smoothed into one sentence, which
941
+ is what a knowledge base of real conversations needs. That phrasing is borrowed from Tencent/WeKnora's wiki
942
+ prompts, read for this work - as is the "too little content, do not call the model" gate.
943
+
944
+ Verified by running it: `chat-notes --dry-run` reports 37 conversations, 2913 messages and 105,607
945
+ characters over 30 days on this machine, and names the 3 that are too thin to card.
946
+
947
+ - **`wiki compile` now uses the per-topic description it was already collecting and throwing away.** The
948
+ aggregator collects, for every wikilink, the sentence the source wrote *about that concept* - and page
949
+ generation only ever used the note's overall summary. A concept mentioned in twenty notes was therefore
950
+ described by five of their generic summaries, never by what any of them actually said about it. Reference
951
+ lines now prefer that description and fall back to the summary, which is also what makes a person page read
952
+ as a timeline: each contributing line is "what happened then".
953
+
954
+
955
+ - **The panel's ball wears the project mascot, with a transparent background.** `resources/panel/mascot.png`
956
+ is served through the panel's static whitelist and drawn with **no disc behind it** - a mascot floating on
957
+ the desktop rather than sitting on a plate - separated from light wallpapers by a `drop-shadow` that
958
+ follows its outline. Three things about it were measured rather than eyeballed: the source
959
+ (`weflow-cli图标.png` at the repo root) is 1024x1024 but its **content occupies only 600x689**, so the
960
+ shipped asset is cropped to the alpha bounding box and resized to 256 (41KB rather than 1.37MB), which is
961
+ what makes the mascot fill the space instead of floating small inside it; the source's background is
962
+ **transparent**, so nothing needs keying; and the outline the ball used to have (a 1px light ring, drawn
963
+ for the disc) had to go with it - with no disc it draws a circle in empty space. Dropping the ring is
964
+ easy to forget, so a test asserts the two changes travel together.
965
+
966
+ An earlier revision of this entry claimed the transparent version "loses the cat's body into a dark
967
+ background". **That was my misreading of a small comparison image** in which the mascot was rendered too
968
+ small; at the real size (76 logical pixels, inspected at 3x against light, mid and dark backdrops) it
969
+ reads fine on all three. The user picked transparent after seeing both.
970
+
971
+ The **tray icon is a separate asset** (`tray.png`) with the disc baked in: at 16-24 pixels on a dark
972
+ taskbar there is no drop-shadow to lean on, so the disc is what keeps the outline legible. `nativeImage`
973
+ cannot composite, so that one is generated with a canvas and committed.
974
+
975
+ - **The ball's background moves, and it says what the assistant is doing.** Behind the mascot there is now
976
+ a soft-edged glow (deliberately not a hard disc - a hard edge is "sitting on a plate" again) whose hue
977
+ drifts on a 26-second loop, slow enough that it is only noticeable if you look at it. Its **state**
978
+ changes are the useful half: while a turn is in flight it brightens and breathes, and when the daemon
979
+ cannot be reached it goes warm and **stops moving** (a still thing is what gets noticed). A collapsed ball
980
+ previously gave no sign at all that it was working. `prefers-reduced-motion` turns all of it off, and the
981
+ three state classes are set from one function so a branch cannot forget to clear `busy` and leave the ball
982
+ glowing "working" forever - a test asserts that, and that no other code touches those class names.
983
+
984
+ Two details that came out of looking at the renders rather than the code: the breathing animation's
985
+ `scale(1.08)` overflowed the 76x76 window and produced a **scrollbar** inside the ball (visible as
986
+ up/down arrows in a screenshot), so ball mode is `overflow: hidden`; and the glow is a separate layer from
987
+ the mascot so the background can animate while the cat stays put.
988
+
989
+ - **The panel window no longer opens to an empty void.** It used to show nothing but black until you
990
+ typed, which says neither what the assistant can do nor that it is alive. It now opens with a
991
+ one-line explanation (including that the database never leaves the machine) and four **clickable**
992
+ example questions that submit through the same path as typing. Verified end to end by clicking one
993
+ through the DevTools protocol: the example block gives way, the question becomes a real turn, and
994
+ the answer comes back through the shared quota counter (`[panel] 9字 → 已回复 (427字, 今日 1/100)`).
995
+
996
+ - **A local panel: talk to the assistant without logging into the WeChat channel.** Until now the only
997
+ way in was the WeChat Bot channel, which requires scanning a QR code - so asking one question cost a
998
+ login. `weflow-cli panel` opens a small always-on-top window that talks to the same brain: the same
999
+ memory, the same daily quota, the same serial queue. It works with the channel logged out. Memory is
1000
+ shared rather than duplicated because facts are stored per conversation id and the panel resolves to
1001
+ the **single** allowlisted id when there is exactly one; with none or several it does not guess - it
1002
+ uses its own bucket and **says so on screen**, because "separate memory that you think is shared" is
1003
+ the exact failure this arrangement invites. `assistantPanelUser` pins the choice.
1004
+
1005
+ The window is a client, never a second assistant (D-045). `weflow-cli panel --status --json` reports
1006
+ it, `panel --ask "…"` asks a question from the terminal, and both go through the same loopback
1007
+ endpoint the window uses. The endpoint binds `127.0.0.1` and cannot be configured otherwise
1008
+ (continuing D-004), but it is deliberately stricter than the daily reader: **every** request needs a
1009
+ per-run token, Origin is a second gate, and POSTs must be JSON. The token never reaches a command
1010
+ line - the Electron shell reads the endpoint file itself and installs the token as an `HttpOnly`
1011
+ cookie, while the browser fallback uses a one-time 60-second code.
1012
+
1013
+ Also in this change, because the panel made them visible: `assistant start` **no longer refuses to
1014
+ start when the WeChat channel is not logged in** (it used to throw before doing anything, so
1015
+ "not logged in" meant "no assistant at all" - a field observation recorded in PROJECT_STATE), and
1016
+ `assistant status` gained `channelActive` / `mode` / `panelPort` / `memoryBucket`, because
1017
+ `messageChannelLoggedIn` only ever answered "is a token configured".
1018
+
1019
+ **The ball needs Electron, and Electron is not a dependency**: with none installed, `panel` falls
1020
+ back to Edge/Chrome `--app` - a small window without an address bar, but **not** a floating ball (no
1021
+ frameless, no always-on-top, no tray, no global hotkey), and the command says so. Install Electron
1022
+ (`npm i -g electron`, or `npm install` in a checkout - the binary is downloaded by its own install
1023
+ script) and the same command opens the ball. Verified on Windows 11 with Electron 42: 76x76, no
1024
+ caption, always-on-top, the renderer authenticates through the cookie, and the ball is visible on
1025
+ screen, and the interactive paths were driven without a click (DevTools protocol for the ball-to-chat
1026
+ resize, a differential registration test for the hotkey). Doing that found two bugs a screenshot
1027
+ could not have shown: collapsing left the window at chat size because a non-resizable window
1028
+ ignores `setSize` on Windows, and the window sometimes never appeared because `ready-to-show` was
1029
+ listened for only after the load. A third came out of running what the tray items actually do: the
1030
+ "quit and stop the assistant" action spawned the CLI in a form commander rejects inside Electron's
1031
+ Node mode. Only a click on the tray menu itself remains untested; each item's effect has been run
1032
+ directly.
1033
+
1034
+ The ball also now **sits in the bottom-right corner and stays where you drag it**. It used to open
1035
+ wherever Windows felt like it (measured 815,418, mid-left) and forget the position on every
1036
+ restart. The remembered spot is checked before use: if it is no longer reachable - a monitor was
1037
+ unplugged, or the ball was dragged off-screen - it falls back to the corner instead of leaving the
1038
+ ball somewhere invisible. Expanding to the chat window and collapsing back re-fit into the work
1039
+ area, so a 420x560 window no longer hangs off the screen when opened from a bottom-right corner.
1040
+
1041
+ - **The four "search" tools now name each other.** They search four different stores - chat logs,
1042
+ the knowledge base, assistant memory, and a semantic index over chats - and each description used to
1043
+ explain only what it searched, not how it differed from its siblings. `search_knowledge` did not say
1044
+ it is *not* chat history; `search_memory` did not say what "memory" means here. Each now names the
1045
+ store it covers and points at the others. Three eval cases pin the choice ("where did I mention X" must
1046
+ use the chat search and must **not** reach for the knowledge base), which is the part that was never
1047
+ measured.
1048
+
1049
+ Found while writing those cases: **an empty fixture invites retries.** With a stubbed search that
1050
+ returns nothing, the model re-queries with different keywords - seven calls in one run, each with
1051
+ different arguments, so the identical-call guard cannot help. That is the fixture's doing, not a
1052
+ defect, so the fixture now returns a hit (and where it cannot, the count sits in the soft budget).
1053
+
1054
+ - **Tools this machine cannot run are no longer offered to the model.** `search_semantic` needs a
1055
+ `dashscopeApiKey` and `get_weread` needs `wereadApiKey`; with neither configured the tools were still
1056
+ in the tool list, so the model tried them and got errors back - the eval's ambiguous-contact case
1057
+ ended with a reply that reported "both search tools failed", which is not the assistant's fault but
1058
+ ours for offering a tool that cannot run. The list is now built by `availableToolDefs()`, which drops
1059
+ a tool when its prerequisite is missing, and the fast path - which dispatches **directly**, bypassing
1060
+ the list - goes through the same predicate before dispatching. The filter is deliberately narrow: it
1061
+ only drops tools that *cannot* run, never tools that merely have no data yet, because "run
1062
+ `weflow-cli wiki compile` first" is a useful answer while an authentication error is not.
1063
+
1064
+ - **The eval can now run a conversation, and it separates floors from expectations.** Multi-turn
1065
+ cases let it check the half of memory that was never verified: storing a fact was covered, **using** it
1066
+ was not. `memory-recall` says "remember I'm allergic to peanuts", then asks what to order for dinner;
1067
+ the hard floor is that the fact is in long-term memory, and whether the reply brings it up is reported
1068
+ as a soft expectation - it failed in one run out of three, and an intermittent red trains people to
1069
+ ignore the report. Same principle applied across the board: every `maxTools: 3` became a runaway guard
1070
+ (6) plus an efficiency budget (3), because the same question produced anything from 2 to 7 calls. The
1071
+ criterion is one line: **if the number or expectation moves with the model's route or wording, it is a
1072
+ budget, not a floor.** The only hard cap left is `no-tool`'s 0 - calling a tool when none is needed is
1073
+ a real defect. And `unknown-contact` stopped asserting on wording: the model's way of saying "not
1074
+ found" is unbounded (没找到 / 查不到 / 不存在 / …), so that case now asserts the **observable fact**
1075
+ that the tool call returned nothing.
1076
+
1077
+ - **A reading-stats tool, and the eval learned to tell a floor from a budget.** `get_reading_stats`
1078
+ answers "what have I been reading / which accounts post the most" from the local archive (it reuses
1079
+ `daily_stats.py` rather than recomputing the same numbers). Its second purpose is honesty about
1080
+ coverage: when the daily has not run, every account's processed count is zero, and saying "nothing was
1081
+ processed in these 7 days; the last report with content was 2026-09-05 (19 days ago)" is a different
1082
+ statement from letting the user believe those accounts had no content. That is how the staleness was
1083
+ found - a 7-day window showed all zeros while a 30-day window did not, and the difference was not the
1084
+ accounts.
1085
+
1086
+ The eval now separates two things it had conflated: **floors are hard, budgets are soft.** The
1087
+ ambiguous-contact case was failing intermittently on a tool-call cap of 5 while every single run
1088
+ correctly asked which person was meant - the same question produced anywhere from 2 to 7 calls, so
1089
+ the cap was measuring the model's route rather than a defect. `maxTools` is now only a runaway guard
1090
+ (raised to 8) and `toolBudget` reports exceeding the budget as a warning that never fails the run.
1091
+ A flaky case is worse than no case: it teaches people to ignore the report.
1092
+
1093
+ - **`contacts -k` only searched the first N rows, and the assistant's name lookup had a blind spot.**
1094
+ Two defects found by trying to resolve a person by name on real data. First, the keyword filter ran
1095
+ **after** `LIMIT`: `get_contacts` fetched the first `limit` rows of `Name2Id` and only then filtered
1096
+ them, so on a 500-contact address book anyone past row 200 was invisible to a search - measured, a
1097
+ lookup by remark hit 3 of 10. The filter now runs first and truncates afterwards (extracted as
1098
+ `filter_contacts`, with tests). Second, `resolveTalker` searched only the most recent **300 sessions**;
1099
+ a name that is not in them fell through to "treat the query as a talker id", so the assistant reported
1100
+ "no messages" for someone who is plainly in the address book. Resolution now falls back to the contact
1101
+ book, matching remark / display name / nickname / alias / username, and still refusing when the match
1102
+ is not unique. Measured on 378 real contacts that are outside the recent sessions: **362 resolved, 10
1103
+ correctly refused for duplicate names, 0 ever resolved to the wrong person**, 5 single-character names
1104
+ still miss - and a miss is safe, because it returns null and the assistant says it could not find
1105
+ them rather than reading someone else's chat.
1106
+
1107
+ - **The assistant can see time now, and can reach a specific day.** Two halves of one gap: the
1108
+ system prompt carried no current date, so "上周三" had nothing to resolve against, and `get_messages`
1109
+ took only a message count - meaning a day far enough back was simply unreachable (the model either
1110
+ said it could not find it or, worse, answered from the most recent messages as if they were that
1111
+ day's). The prompt now opens with `[当前时间] 2026-09-23(星期三)09:05`, and `get_messages` accepts
1112
+ `since`/`until` (`2026-09-16`, or relative `3d` / `2w` / `12h`, resolved by day boundary rather than
1113
+ an exact 24 hours because that is what people mean). With a window it reads through
1114
+ `getMessagesInRange` and says which window it used; without one, behaviour is byte-identical to
1115
+ before. An unparseable time is a readable parameter error - guessing a window would be worse, since
1116
+ the model would then believe it had queried the period the user named.
1117
+
1118
+ - **The same call twice in one turn is now skipped.** The eval caught this rather than a person: its
1119
+ `ambiguous-contact` case produced **seven** tool calls, three of them the identical `list_sessions`.
1120
+ Repeating an identical call cannot return anything new, so the loop now returns a note
1121
+ ("this step is identical to an earlier one; use the result already in the conversation") instead of
1122
+ executing, and records the skip in the trace. The case dropped to four calls and passes.
1123
+
1124
+ - **The assistant now records what it did on the way to an answer, and you can read it.**
1125
+ `weflow-cli assistant trace` prints the last few turns, and sending `轨迹` in WeChat returns the previous
1126
+ one. Each record carries the fast-route's decision, every tool call with a **redacted argument summary**
1127
+ and the size of what came back, how many model round trips it took, and why it stopped
1128
+ (`answered` / `rounds-exhausted` / `llm-error` / `builtin`). Until now the only output was the reply:
1129
+ `TURN_DONE tools=5` said five tools were called and nothing about which five or with what arguments -
1130
+ which is exactly the question that could not be answered the one time it mattered (a turn that called
1131
+ `search_favorites` twice and `read_favorite` twice).
1132
+
1133
+ Two things are deliberately separate. The **audit** is unchanged: events and byte counts, never
1134
+ content, because it is the egress record. The **trace** is a local debugging artifact and does contain
1135
+ argument summaries, redacted and truncated at 40 characters per value; the in-chat rendering leaves the
1136
+ machine (as the reply already does) and omits `userId`.
1137
+
1138
+ On chain of thought: the trace carries whatever reasoning the provider returns in
1139
+ `reasoning_content`, clipped at 800 characters. The default `deepseek-chat` does not return any, so the
1140
+ field is empty and the trace says "无" rather than implying it thought something. Switch to a model that
1141
+ returns it (`config set aiModel deepseek-reasoner`) and the text shows up in the CLI view; it is never
1142
+ fed back into the conversation, since it is the model's monologue rather than an answer. See D-043 for
1143
+ the boundaries - including why "did this step produce anything" is a documented convention with a
1144
+ test-guarded whitelist rather than a real outcome field.
1145
+
1146
+ - **The assistant has a behaviour eval now** (`npm run eval:assistant`). Until this, there was no way
1147
+ to know whether the assistant was any good: the unit tests all inject a fake model (they prove the
1148
+ code paths still work, not what a model does with 16 tools), and the only other feedback was talking
1149
+ to it in WeChat and noticing problems by luck. The eval runs twelve synthetic cases against the real
1150
+ model and asserts **floor conditions**, not quality: did it call the tool that could answer the
1151
+ question, and did it not reach for tools when none was needed; did tool usage stay bounded (one
1152
+ question in the archive used five calls); does a **failing** tool get reported as failing - the case
1153
+ that earns its keep, since a script returning exit code 2 produces a reply that quotes the error,
1154
+ names the likely cause and offers an alternative, instead of "no one is waiting on you"; is an
1155
+ unknown contact ever given invented content; and is memory judged by **the outcome** (the fact
1156
+ landing in long-term memory) rather than by whether `save_memory` was called.
1157
+
1158
+ Data is synthetic and the whole run happens in a temporary home directory, so the real audit log and
1159
+ memory file are untouched - writing the memory file from a second process would otherwise clobber
1160
+ whatever the daemon had just written. Only the model call leaves the process; any other request
1161
+ throws. The key is read from the real config (a `lock:` ciphertext that only decrypts on this
1162
+ machine) and the temporary config holds that one field and nothing else.
1163
+
1164
+ Three limits are documented with it, because they are the difference between a useful instrument and
1165
+ a reassuring one: the expectations are the author's, not human labels, so this measures floors rather
1166
+ than quality; a first run that comes back green proves little, since cases and behaviour share an
1167
+ author - the value is in re-running it after a change; and **false failures are a real hazard**: this
1168
+ suite's own first version asserted the model would say "查不到" and failed twice on "查不了", so
1169
+ assertions now key off the tool's own diagnostics (error text, exit code) rather than the model's
1170
+ wording. The run history, kept as it happened rather than tidied: **6/8** (one fixture bug - the
1171
+ `getMessages` stub ignored the talker, so a non-existent contact was served another conversation's
1172
+ messages and the assistant reported them as theirs - and one false failure) → **8/8** → **8/8** →
1173
+ **7/8** (the same false failure again) → **8/8, 8/8, 8/8** after the assertion was rewritten. Both
1174
+ failures were the assertion's fault, not the assistant's.
1175
+
1176
+ Four more cases followed (favourites search, a refused fetch of an internal address, two asks in one
1177
+ message, an ambiguous contact name - the last two guard "ask which one instead of guessing" and "serve
1178
+ both halves"), and their first runs produced **11/12 twice**, again entirely the assertions' fault:
1179
+ one case demanded a refused fetch be described with words the model did not use ("抓不了" versus its
1180
+ "抓不下来"), and another was capped at three tool calls when four was reasonable exploration. The
1181
+ rewrite settled on rules now written into OPERATIONS: **match stems rather than whole words**, **never
1182
+ assert on a forbidden word** (the model said "this is a local problem, **not** that nobody is waiting
1183
+ on you" and the regex read it as the opposite), and ask before tightening a cap. Three consecutive
1184
+ 12/12 runs after that.
1185
+
1186
+
1187
+ - **The calibration harness now covers what the report actually decides, and gained a half that needs
1188
+ no labels at all.** `quality_eval.py` sampled articles and asked you to label `topic` + `include`;
1189
+ `relevance` was not in that list even though **it** is what the admission rule reads. The label file
1190
+ now has three fields, `score` reports relevance agreement **and the mean error in levels** (two
1191
+ articles both labelled 中, one 0.4 too high and one 0.4 too low, score 100% accuracy while the score
1192
+ is visibly misaligned - accuracy cannot see that), and there is now a second threshold sweep: the
1193
+ relevance cuts `0.5`/`1.5` were written with the comment "暂定" because there was nothing to calibrate
1194
+ them against, and this is the thing that calibrates them. The printed sheet is **blind** - it no
1195
+ longer shows Jev's answer next to each title, because seeing it first is an unmeasurable inflation of
1196
+ the agreement being measured.
1197
+
1198
+ - `quality_eval.py consistency <file>` is the **label-free** half: it reports how often the two
1199
+ questions answer the same article two ways (high relevance with nothing usable, or low relevance with
1200
+ something usable). This came from `jev-chat-jarvis` (the vendored reference implementation of this
1201
+ exact pattern), whose task spec makes "questions must not contradict each other" a hard requirement
1202
+ and then needs a labelled set to enforce it - but a contradiction is self-evident, so this number is
1203
+ available today, without a single human label. Items missing either score are counted as undecidable
1204
+ rather than as agreement, which is the same discipline the rest of this repo applies to missing
1205
+ values.
1206
+
1207
+ - `jev_probe.py --criteria-ab` asks the same articles twice, current criteria (Chinese, score bins that
1208
+ name an abstraction level) against an English variant whose score bins describe concrete scenes, both
1209
+ rules taken from `jev-chat-jarvis`'s hard constraints. Measured here on 24 and 12 real articles: topic
1210
+ agreement 79% and 67%, relevance raw-score mean absolute difference 0.17 and 0.24, disagreements
1211
+ concentrated on the AI↔学术 boundary (a paper about a method) and 文学↔新闻. **These numbers say what
1212
+ a wording change moves, not whether it improves anything** - that still needs the labelled set, which
1213
+ is why the variant stays a candidate and the production criteria are unchanged.
1214
+
1215
+ - **The assistant can look at a picture.** 15.5% of the messages in one measured 30-day archive are
1216
+ images (242 of 1564), and the model used to see `[图片]` and nothing else - the largest remaining gap,
1217
+ and one no amount of prompt work closes. `get_messages` now renders an image as `[图片 #1234]`, and a
1218
+ new `look_at_image` tool takes that number, decrypts that one image **locally**, and attaches it to the
1219
+ next request as a real image. Looking is on demand: one image per call, at most two per turn.
1220
+
1221
+ **An image is data leaving the machine, and it is treated as such.** `strict` mode (`assistantPrivacy`)
1222
+ holds images back at two layers - the tool refuses to fetch, and the request builder drops anything that
1223
+ got through anyway - and a drop is stated in the message body rather than happening silently. The
1224
+ `#N` handle is not even shown in `strict` mode: advertising something the tool will certainly refuse is
1225
+ worse than not mentioning it. The audit gained `IMAGE_SENT` and `IMAGE_HELD` lines (byte counts and
1226
+ message ids, never content).
1227
+
1228
+ Reading is 0.9 s for a direct chat and up to ~19 s for a group with 30k messages, because the media
1229
+ index is rebuilt per read; a resolved image is cached under `output/.cache/read-image/`, so a second
1230
+ look at the same picture is cheap. Images never enter memory. See D-042 for the boundaries, and note
1231
+ that this makes a third-party vision model part of the loop - the same content already went to
1232
+ DeepSeek as text, but images are a new class of it.
1233
+
1234
+ - **Drafted replies** (`weflow-cli draft <联系人>`, and `draft_reply` in the assistant). Given one
1235
+ conversation it asks a decision model seven typed questions - what the other side is after, what they
1236
+ need, which action type fits, whether substance is owed, how risky it is (0-9), whether money is
1237
+ involved, whether a commitment is outstanding - then writes and ranks candidate replies. The pattern is
1238
+ borrowed from `jev-chat-jarvis`; the two things it does *not* do are the two worth doing, and both were
1239
+ read from its source: its risk level is only a badge colour (it never refuses a draft), and its judgement
1240
+ never reaches the drafting prompt. Here the judgement **is** injected, and the user's own earlier
1241
+ messages are offered as a tone sample. **It produces text and stops there** - no input-box filling, no
1242
+ key simulation, no focus stealing; `capabilities --json` reports `sendsNothing: true`.
1243
+
1244
+ Money, or risk >= 7, means **no draft at all**: you get what the risk is and what to confirm first. That
1245
+ is the answer, not a failure. The 0-9 threshold is **picked, not calibrated**, so the band explains the
1246
+ refusal and does not decide alone; an outstanding commitment deliberately does *not* refuse (that is
1247
+ exactly when a draft helps) and constrains the prompt instead (`--gate-commitment` makes it hard).
1248
+ Three model calls per run, all reported by `--dry-run` before anything leaves the machine. In the
1249
+ assistant the transcript is masked first and handed over on stdin, and `strict` mode refuses outright -
1250
+ drafting has to send the body to remote models, and drafting from a body masked into "content N
1251
+ characters" would produce a fluent answer built on nothing. Text in the panel now carries a copy button,
1252
+ which says so when the clipboard write fails instead of claiming success. See D-047.
1253
+
1254
+ Two things about the judgement are recorded rather than settled: the criteria are **English** (following
1255
+ the reference implementation, whose `TASK.md` states that is the model's main training language) while
1256
+ the money/commitment questions are reused **verbatim in Chinese** from `reply_debt` - two wording
1257
+ conventions against one model with **no A/B run**; and nothing here is calibrated against a gold
1258
+ standard, so "is this draft any good" has only your own reading as a check.
1259
+
1260
+ - `strict` mode now keeps the **type** of a non-text message. Its own rule says "time, direction and type
1261
+ only", and masking `[图片]` / `[文件] Base.csv` into `[内容4字已按严格模式屏蔽]` was throwing the type
1262
+ away too - the model could not tell an image from a text message, and learned nothing that was not
1263
+ already allowed. The payload is still withheld. A message the user *typed* that happens to start with
1264
+ `[图片]` is still masked as text: only the reader's own non-text labels count as labels.
1265
+
1266
+
1267
+ - **Right-click in the panel for a quick reply.** A new `quickReplyContacts` config key
1268
+ (`weflow-cli config set quickReplyContacts "咸鱼梦想家,老王"`) holds the contacts the menu offers; picking one asks the
1269
+ assistant to draft candidate replies for that person through the existing `/api/ask`, so quota, the serial queue, memory and
1270
+ the copy button all come along unchanged. The list rides on `/api/status`, which the page already polls every 30 seconds -
1271
+ no new endpoint, no new IPC, and the preload surface is untouched. Three details are deliberate: the menu **states its own
1272
+ cost** ("drafting sends this conversation to two cloud models") instead of letting you find out afterwards, an empty list
1273
+ shows the command to run rather than an empty box, and the menu is **native** (built in the main process), so it draws
1274
+ outside the window - the ball is 76x76 and never expands for it. (The first version did expand it, and the user said what
1275
+ that felt like: right-click makes the 第二大脑 window pop up.) The browser fallback does not take over right-click at all -
1276
+ the native menu's Copy belongs to the user.
1277
+
1278
+ Two follow-ups from using it: the window now **raises and focuses itself when it expands** (the chat window is not
1279
+ always-on-top by design, and closing a native popup can hand focus back elsewhere - the report was "I do not know where
1280
+ the answer went"), and the conversation shows a readable turn (`快速回复:<name>`) while what is actually sent is the
1281
+ explicit tool-naming request, so the chat log does not fill up with machine-shaped instructions.
1282
+
1283
+ - **The same right-click menu can now put the ball away.** Its last item is the only one in that menu that reaches no
1284
+ model at all: it hides the window instead of quitting the panel, so it is reversible by design - and that is exactly
1285
+ why the label names the way back (`关闭悬浮球(托盘图标能再打开)`). The ball runs with `setSkipTaskbar(true)`, so
1286
+ once it is hidden there is nothing on the taskbar to click; a label that just said "close" would read as "it is gone
1287
+ for good". Keeping "hide" and "exit" apart is the same line the tray menu already draws (its two exit items sit after
1288
+ a separator, away from show/expand), so no exit action was added here. The item is also the **only one present when
1289
+ the contact list is empty** - a user who never configured `quickReplyContacts` would otherwise get a menu of two grey
1290
+ lines that does nothing when clicked, including no way to dismiss the ball.
1291
+
1292
+ - **The semantic index's window is adjustable, and the preview says what it is.** How far back the index reaches was
1293
+ hardcoded at the two call sites, and neither the command nor its preview mentioned it at all: 90 days of chat and 30
1294
+ date-directories of articles. So "a personal knowledge base" quietly meant "the last three months", with nothing on
1295
+ screen saying so. `search-index` now takes `--days` and `--article-days`, and both the read-only preview and the
1296
+ confirmation prompt state the window they will use - the window is part of the cost, because more days means more
1297
+ text to the embedding service. **The defaults are unchanged (90 / 30)**: this makes a fixed number adjustable rather
1298
+ than changing what a build costs. Out-of-range values are refused before anything is read, with `0` called out
1299
+ specifically - a zero-day window builds an *empty* index that looks like a successful build, which is the same shape
1300
+ of failure as the `collect_articles` glob bug that once made the article half of the index silently empty. The
1301
+ script also reports the effective window in its JSON, which the CLI passes through, so a real run states what it
1302
+ used rather than leaving the user to infer it.
1303
+
1304
+ - **The interactive menu is the last extension point that had nothing watching it - now it has a guard.**
1305
+ `test/cli-menu.test.ts` requires the menu's entries and `showInteractiveMenu`'s `switch` cases to match **in both
1306
+ directions**, and every `runCmd` / `runSubCmd` the menu calls to name a command that actually exists. Both failures are
1307
+ silent by construction: an entry with no `case` does nothing when picked, and `runCmd` is written as
1308
+ `if (cmd) await ...`, so a renamed command makes an entry do nothing too. The command list is taken from the CLI's own
1309
+ `--help` rather than from the source, because the source cannot tell the two apart - 64 `.command('...')` call sites
1310
+ against 44 real commands. Four mutations (rename a menu value, rename a `case`, point `runCmd` at a missing command,
1311
+ point `runSubCmd` at a missing subcommand) each turn a specific assertion red, and `docs/EXTENDING.md`'s recipe C no
1312
+ longer carries a "nothing will tell you" caveat.
1313
+
1314
+ - **`docs/EXTENDING.md` - what to touch when adding to this project, and what will catch you.** The repository had no
1315
+ document for its most common kind of change, so "add an assistant tool" existed only as tribal knowledge spread over
1316
+ several files while "add a config key" was a comment at the top of a test. The guide gives one recipe per extension
1317
+ kind (assistant tool, config key, CLI command, hand-written MCP tool, Python workflow), each with the places to touch
1318
+ and **the test that fails if you miss one** - and, just as usefully, the two places where *nothing* will fail: the CLI
1319
+ command's interactive-menu entry has no guard at all. It also states plainly that there is no plugin loader and no
1320
+ public import surface, why that is deliberate (an in-process plugin would hold the same reach as the code it joins -
1321
+ database path, every API key, the message channel - against a standing rule that those boundaries stay explicit), and
1322
+ what a third party can use today instead (MCP, which is enumerable, scoped per tool, and switchable off).
1323
+
1324
+ ### Changed
1325
+
1326
+ - **Drafting no longer stops when the judgement model is unreachable.** Jev's free period ended on 2026-09-25, and
1327
+ the first failure it produced here was already visible to a user as a bare "the tool errored (script exit code 1)"
1328
+ - with the assistant then *inventing* a reason for it ("the model was inconsistent about whether to promise
1329
+ something"), because the tool only reported the exit code. The judgement step is now degradable: when that call
1330
+ fails, drafting continues on DeepSeek alone and returns the candidates **with `judged: false`** plus the reason, so
1331
+ the CLI prints "这次没有判断…闸门没生效" before the list and the assistant tool prints the same. That warning is the
1332
+ point: the gate (money, or risk >= 7) exists only because a judgement exists, so a degraded run must not look like
1333
+ a normal one - and it must not look like one *to the model relaying it* either. Availability follows the same
1334
+ logic: `deepseekApiKey` is now the only required key, and a missing `typesafeApiKey` no longer hides the tool.
1335
+ Verified end to end with a deliberately bad Jev key: the candidates come back, the warning comes back, and the
1336
+ tools never send anything.
1337
+
1338
+
1339
+ - **Dragging the ball in the chat window grew the window by the drag distance, and the extra width showed up as
1340
+ a gap between the ball and the bubble.** `panel:dragMove` re-read the window's *current* size with
1341
+ `getContentBounds()` and wrote it straight back with the new position - a read-modify-write, and the read is
1342
+ the one that lags. Measured with a probe driving the same IPC sequence (30 steps, a 30x24 move) on a window
1343
+ with the panel's own options: with `resizable: false` the size does not move at all, and with
1344
+ `resizable: true` (which is what the chat window is) it drifts by **exactly the drag delta, 30x24**. The size
1345
+ is now captured once in `dragStart` and reused for every move, which measures 0x0 in both cases. The symptom
1346
+ matched: the bubble is pinned to the window's left and the ball to its right, so any width beyond
1347
+ "bubble + gap + ball" appears between them - and expanding or collapsing re-asserts the exact size, which is
1348
+ why clicking the ball "reset" it. A test pins that `dragMove` never calls `getContentBounds()`.
1349
+
1350
+
1351
+ - **The MCP surface now says what it actually is, and `draft_reply` on that surface needs an explicit
1352
+ confirmation.** Two claims were wrong and are corrected here rather than dropped: `capabilities --json`
1353
+ reported `safety.mcpDefaultReadOnly: true`, and the MCP guide said the default surface is read-only. The
1354
+ surface is **derived from the assistant tool table minus `save_memory`**, so it has contained a file writer
1355
+ (`export_chat`) and a model-calling tool (`look_at_image`) since those shipped. `capabilities` now reports
1356
+ `mcpDefaultReadOnly: false` plus a `safety.mcpSurface` breakdown (`writesFiles`, `callsCloudModels`,
1357
+ `requiresConfirm`), and the guide's tool table lists the whole surface instead of the eleven hand-written
1358
+ entries. Auditing that surface turned up one more thing: it lists **four** tools that send user data to cloud
1359
+ models (`who_owes_reply`, `search_chats`, `search_semantic`, `draft_reply`), not the two the first version of
1360
+ that declaration guessed, and `look_at_image` is now **excluded from the surface entirely** - it hands its
1361
+ image to the assistant's own model through a side channel MCP never reads, so over MCP it answered "you can
1362
+ see it now" while shipping nothing. The two tests whose names said "read-only tool surface" but only asserted that publishing, sending
1363
+ and memory writes are absent are renamed to say what they check.
1364
+
1365
+ Because drafting sends a conversation to two cloud models, an **MCP call without `confirm: true` returns a
1366
+ preview only** - how many messages, how many characters, which two models - and nothing leaves the machine.
1367
+ The panel and the WeChat bot keep working without that flag: their boundary is the sender allowlist plus a
1368
+ user asking in a conversation only they can see, whereas an MCP client is a third-party process whose
1369
+ session nobody here can observe. `capabilities.safety.mcpSurface.requiresConfirm` declares it, the tool
1370
+ description states it (a calling model that does not know it cannot ask its user), and a new test drives the
1371
+ real MCP protocol to prove that a call without the flag never produces a draft. The same gate now covers
1372
+ the other three tools that send user data to cloud models (`who_owes_reply`, `search_chats`,
1373
+ `search_semantic`) through one shared message shape - each tool fills in what it actually sends, and the
1374
+ two whose scripts support `--dry-run` show real counts.
1375
+
1376
+
1377
+ - **The ball no longer disappears when you open it: the conversation unfolds beside it, like an icon
1378
+ that is talking.** The window is now "ball + gap + bubble" as one piece (508x560), with the ball
1379
+ pinned to its own corner and the bubble growing out of the other side. The ball is the speaker, so it
1380
+ stays put - it is a **toggle** (click to open, click again to close), and the geometry is computed by
1381
+ `bubbleLayout` in `ball-position.cjs`, a pure function CI drives with synthetic displays. It prefers
1382
+ the bubble on the ball's left, bottom-aligned (the ball defaults to the bottom-right, so the bubble
1383
+ grows up-left without covering anything), flips to the right when there is no room on the left, and
1384
+ switches to top-alignment when the bubble would run off the top of the work area - a popover, in
1385
+ short. A 12px CSS tail points at the ball's centre. The mode still has **one source of truth** (the
1386
+ main process announces `{mode, side, anchorY}`; the page only requests changes), so the ball, the
1387
+ collapse button, the tray and the hotkey all take the same path, and `prefers-reduced-motion` still
1388
+ stops both halves. The page's layout moved into a new `#bubble` wrapper element, which is what carries
1389
+ the rounded corners and the background - the window's background is transparent now in both modes, so
1390
+ the gap beside the ball really shows the desktop.
1391
+
1392
+ Measured on a real Electron window with the real page (throwaway probe, 150% display scaling), because
1393
+ two of the three things below were **not** visible by reading the code:
1394
+
1395
+ - **The ball does not move.** Its offset inside the window is exactly 0 at every sample, the window's
1396
+ anchored corner is held constant, and sampling the ball's screen position through a whole expand and
1397
+ a whole collapse gives a spread of **at most 1px** (page-side, 23 and 30 samples). At rest before and
1398
+ after, the ball's screen position is identical (1608.33 both times; the 0.33 is the 150% scale).
1399
+ - **Two bugs found by measuring.** (1) The tween rounded each of the four edges independently, so the
1400
+ anchored edge wobbled by 1-2px (measured right-edge values 1683/1684/1685) - and since the ball is
1401
+ CSS-fixed to that edge, that wobble *is* the ball moving. `boundsAt` now takes the corner to pin and
1402
+ derives the coordinates from that edge, so it is exact. (2) Collapsing placed the ball at the
1403
+ **window's top-left** instead of the corner it actually sits on, which would have made it jump to the
1404
+ bubble's opposite corner; `ballRectInWindow` (also pure, now tested as a round-trip: for all four
1405
+ corners, expand-then-collapse returns the ball to where it started) fixes it.
1406
+ - **A platform wrinkle worth knowing**: asking this window for 76x76 at 150% scaling yields a ~79px
1407
+ client width - Windows enforces a minimum window width. Everything stays consistent because the ball
1408
+ is anchored to the edge rather than to a computed size, but the window is a few px wider than the
1409
+ ball, and the remembered position (`~/.weflow-cli/panel_position.json`) is the window's, not the
1410
+ ball's visual left edge.
1411
+
1412
+ Frames land every 28-45ms (3-8 frames for a 190ms tween across runs), because the cost is the actual
1413
+ window resize, not the 16ms timer; the motion is time-based, so it drops frames rather than stretching.
1414
+ `BALL_SIZE` and the new `BUBBLE_SIZE`/`BUBBLE_GAP` live in `ball-position.cjs` only, with tests pinning
1415
+ the CSS copies (`--ball-size`, `--bubble-width`, `--bubble-gap`) to them. How it *looks* is still a
1416
+ human judgement - a screenshot cannot settle it, because GDI capture misses DWM-composited transparent
1417
+ windows.
1418
+
1419
+ ### Fixed
1420
+
1421
+ - **91% of the article notes had WeChat's own reader UI text inside their summary.** The article body comes from
1422
+ WeChat, so it carries the reader's widget text and the footer's 原创 marker with the account name pasted three
1423
+ times - and the summary is built from the article's first lines, so it went straight in. Measured over the corpus:
1424
+ **1484 of 1633** notes are affected (an earlier count of 330 in this work was wrong - it counted one exact phrase
1425
+ over whole files, and the residue has several spellings). The cleaner now runs where notes are written and where
1426
+ knowledge cards are built, and re-running it over the corpus leaves **0** affected.
1427
+
1428
+ Two things about it are worth recording. The pattern list that already existed in `classify_daily.py` was used only
1429
+ on the daily-report path - the note writer never called it - and comparing it against the real string showed it
1430
+ **missed one of the three reader phrases**: the actual text is 在**公众号**小说中沉浸阅读 while the list had
1431
+ 在小说阅读器**中**沉浸阅读. The list now covers the observed forms, lives in `_utils.strip_wx_ads` (one
1432
+ implementation; `classify_daily.clean_ads` delegates to it and keeps only its daily-specific rules), and strips the
1433
+ underscore runs and the repeated account name as well. It is deliberately narrow: a test asserts that a sentence
1434
+ containing 沉浸, an underscore in a variable name and the word 原创性 comes back untouched.
1435
+
1436
+ The **existing** vault notes still carry the residue - the cleaner applies to what gets written, not to what is
1437
+ already there - but the knowledge path cleans at the card step, so it does not reach concept pages. Rewriting the
1438
+ 1484 existing summaries is a separate, approval-shaped action and has not been done.
1439
+
1440
+ - **The wiki aggregator was reading the wrong fields and treating non-concepts as concepts, and scanning the whole
1441
+ corpus showed why the article line produces nothing.** This was found by running the aggregator over the real Vault
1442
+ rather than over fixtures - 1633 notes, read-only - which turned up four things at once:
1443
+
1444
+ - the notes' theme lives in `hasTopic: [[AI]]` (because the vault's own dataview queries use that field), while the
1445
+ aggregator read `topic`, so **every note's theme was silently empty**. It now reads both and strips the brackets;
1446
+ the writer was deliberately *not* changed, since renaming the field would break the user's Obsidian queries;
1447
+ - the summary section of those notes is headed `## 📋 摘要`, which the aggregator did not recognise - it was landing
1448
+ on the same paragraph only because the "first paragraph" fallback happened to reach it. That heading is now
1449
+ explicit, with the fallback kept for producers that write no heading;
1450
+ - **the body's `[[...]]` links are not concepts**: 1631 of 1633 notes have exactly one body link and it is their own
1451
+ theme (`> - **主题**: [[AI]]`), and the `## 🔗 关联网络` sections link **note paths** (`[[AI/某篇.md]]`). Taken at
1452
+ face value those produced a concept ranking led by "新闻 (752), 政治 (388), 学术 (341)" plus two `.md` file paths -
1453
+ a `--limit 20` run would have spent twenty model calls writing that into the Vault. Relation sections are now
1454
+ excluded by structure and path-shaped links by form;
1455
+ - and the conclusion: **after filtering, the article corpus yields zero concepts**, because these notes have no
1456
+ concept section at all. `wiki compile` cannot produce concept pages from articles until something extracts
1457
+ concepts from them - the same gap the conversation line closed on 2026-09-26 with `chat-notes`, which is the only
1458
+ source that produces them today. `--source`'s help text and `OPERATIONS.md` now state the requirement instead of
1459
+ pointing at a directory that cannot satisfy it.
1460
+
1461
+ The theme is also *used* now, not just read: it appears in the reference lines handed to page generation and as
1462
+ `topics` in a concept page's frontmatter (`topic` had been collected and dropped since the aggregator was written).
1463
+ Eleven tests cover it, six of them new here, including one that pins the measured corpus shape and one that asserts
1464
+ a relation section yields no concepts *whatever* is written in it. Mutation checks caught two of my own tests being
1465
+ unable to distinguish the two filters - each filter alone could be deleted without a failure, because the fixture
1466
+ only exercised them together - so both now have a case of their own.
1467
+
1468
+ - **The WeChat channel could die silently, and one of the three reasons was a wrong error code.** Checked
1469
+ against the vendor's own implementation (`third-party/WeKnora/internal/im/wechat/longpoll.go:151`), which
1470
+ encodes what this side had been guessing at: **`errcode: -14` is "this token is no longer valid"**, while this
1471
+ repository only ever looked at `-1 || 401`. A wrong code does not raise anything - it quietly reclassifies
1472
+ "expired" as an ordinary error, so the loop retried every 5 seconds, forever, while the user saw nothing.
1473
+ Three things changed, and the third is the one that matters most:
1474
+
1475
+ - `-14` (and HTTP 401) are now recognised, and treated differently from a network blip, because retrying an
1476
+ expired token cannot succeed;
1477
+ - retries use 1s→30s exponential backoff instead of a fixed 5 seconds, so a long outage no longer floods the
1478
+ log - noise is the same as saying nothing;
1479
+ - **the failure is now stated where a user will see it**: one unmissable log line naming the remedy, and
1480
+ `isChannelActive()` - which the panel shows - reports `false`, because it used to mean "a token was
1481
+ configured at startup" and stayed true forever after the token died. The panel now reads "仅本机入口",
1482
+ which is the truth, and the local entrance keeps working either way.
1483
+
1484
+ Also: the message callback was wrapped in `try { cb(msg) } catch {}`, so **any exception raised while the
1485
+ assistant handled a message vanished** - the user sent something, nothing happened, and the log had no trace
1486
+ of either. It is logged now. A fourth item from the same 2026-09-17 comparison turns out **not** to have been
1487
+ a defect and is retracted: this side uses a 40 s HTTP timeout for the long poll, and so does the vendor
1488
+ (`longPollHTTPTimeout = 40 * time.Second`, in the same file) - the "we might abort on the boundary" worry in
1489
+ that note does not hold.
1490
+
1491
+ Five tests were added for the parts that can be tested without a live channel: the classification table, the
1492
+ backoff curve, and a driven loop (a stubbed client reporting `-14` once and then a message whose callback
1493
+ throws) asserting that the expiry is reported **once** rather than every round, that the callback's exception
1494
+ is logged, and that the state recovers after a healthy poll. Four mutations - reverting the error code, the
1495
+ backoff, the channel-status semantics, and the swallowing - each turn one red. The live path (a real expired
1496
+ token) has not been exercised: it needs the server to say `-14`, which cannot be forced from here.
1497
+
1498
+ - **`draft`'s interactive confirmation was broken: answering "yes" ran nothing.** The command built the
1499
+ script's arguments *before* asking, so `--yes` was only ever present when it was passed on the command line;
1500
+ the interactive answer never became that flag, and the script - which has its own `--yes` gate - refused the
1501
+ run. What the user saw was "I confirmed, and it told me I had not". The two ways of saying yes are now one
1502
+ variable and the arguments are built after the decision, so the flag cannot be missing on one path while
1503
+ present on the other. The interactive path itself has no test (it needs a terminal), which is exactly why the
1504
+ fix is structural: the shape that could forget the flag no longer exists.
1505
+
1506
+ - **The transcript line was implemented twice, and the two copies had drifted in four ways.** Nothing failed - the two
1507
+ paths just fed different text into the same judgement and drafting prompts. The tool path
1508
+ (`assistantTools.transcriptLine`, panel and WeChat: mask, then send over `--stdin`) and the CLI path
1509
+ (`reply_debt.format_line`, used by `draft_reply.py --talker`, which reads the database itself) rendered the same
1510
+ message as `[9/23 12:20] 对方:…` and `[09-23 12:20] 老王:…`, truncated at 160 characters against 120, and marked the
1511
+ cut with `…` on one side only. The most dangerous of the four is the `我` marker: the scripts pick the user's tone
1512
+ sample by matching `'] 我:'` and decide who sent the last message from it, so one character of drift would disable
1513
+ both silently. The shape is now a single contract, enforced by `test/transcript-format-contract.test.ts`, which
1514
+ renders the same fixtures - self, other, non-text, over-long, a third person in a group - through both
1515
+ implementations and requires them to be equal character for character. Seven mutations, each reverting one
1516
+ difference or breaking one side's clip marker or clip length, turn it red.
1517
+
1518
+ Two things surfaced while measuring, and both changed the fix. The clip-length comparison had to take its value from
1519
+ the Python constant rather than the TypeScript one - feeding Python the TypeScript number made "both sides changed to
1520
+ 120" compare equal, so the first version of that test could not see its own subject. And `senderUsername` turned out
1521
+ to be **the raw wxid column** (`wcdbCore`'s `sender_username`, `sqlcipherCore`'s `StrTalker`), not a name, so using
1522
+ it as a speaker label sent an account identifier to two cloud models while the transcript only needed to tell
1523
+ speakers apart. Both implementations now use a resolved display name when there is one and `对方` otherwise, and
1524
+ neither falls back to the raw column.
1525
+
1526
+ The same contract also covers **how much conversation each path sends**: 30 messages (`TRANSCRIPT_MESSAGES` in
1527
+ TypeScript, `TRANSCRIPT_SIZE` in Python) alongside the 160-character limit per message. Those two numbers agree
1528
+ today, but they agreed by coincidence - neither was watched, and either one changing alone would have made the same
1529
+ conversation a different input depending on which entrance it came through. Two mutations, one per side, each turn
1530
+ the assertion red.
1531
+
1532
+ - **`get_messages` labelled speakers with a raw wxid, and that text goes to a cloud model.** The tool's output is a
1533
+ transcript the assistant reads, and every line from the other side was tagged with the value of `senderUsername` -
1534
+ which is a database column holding the sender's **wxid**, not a name. So asking "what did 老王 send me" produced
1535
+ lines tagged with an opaque account identifier: an identifier leaving the machine across a boundary the project
1536
+ treats as sensitive, in exchange for information the model cannot use. It is fixed by the same rule the drafting
1537
+ transcript now follows - a resolved display name when there is one, `对方` otherwise - and by one shared helper
1538
+ (`speakerLabel`), since "who is speaking" is exactly the kind of thing that grows a second implementation. This was
1539
+ measured before it was changed, and the measurement is the reason it *could* be changed: across 8 sessions and 179
1540
+ messages from other people, `senderDisplay` held a name 174 times, `senderUsername` was guid-shaped 176 times, and
1541
+ the two were never the same value - so using the name keeps a group's speakers distinguishable rather than
1542
+ collapsing them. The user's own lines still read `用户` there and `我` in the drafting transcript; those are
1543
+ different framings on purpose and each is pinned by a test.
1544
+
1545
+ - **The assistant was allowed to invent a reason for a tool failure, and did.** When drafting failed on 2026-09-25 the
1546
+ tool reported only "the judgement step failed (script exit code 1)" and the assistant answered with "the reason is
1547
+ probably that the model was inconsistent about whether to promise something" - a cause it had no evidence for. The
1548
+ real cause was a dropped connection while calling the judgement model, which the tool *did* know about; what the
1549
+ prompt lacked was a rule. It now has one: when a tool fails (its result starts with a bracket), relay only the reason
1550
+ the tool gave, and if it gave none - or only an exit code - say that, plus the next step. Guessing is named
1551
+ explicitly, because the user reads the guess as a fact. The assertion reads the system prompt **as actually sent to
1552
+ the model**, not the source string.
1553
+
1554
+ - **Drafting asked "what is their last message about?" even when the last message was mine.** The judgement questions
1555
+ are written around *their* last message, and the drafting prompt is told to reply to what they said - but nothing
1556
+ checked who sent the last line. If the user had sent it, the judgement interpreted **the user's own words** as the
1557
+ other person's position (intent, need and "what should I do" all answered against the wrong person), and those
1558
+ answers then went into the drafting prompt as context. Both prompts now receive the premise when it holds
1559
+ ("the last message is mine, they have not replied - do not read my words as their position"), and the premise is
1560
+ printed to the user ahead of the candidates, because otherwise the candidates look like answers to a message that
1561
+ does not exist. The fact is carried as a structured `isSend` field, never parsed back out of the transcript text -
1562
+ in a group chat the other side's label is a person's name, not `对方`. When the caller cannot say who sent it, no
1563
+ premise is produced at all: a guessed premise would be worse than none.
1564
+
1565
+ - **Nothing kept the tool table and its four consumers in agreement - now something does.** `TOOL_DEFS` (19 tools),
1566
+ the `executeTool` switch, the availability rules, the MCP exclusion table and the MCP documentation table were five
1567
+ hand-maintained lists with no assertion between them. The way that fails is silent in both directions: declare a tool
1568
+ without a `case` and the model sees it, calls it, and gets `(未知工具: x)`; add a `case` without a declaration and it
1569
+ is dead code no model can reach. `test/tool-registry.test.ts` now derives the set from the source and compares all
1570
+ five - both directions, names unique, every declaration usable, every name in the two lookup tables real, and the
1571
+ `docs/MCP.md` table equal to the actually-served surface. It found two live defects on its first run: the availability
1572
+ rules and the MCP exclusion table became **declarative one-line entries** (`TOOL_REQUIREMENTS`, `MCP_EXCLUDED`, the
1573
+ value being the reason) instead of name checks buried in if-chains, and **`wechat.export_messages` was missing from
1574
+ the `docs/MCP.md` table** - a tool that is served but was not documented, i.e. the same direction of drift as the
1575
+ duplicate row below. Five mutations (rename in the table, rename a case, a bogus exclusion name, a duplicated doc
1576
+ row, a renamed served tool) each turn a specific assertion red.
1577
+
1578
+ - **`capabilities --json` was under-reporting which MCP tools need `confirm: true`.** A machine-readable safety
1579
+ field that under-reports is the same class of problem as one that lies. `safety.mcpSurface.requiresConfirm`
1580
+ listed one tool (`draft_reply`) while **four** handlers carry the identical gate
1581
+ (`if (ctx.requiresConfirm && args.confirm !== true)` - `search_chats`, `who_owes_reply`, `draft_reply`,
1582
+ `search_semantic`). `docs/MCP.md` had been saying "all four" correctly all along; the two things that disagreed
1583
+ were the field and its test, because the assertion had been written by copying the field's value rather than by
1584
+ reading the code - so the pair stayed consistent with each other and wrong together. The assertion now **derives**
1585
+ the set from the source (every `case` block that contains the gate) and compares it against both `requiresConfirm`
1586
+ and `callsCloudModels`, so changing one without the other turns a test red; a mutation that restores the old
1587
+ single-name list fails it. The same audit turned up a **duplicate row** in the `docs/MCP.md` tool table:
1588
+ `wechat.who_owes_reply` appeared twice, and the stale copy was the one that did not say chat text leaves the
1589
+ machine. Deleted.
1590
+
1591
+ - **Clicking the ball did nothing.** The ball carried `-webkit-app-region: drag` so it could be dragged -
1592
+ and on Windows a drag region **swallows mouse events**, so the page never received the click. Dragging is
1593
+ now implemented in the page itself (pointer events, with a 4-pixel threshold that separates a click from
1594
+ a drag) and the native drag region is gone. The verification is the part worth recording: this had been
1595
+ "verified" earlier by calling `element.click()` through the DevTools protocol, which **bypasses real
1596
+ input** and happily reported a ball that could not be clicked. Both the new click and the drag are now
1597
+ confirmed with synthetic-but-real mouse input (`SetCursorPos` + `mouse_event`): 78x76 -> 421x560 -> 77x76,
1598
+ and a drag that moves the window without changing its size.
1599
+
1600
+ - **Dragging made the ball grow.** `win.setPosition` / `win.setBounds` on this window (frameless,
1601
+ transparent, non-resizable) operate on the **outer** rect and drift a little on every call - measured at
1602
+ roughly +0.8px per call, with no bound: 20 moves took 76x76 to 97x92, and the same happens without any
1603
+ mouse involved (calling the drag IPC directly reproduces it). `setContentBounds` (the client area) is
1604
+ stable - 20 moves, size unchanged. **That measurement was narrower than the sentence:** it was taken on the
1605
+ non-resizable ball window, and the claim does not survive on a *resizable* one if the size is re-read from
1606
+ `getContentBounds()` on every move - see the drag fix below. The same drift was quietly affecting `setMode` too: expanding measured
1607
+ 421x561 rather than the requested 420x560.
1608
+
1609
+ Every "atomic write" in this project (the memory file,
1610
+ the configuration, the panel's endpoint file) wrote a `.tmp` and renamed it over the target - and on
1611
+ Windows `rename` fails with `EPERM` when another handle has the target open, which antivirus and search
1612
+ indexers do briefly and routinely. The callers all record a reason and carry on, so the visible effect
1613
+ was nothing at all: one full-suite run here saved the memory file and the file came back without its
1614
+ `version` field, with the test green on the other three runs. The write now retries a busy target and
1615
+ then falls back to writing in place (D-046) - the bytes are the same either way, so only the atomicity of
1616
+ that single write is given up, and the test that reproduces it is in the suite.
1617
+
1618
+ - **"No pending todos" and "todos were never extracted" were the same sentence.** Todo extraction reads
1619
+ chat logs, so it runs only when the user invokes `weflow-cli todos extract --days N --yes` - it is a
1620
+ confirmed action, and nothing schedules it. On a machine where that has never been run, the todo file
1621
+ does not exist, `list` returns an empty array, and both the assistant tool and the terminal printed
1622
+ "nothing to do". Those are different claims: one is about the user's workload, the other about whether
1623
+ the question was ever asked. This was not hypothetical - on this machine `~/.weflow-cli/todos.json`
1624
+ does not exist, so `get_todos` was answering `(没有待办任务)` to every question about pending work.
1625
+ The script now reports whether the file exists (`list --json --meta` gives `{items, extracted,
1626
+ count}`), the assistant names the missing step and the command that fixes it, and `todos list` /
1627
+ `todos remind` say it too instead of congratulating an empty list.
1628
+
1629
+ The bare-array shape of `list --json` is deliberately unchanged - it is a published capability - so
1630
+ the new signal rides on a flag rather than a changed contract; the tool also falls back to the old
1631
+ wording if it gets an array. `mcp_bridge.py` had always made this distinction; the assistant tool was
1632
+ the one reader that dropped it.
1633
+
1634
+ - **The chat export tool reported a directory it may not have written to.** `export_chat` builds the
1635
+ destination as `output/exports/<name>-<timestamp>`, and on a name collision appends `-2`. The reply
1636
+ to the user was assembled from the *pre-collision* name, so the second export in the same second told
1637
+ them to look in `<name>-<timestamp>` while the files were in `...-2`. A write tool that names the wrong
1638
+ location is worse than one that names none: the user goes looking and finds nothing. The message is now
1639
+ built from the directory that was actually written; with the default root it stays a repository-relative
1640
+ path (`output/exports/...`, so it is followable), and with an overridden root it is the bare directory
1641
+ name - an absolute local path has no reason to enter the conversation.
1642
+
1643
+ Found by pointing the tool at a real database with the export root redirected to a temporary directory.
1644
+ The test that should have caught it had pinned the bug: it redirected the root *and* asserted the
1645
+ message contained `output/exports/`, so it was written to match the code rather than the intent. It is
1646
+ now two cases - overridden root reports the bare name, default root reports the relative path - and
1647
+ both assert no drive letter appears.
1648
+
1649
+ - **A failed memory save was silent.** `AssistantMemory.save()` ended in `catch { /* persistence failure
1650
+ must not break the conversation */ }` - the right *behaviour* (a disk hiccup should not drop the
1651
+ reply) with the wrong *silence*: the user says "remember this", the write fails, the memory is gone,
1652
+ and nothing anywhere records it. Found by accident: one flaky full-suite run failed two "memory
1653
+ survives a restart" tests and no line anywhere pointed at why. A failed save now records the error
1654
+ code in the audit (`MEMORY_SAVE_FAILED code=…`, never the message, which can hold a path), exposes
1655
+ the reason as `memory.lastSaveError`, and the in-chat `记忆` command prints it. That last one matters
1656
+ most: a user asking what the assistant remembers must not be shown an account of a memory that never
1657
+ reached the disk. The in-memory state was never at risk - a failed save leaves the dirty set intact,
1658
+ so the next save retries.
1659
+
1660
+ - The test for it needed its own file, and its first two versions were wrong in ways worth recording.
1661
+ The failure has to be manufactured **before** `assistantMemory` is imported (module-level paths are
1662
+ computed at import), so it cannot live beside the other memory tests. And the first fixture put a
1663
+ directory where the memory file goes, which does not work: `load()` reads a directory, throws
1664
+ `EISDIR` and takes its **quarantine** path, renaming the directory away - so by the time `save()` runs
1665
+ the name is free and the save succeeds. The failure being tested for did not exist, and the test
1666
+ reported "the reason was not recorded". The working fixture occupies the `.tmp` name instead, which
1667
+ fails the write while leaving the audit file writable.
1668
+
1669
+
1670
+ - **The labelling sheet could not actually be labelled.** `sample` printed the first 400 characters of
1671
+ the article body, and that region is boilerplate: `# title`, `> source / > time / > 阅读原文`, then the
1672
+ scraped copy of the article, which repeats the title and carries the cleanup leftovers. On the sample
1673
+ that produced it, most rows showed a title and a source name and nothing else - found by actually
1674
+ labelling a batch, where the only thing left to judge from was the title. The excerpt now takes the
1675
+ **`## AI 摘要` section**, the two or three sentences the judgement is really about; without a summary it
1676
+ falls back to the body after `## 正文`, then to the first non-boilerplate paragraph. Cleanup leftovers
1677
+ are matched by their invariant fragments (`在小说阅读器`, `沉浸阅读`) rather than by whole sentences,
1678
+ because the wording varies between articles.
1679
+
1680
+ - **`--seed` did not actually reproduce a sample**, despite the help text promising "两次抽样结果一致".
1681
+ Two causes, found one after the other: the pool was drawn in **concurrent completion order**
1682
+ (`as_completed`), and `rng.shuffle` consumes that order, so the same seed produced a different 50 (7 of
1683
+ 50 rows differed between two consecutive runs); and even with the order fixed, **the scores come from a
1684
+ live model**, so an article scoring 0.49 in one run and 0.52 in the next changes band and therefore
1685
+ changes the draw. The pool is now sorted before grouping and scores are **cached by article path**
1686
+ (`~/.weflow-cli/labels/.jev-scores.json`), so two runs of the same command produce the same 50 in the
1687
+ same order - verified, and pinned by a test that feeds the same items in reverse order. The cache also
1688
+ means a re-run costs no quota; `--refresh` bypasses it when the model or the criteria change.
1689
+
1690
+
1691
+ - **Quoted messages no longer lose what they were quoting.** In the appmsg payload the *reply* sits in
1692
+ `title` and the *quoted original* in `refermsg/content`, and only the first was read. Measured on 37
1693
+ real quote messages in one archive: the quoted original is a median of 36 characters (longest 12733 -
1694
+ a whole article was pasted in), so the model was routinely shown a line like "是呀,够得意个" with no
1695
+ way to know what it was replying to. Both parts are carried now, separated by ` | 引:`. Each is
1696
+ clipped with a trailing `…` (reply 60, quoted text 120) because an unmarked cut reads as a complete
1697
+ sentence - the longest quoted text would otherwise have looked like it simply ended. Entities are
1698
+ decoded for display (`a&amp;b` reads as `a&b`), which the WeChat 3.x reader already did and the 4.x
1699
+ one did not.
1700
+
1701
+ - **The same fix reached the WeChat 3.x reader, which had no test at all.** `sqlcipherCore`'s AppMsg
1702
+ formatting was the second implementation of this display form; it is now `core/appMsgFormat.ts` as
1703
+ pure functions with 13 tests. Extracting it immediately paid for itself: reading `<type>` from the
1704
+ whole document could pick up a quoted message's `<type>` instead of the outer message's, and the
1705
+ entity-decoding the original did was very nearly dropped in the move (the tests caught both). The two
1706
+ implementations now share one set of clip lengths and one separator, so the same message reads the
1707
+ same on either WeChat version - they are still two implementations, and the tests pin the values they
1708
+ must agree on.
1709
+
1710
+ - **One assistant message body was cut at 80 characters.** A quote (`[引用] reply | 引:quoted`) had
1711
+ its quoted text reduced to seven or eight characters - carried, but useless - and any long message
1712
+ was cut mid-sentence with nothing to show it had been cut. The limit is now 160, with a trailing `…`.
1713
+ Worst case stays bounded: 50 messages × ~180 characters ≈ 9k.
1714
+
1715
+ - **The Vault's copy of an article now has the WeChat interface phrases stripped; the file it was copied
1716
+ from does not.** `Sources/WeChat/` is the layer a person reads and searches, and "继续滑动看下一个",
1717
+ "轻触阅读原文" and the `在小说阅读器中沉浸阅读` family (eight phrases) are page furniture rather than
1718
+ article text - searching the vault used to surface them. `output/biz-daily/` keeps the fetched text
1719
+ byte-for-byte, because it is the only copy of the bodies and a cleaner with a bug there would destroy
1720
+ material that cannot be re-derived. Measured after the pass: 15,731 of 35,463 files carried a phrase
1721
+ before, **0 after**.
1722
+
1723
+ **The cleaning lives inside `copy_to_vault`, and that placement is the fix.** It is not a one-off: this
1724
+ step re-runs, so a cleaner sitting anywhere else gets undone by the next sync - which is exactly what
1725
+ happened during development, when a plain `--vault-sync` restored the residue on 15,731 files because an
1726
+ earlier pass had cleaned the Vault copies while the copy function was still a dumb `write_bytes`.
1727
+
1728
+ Two things are deliberately untouched. **The frontmatter**, because these are short words: `去阅读`
1729
+ occurs inside `如何去阅读一本书`, and the title is the only alignment key between a reading note and its
1730
+ source - measured, 0 of 35,060 files currently need it, so the guard is for the future and is written
1731
+ down as such rather than dressed up as a fix. **Fenced code blocks**, because `strip_wx_ads` also
1732
+ normalises whitespace, which is harmless for the summaries it was written for and eats indentation in
1733
+ code: 20 files carry fences and **10 of them would have been altered**.
1734
+
1735
+ **What it actually deletes, measured instead of assumed.** It is `strip_wx_ads`, the same function the
1736
+ notes already use, so it does more than the eight phrases: `______` runs, `javascript:void(0)` links,
1737
+ the `原创 <账号名>` byline that WeChat pastes several times over, and repetitions of the same token.
1738
+ The byline rule alone fires on **9,334 of 35,463 files**. It never rewords prose - sampled 300 articles
1739
+ and classified every diff - but this *is* text removal, and an earlier draft of this entry claimed the
1740
+ pass only touched phrases and whitespace, which measurement did not support. The byline is page
1741
+ furniture and the account name survives in `source:` in the frontmatter, so the trade is defensible;
1742
+ the original is in `output/biz-daily/` either way.
1743
+
1744
+ - **`--vault-sync` had been deciding which days to copy by a marker only one writer sets.** The gate was
1745
+ `backfilled` in `.articles.json`, but that key is written by the backfill path only - so the 12 days
1746
+ produced by the `daily` path were never copied **and were never reported as skipped**, including two days
1747
+ (08-24, 08-25) with no directory in the Vault at all. The gate is now "the day has at least one `.md`
1748
+ besides `README.md`", which is what the flag always meant: 187 → 199 days, 33,232 → 35,463 files. The
1749
+ 1,755 files still carrying residue after the first cleaning pass were exactly this population. The
1750
+ reading notes were never affected - every one of them points at `output/biz-daily/...` through
1751
+ `local_source`, not at the Vault copy (verified across five months, 1,144 of 1,144), so `wiki lint`
1752
+ reports the same result before and after.
1753
+
1754
+ - **`wiki lint`'s "similar titles" report now says out loud that its own threshold has stopped
1755
+ discriminating.** The rule (a common substring of ≥5 characters covering ≥40% of the shorter title) was
1756
+ set when there were 5,062 pages with long Chinese news headlines. At 22,374 pages full of short names it
1757
+ fires on coincidences - `AI Agent开发框架` against `生物信息学LLM Agent综述` - and reports **424,697**
1758
+ groups. A 20-pair sample confirms the rule itself holds, so this is the threshold and not the
1759
+ implementation; the printed line now carries that caveat rather than letting the number read as a
1760
+ finding. The same investigation turned up a second, older problem: the rule **cannot catch the case it
1761
+ was written for** - `打虎!陈勇被查` and `中建集团副总经理陈勇被查` are the same event with different
1762
+ headlines, and their longest common substring is 4 characters, so only 2 of the 4 motivating titles ever
1763
+ matched. The misleading claim in the docstring is gone; tightening the threshold is left as a decision
1764
+ because every variant trades one kind of miss for another.
1765
+
1766
+
7
1767
  ## 1.7.0
8
1768
 
9
1769
  ### Added
10
1770
 
1771
+ - Article bodies are now cached as they are fetched, so a daily run can resume. The run used to be **all-or-nothing**: it fetches every article before writing anything, and the bodies only ever lived in memory, so any interruption discarded the whole day. On a 400-article day that is over an hour of fetching, and on 2026-09-22 it stopped at 105/403 having written nothing. Bodies are now stored under `output/.cache/fetch/<md5(url)>.md`: a re-run fetches only what is missing (a resumed article costs ~0s instead of ~20s, and it does **not** take the 8-12 s throttle sleep, because a cache hit sends no request), and switching between `--no-summary` and normal, or changing the classifier, no longer re-downloads the day. Only successes are cached - a failure would otherwise be remembered forever instead of retried. `--no-fetch-cache` forces a refetch.
1772
+
1773
+ - Topic exclusion: `dailyExcludeTopics` (comma-separated, e.g. `新闻,投资,学术`), settable with
1774
+ `weflow-cli config set dailyExcludeTopics "..."`, plus `--exclude-topics` on
1775
+ `generate_ai_report.py` / `generate_html.py` for a one-off override. This is a **display**
1776
+ switch: bodies are fetched and archived as usual, and the topic simply does not appear in the
1777
+ two views. Pre-fetch filtering was measured first and rejected - judging the type from a source
1778
+ name plus a title and digest agreed with the source-level configuration only 60% of the time on
1779
+ 217 articles, with 48 false positives and no gold standard - and a skipped fetch is
1780
+ **irreversible**, since nothing gets archived. Excluding at the display layer also means
1781
+ changing your mind costs a regeneration, not a refetch. The exclusion is orthogonal to
1782
+ `--include-all` and to the inclusion score, it is applied identically by the report and the
1783
+ reader page, and all three lists in the report's **我拿不准的** section are filtered, so a
1784
+ report that excludes 新闻 cannot turn around and list 新闻 in its own uncertainty section.
1785
+ Excluding the focus topic is refused with a warning rather than obeyed: an empty subject makes
1786
+ the report either bodyless or exit with "未找到文章", which blames the wrong thing. A misspelled
1787
+ topic name is ignored **with a warning** - a silent no-op would look like the filter working.
1788
+
1789
+ - Source-level prior: `~/.weflow-cli/source_topics.json` accumulates how many articles each
1790
+ 公众号 has been judged to publish per topic, from what the daily actually archived. A source
1791
+ with enough samples (`SOURCE_PRIOR_MIN_SAMPLES`) dominated by one topic (`SOURCE_PRIOR_SHARE`)
1792
+ is reported at the end of the run, and when that topic is one you exclude the run names it:
1793
+ `来源先验: 甲号 → 新闻(20 篇里 19 篇 = 95%)`. It **only reports - nothing is skipped**,
1794
+ because a pre-fetch skip is irreversible and this table is still growing. The table holds
1795
+ account names, so it lives outside the repository; counts are append-only and only articles
1796
+ that landed on disk are recorded, so the table and `output/` describe the same corpus.
1797
+ `stable_source_topic` returns `None` for "not known yet", and callers must not read that as a
1798
+ default topic. See D-037 and D-038.
1799
+
1800
+ - `test/config-keys.test.ts`: every key in `bin`'s `configurableKeys` must also be declared in
1801
+ the `CliConfig` interface, given a default, reset by `clear()` and read back by `load()`.
1802
+ These are the four ways a key can be accepted by `config set` and then quietly not survive a
1803
+ restart.
1804
+
1805
+ - `daily --no-summary`: judge without generating. Topic, relevance and the inclusion decision still come from the decision model, but **no LLM call is made at all** - no summaries, no tags, no concepts, no README briefing - so this run needs no DeepSeek key. Articles are still fetched and archived (the body is kept); the md simply carries no `## AI 摘要` section, and `.articles.json` records `summary: ""` rather than quietly substituting the platform's digest, because an empty heading reads as a failed generation and a digest reads as our summary - both claim something that is not there. Verified by running a 4-article day with a **deliberately invalid** DeepSeek key: it completes normally with no 401, i.e. nothing reached the LLM. Wired through `weflow-cli daily --no-summary` and `pipeline.py`; the downstream pipeline steps (action suggestions, wiki compile, AI report) still use an LLM, so a fully LLM-free run adds their `--skip-*` flags, and the pipeline says so when it notices.
1806
+
11
1807
  - `sync run|status|verify`: a local message-sync checkpoint. `sync run` reads a time window, deduplicates against the previous checkpoint and records what it covered; `sync status` reports coverage without touching the database, so it still works while the database is locked. It does **not** advertise a stable cursor - overlapping windows plus local deduplication is what it offers, per D-027.
12
1808
  - Per-shard read reporting. Shard open and read failures used to be swallowed by a bare `except: continue`, so "read 31 messages" and "read one shard and silently skipped three" were indistinguishable from the outside. `--report-shards` (opt-in; without it the JSON is byte-identical) surfaces `scanned/opened/failed` and one entry per shard. On the machine this was developed against the report reads `message_0.db` 31 rows and `message_3.db` 1258 - exactly the shape of the shard-read bug fixed in 1.6.4, which was completely invisible at the time.
13
1809
  - A media coverage report on HTML export: `<prefix>_media.json` beside the parts, with a status and a reason for every media item (`embedded | cached | remote-fetched | missing | unsupported`). The exporter had been computing `COVER_STATE` counters and discarding them, and a media miss showed up only as a bare `[图片]` with no reason recorded. A real 120-message export reports 54 items: 35 embedded, 15 missing, 2 unsupported, 2 remote-fetched, with reasons `not-in-local-cache` and `voice-not-in-media-index`.
14
1810
  - `docs/SYNC_CONTRACT.md` freezing the `weflow-sync/v1` and `weflow-job/v1` state schemas, and `D-029` recording the additive-only boundary, why the identity omits `shard`, and why `sync retry` is deliberately not implemented.
1811
+ - `python scripts/nt_decrypt.py verify-native`: check a passphrase against an encrypted database using only the standard library (PBKDF2-HMAC-SHA512 over the page-1 salt, then the SQLCipher page MAC). "Is this key right?" can now be answered when `sqlcipher3` is missing or built against a different SQLCipher - previously that question and a broken driver looked the same from the outside. It answers yes / no / cannot-tell, and does not raise on unusable input. The synthetic test fixture was also made faithful while adding this: it had been encrypting every shard with one fixed salt and the passphrase as a raw key, so it could not exercise per-shard key derivation at all.
1812
+ - Article topic and relevance in the daily report are now decided by TypeSafe's Jev decision model (`scripts/jev_client.py`) instead of being parsed out of generated text. One request asks a 6-way `choice` for the topic and a 3-level `score` for relevance, and returns probabilities rather than a string with `【主题】` in it. The LLM still writes the summary, tags and concepts - Jev cannot generate text. `weflow-cli config set typesafeApiKey "..."` enables it; without a key the previous path runs unchanged, and `--classifier llm` forces it. See D-031.
1813
+ - Two additive frontmatter keys on each article: `relevanceScore` (the raw score) and `topicConfidence`. The three-level cut points are provisional and uncalibrated, so the raw value is kept rather than discarded at the write step.
1814
+ - The daily report ends with **我拿不准的**: the entries whose probabilities sit near the threshold, split into those admitted and those excluded, plus any article whose topic confidence is low enough that it may be in the wrong section. It is computed **locally, from frontmatter** - no extra model call - because asking a model to describe its own uncertainty means asking it to generate more prose. When the articles predate the probability fields it says so outright, rather than printing nothing and letting the absence read as confidence. See D-033.
1815
+ - The daily report now asks whether an article belongs in the report, instead of inferring it. `worth_including` is a yes/no question riding along in the same decision request (measured: 12 questions cost 0.91s against 0.84s for 2, since `state` dominates the token count), and it is stored as `includeScore`. Observed to diverge usefully from `relevance`: an award announcement scored 0.79 for relevance but 0.03 for inclusion.
15
1816
 
16
- ### Changed
1817
+ - `scripts/route_cards.py`: search your own conversations by describing what you are looking for. The decision model picks the real query words out of the n-gram fragments the question produces (measured: keeps 会议 at 0.77, rejects seven fragments at 0.11-0.40), and **ranking is local** - by how many messages in each session literally contain those words - because ranking sessions with the model was measured and does not work (all candidates scored 0.50-0.51 as yes/no and 1.74-1.76 on a 0/1/2 scale, regardless of 247 versus 8 actual hits). Retrieval reads WeChat's own `message_fts.db`, whose text is plaintext and whose integer `session_id` is the rowid of the same database's `name2id` table, so it needs no index of its own. Only the candidate words and your question leave the machine; `--keyword` keeps the whole path local, and message text never leaves. See D-036.
1818
+
1819
+ - Decision-model calls now record **which model actually served them**, and validate the `choice` contract on the way in. The API returns a `model` field naming the served version (`jev-1.13.0`) as distinct from the requested alias (`jev-latest`), and the daily stores it as `decisionModel` in `.articles.json` - so "production is no longer running what was measured" becomes visible instead of producing results that merely look fine. Every `choice` answer is checked before use: probabilities present, key set equal to the criteria, values in `[0,1]`, sum ≈ 1, and `choice` equal to the argmax; a non-argmax choice is not a wrong-looking answer but a normal-looking wrong one. `instructions` is also confirmed to accept a structured object (`{"goal": …, "rules": […]}`), though the daily's prompts deliberately stay as strings - changing them would move behaviour the cut points were calibrated against.
1820
+
1821
+ - When the decision model has judged an article, the LLM prompt no longer asks it to classify too. It used to ask for `【主题】`/`【相关度】` whoever was judging, so on the Jev path those answers were written and thrown away - the fields plus the six-category criteria and the three-level definitions came to 446 characters of prompt per article (57% of the prompt skeleton; about 9% of the total input, since the article body dominates), plus 10-15 output tokens. The **summary, tag and concept requirements are byte-identical between the two prompts** (a test asserts it), so what gets generated does not change; `--classifier llm` and any article whose Jev call failed still get the full prompt, keeping the fallback path byte-identical as D-031 requires. The choice is per article, not per batch - a failed judgement needs the LLM's own topic and relevance. Token savings are estimated from prompt size and output fields, not read from a bill: neither this client nor the decision-model client exposes usage amounts.
1822
+
1823
+ - The assistant's two untested core files now have coverage, via a synthetic harness rather than a
1824
+ live channel. `assistantMemory` gets 17 tests (working window, compression past the cap, the
1825
+ degraded path when the LLM refuses to compress, fact extraction cadence and noisy-output parsing,
1826
+ the fact cap, persistence across a restart) and `assistantService.handleMessage` gets 14 (built-in
1827
+ commands without any model call, the ReAct loop feeding a real tool result back, tool-result
1828
+ redaction, the unknown-tool and malformed-arguments paths, the 6-round cap, an LLM failure that
1829
+ must say so, audit lines, and memory wiring), plus 8 for the daemon start path. No network, no
1830
+ WeChat channel, no real home directory: `callLLM` is injected, the tools used are the two that
1831
+ only touch memory (`search_memory` / `save_memory`), and `HOME` is pointed at a temporary
1832
+ directory before the modules are imported. Two invariants the harness exists to hold: a tool
1833
+ result is redacted **before** it can leave the machine, and the audit log never contains message
1834
+ text. One sharp edge was found and **documented instead of changed**: fact de-duplication uses
1835
+ mutual containment, so an existing `事实 1` silently blocks a new `事实 10`.
1836
+
1837
+ - 11 of the assistant's 12 tool branches are now executed by tests, through stubs on the exported
1838
+ `chatService` / `wereadService` singletons and a replaced `fetch` - so no database, no network
1839
+ and no real home directory. Before this, no test had ever run a tool branch: only three pure
1840
+ helpers were covered, and the rest of the surface was unverified. The branches now pinned
1841
+ include: the strict-mode body mask applied **inside** `get_messages` (so third-party chat text
1842
+ cannot appear in a tool result), `read_favorite` refusing an unsafe link **without issuing any
1843
+ request at all**, its single retry for WeChat's WAF challenge page and the `content_noencode`
1844
+ fallback, the "deleted by the publisher" case, out-of-range tool arguments becoming a readable
1845
+ parameter error, ambiguous contact names asking for a more precise one (and an exact name
1846
+ winning over a partial match), and a tool that throws internally returning a readable failure
1847
+ instead of rethrowing into the ReAct loop. **Not covered, deliberately**: `get_todos` spawns a
1848
+ Python subprocess against the real database and has no cheap stub point.
17
1849
 
1850
+ - The assistant's resident loop is now driven by tests too - who gets answered and who is denied,
1851
+ without a WeChat channel: `WechatMessageService.prototype` is replaced, the token comes from a
1852
+ stubbed `configService.get`, and the queue is drained by awaiting it. Pinned: an empty allowlist
1853
+ denies everyone with an audit line and **no model call**; a non-text message is ignored without
1854
+ spending quota; an exhausted quota replies "额度已用完" and does not call the model; the quota
1855
+ resets across a date change; two messages are processed strictly in arrival order (the serial
1856
+ queue is what keeps the memory window from interleaving); a group needs all three controls
1857
+ (group allowlist, sender allowlist, @ mention) and is denied with a distinct reason for each
1858
+ missing one; and one message that throws does not take the loop down with it.
1859
+
1860
+ - The privacy wiring - which config value decides "this data does not leave the machine" - is now
1861
+ tested. Existing privacy tests called `redactText(text, mode, localInference)` with explicit
1862
+ arguments, so the function was covered while the thing that *derives* that boolean in
1863
+ production was not: `PrivacyGate.isLocalInference()` reads `aiEngine`, and `engineConfig()`
1864
+ decides where the request is actually sent. Pinned: only `ollama`/`lmstudio`/`local` count as
1865
+ local inference and skip redaction; a custom `aiBaseUrl` is still cloud, so swapping in a relay
1866
+ does not quietly stop PII masking; local engines point at `localhost` with `key: null` and a
1867
+ trailing slash on a custom base URL is stripped; and `reviewEvidence` refuses to put chat text
1868
+ on the wire without an explicit `--allow-cloud` (**without issuing any request**), redacts the
1869
+ transcript when it does, and keeps it intact under local inference.
1870
+
1871
+ - The assistant's single-round fast path (D-035) is implemented and **off by default**. It asks the local
1872
+ decision layer one question - "must this be answered from local data, and if so which of these nine
1873
+ capabilities" - then pre-dispatches that one tool, so the ReAct loop's first request already sees the
1874
+ result: two model round trips become one. Only tools with closed or empty arguments are routable
1875
+ (`list_sessions`, `get_stats`, `get_daily_report`, `get_sns`/`get_weread`/`get_todos` modes); a question
1876
+ like "summarise my chat with X" needs a free-text argument a closed-set question cannot produce, so it
1877
+ falls back - passing the raw message as the argument would manufacture exactly the failure this
1878
+ decision was written to avoid (a wrong tool, and a confident answer built on an irrelevant result).
1879
+ `config set assistantFastRoute log` records "would have routed to X" and changes nothing; `on` enables
1880
+ it. Every uncertain exit - judge unavailable, non-numeric probability (`Number(true)` is 1, so a boolean
1881
+ `noul` would have routed *certainly*), low confidence, unknown capability - falls back to the previous
1882
+ behaviour rather than to a weaker new one, and a test compares the fallback transcript field by field
1883
+ against the baseline. The dispatch-and-audit step is now one shared implementation, since "no tool
1884
+ dispatched without the audit line" is the property that must not regress. Measured on 17 questions
1885
+ against the real decision layer (`scripts/assistant_route_probe.ts`): 0 wrong routes, 10 routed
1886
+ concretely, ~1.33 s per decision, 2 fell back - with the probe's own expectations, not human labels.
1887
+
1888
+ - First-run setup for the assistant now says what to do and, more importantly, **which ID to allowlist is
1889
+ not guessed**. The assistant denies every sender until `assistantWhitelist` is set, so a fresh
1890
+ `login-wechat` used to end in silence: talk to your own bot, get nothing back, and go digging through
1891
+ logs for the reason. Two sources of that ID exist and only one is verified - the login response carries
1892
+ an `ilink_user_id` while the allowlist matches the inbound `from_user_id` (documented as an
1893
+ `@im.wechat` ID), and nothing in this repo connects the two, so the login path does not write the
1894
+ allowlist. Instead, while the allowlist is empty the daemon prints the **first denied direct message's**
1895
+ full sender ID together with the exact `config set` line to run, once, and never for a group (a group's
1896
+ sender is a member, not the person to allowlist). Auto-configuring an allowlist from an unverified
1897
+ identifier would have failed in the worst way available here - non-empty, so it looks configured, and
1898
+ still denying you, with the one-time hint disabled because the list is no longer empty.
1899
+ `assistant log --json` still returns metadata only, never log content.
1900
+
1901
+ - The assistant now names **all three** ways out when the strict privacy mode hides chat bodies. It had
1902
+ said only "switch strict mode off", which omits the option that actually matches a privacy worry: a
1903
+ local engine (`aiEngine=ollama`/`lmstudio`) leaves the machine out of it entirely, and strict-mode
1904
+ masking is skipped there by design because nothing is sent anywhere. The system prompt now requires
1905
+ it to list balanced (bodies leave, PII masked), a local engine (nothing leaves), or staying as is -
1906
+ rather than presenting one of them as the only choice.
1907
+
1908
+ - A `隐私` built-in command in the assistant: send it and the reply lists the current privacy mode, what the
1909
+ tools actually receive (masked, original, or not sent at all), and the exact `config set` lines to
1910
+ change it - including the local-engine option when inference is in the cloud. It is **read-only on
1911
+ purpose**: a chat message must not be able to weaken a privacy setting, so the mode is changed on the
1912
+ machine, not from inside the conversation.
1913
+
1914
+ - The assistant claimed it had called a tool and been blocked by strict mode, **without calling any tool at
1915
+ all** - the audit line read `TURN_DONE 264B tools=0`. Two things changed: the privacy state is now written
1916
+ into the system prompt as a **fact about the current mode** instead of a conditional ("if strict mode hides
1917
+ bodies, then ..."), and a rule says not to conclude anything about tool output before actually calling the
1918
+ tool. **The first explanation for it was wrong, and measurement said so**: this release's notes originally
1919
+ attributed the missing call to that conditional. A probe of the same question against the real model - four
1920
+ prompt variants (current, hypothesis-sentence only, with the model's own previous "blocked" answer seeded
1921
+ into its window, and the complete pre-change prompt) x 8 runs = **32 calls - called the tool 32 times out of
1922
+ 32**. The prompt was therefore not the cause, and the cause of that single occurrence is unknown (sampling
1923
+ variance is the likeliest). The changes stay, because stating the current mode as a fact is right on its own
1924
+ terms, but they are **not** presented as a proven fix. What actually caught this was the audit line: a
1925
+ per-turn `tools=N` count turned "the assistant says it looked" into "the assistant did not look".
1926
+ - A **tool-use guard** in the assistant: if the router judged that a message needs local data and the turn
1927
+ then produced **no tool call at all**, the model is pushed back once - "you hold no tool result; call a
1928
+ tool or say which one you called and what it returned" - and the loop runs again. It exists because two
1929
+ live answers said "I did look it up, the content was masked" while the audit showed `tools=0`; 40 probe
1930
+ calls against the real model could not reproduce it, so this is a code-level **contradiction check**
1931
+ rather than a theory about the cause. The trigger is the routing decision, so: it never fires in `off`
1932
+ (no signal), and it **does** fire in `log` - deliberately, because `log` promises that *routing*
1933
+ changes nothing, while this is a safety behaviour, and the observation period is exactly when a
1934
+ fabricated "I looked" is most likely to be noticed. It fires at most once per turn, only on the
1935
+ contradiction, and a failed push-back keeps the reply it already had. `TOOL_GUARD_PUSHBACK` in the
1936
+ audit is the line to grep; the loop is now one shared implementation for both passes.
1937
+
1938
+ - **Non-text messages are no longer dropped when reading a conversation.** `parsedContent` was filled only
1939
+ for `local_type == 1`, and a non-text body is a zstd-compressed BLOB that "is not a str, so it becomes
1940
+ an empty string" - so images, stickers, files, quotes, transfers, red packets and **revoke notices**
1941
+ all arrived downstream as empty. In a real 44-message conversation the three "empty" messages turned out
1942
+ to be a revoke notice and two stickers, and the assistant answered, truthfully, that it could not read
1943
+ the content. They now get a display form: the type code is derived (`local_type = apptype * 2**32 + 49`,
1944
+ 49 being the appmsg family) rather than tabulated, the XML is decompressed when it is zstd, and the
1945
+ useful part is carried through - `[文件] Base.csv`, `[引用] <quoted text>`, `"某人" 撤回了一条消息`,
1946
+ `[转账] 微信转账`, `[表情]`. An unknown app type says `[应用消息]` rather than guessing "link", and an
1947
+ unknown type says `[未识别的消息类型 N]`: **a non-text message never becomes an empty string again**, and
1948
+ a test asserts that over a list of types. The assistant tool now prefers `parsedContent` for non-text
1949
+ messages, so raw XML (md5, cdn urls) no longer goes into the model's context. Also fixed a latent wrong
1950
+ value: `getMediaStream` labelled **everything** that was not a video as `mediaType: 'image'`, text
1951
+ included (it has no callers today, but a wrong value in a public method gets believed eventually).
1952
+
1953
+ - The assistant's memory file now carries a **format version** (`version: 1`, conversations under a
1954
+ `users` key). A file without a version is the previous shape and is migrated; any other version is
1955
+ **refused** - renamed to `assistant_memory.json.unreadable-<timestamp>` and announced in the log and the
1956
+ audit - rather than parsed by guesswork. A file that cannot be parsed at all takes the same path, because
1957
+ it may be the only copy. The criterion for what counts as a structural change (and therefore a bump) is
1958
+ written down next to the constant: renaming/removing a field, changing its meaning or units, or changing
1959
+ the key space; **adding an optional field does not**, and a test pins that unknown fields in a
1960
+ same-version file are ignored.
1961
+ - Long-term facts gained **provenance and usage**: each fact records the user turn it was extracted from
1962
+ and the sentence that triggered it (`sourceTurn`, `sourceQuote`), and the time it was last retrieved
1963
+ (`usedAt`). A fact can now be checked against what the user actually said, and stale facts are
1964
+ distinguishable from live ones.
1965
+ - Fact de-duplication no longer loses the more specific version. It used to treat mutual containment as a
1966
+ duplicate, so `项目叫 weflow-cli` blocked `项目叫 weflow-cli 并开源` - the more precise statement could never
1967
+ be stored. Now the longer, more specific fact replaces the general one (normalised comparison, with a
1968
+ length-ratio guard so that two facts merely sharing a short fragment stay separate). The remaining
1969
+ ambiguity is documented in a test: two facts that are prefixes of each other resolve in favour of the
1970
+ longer.
1971
+ - The working window compresses on **two gates with different retention rules**, because a fixed turn count
1972
+ is window-independent and goes wrong as soon as the model changes. The turn gate (many short turns) keeps
1973
+ about half; the budget gate (a few long turns) keeps what fits 16% of the input budget, and the character
1974
+ bound wins over the turn floor - six 6,000-character turns are 36,000 characters, and no turn floor
1975
+ justifies exceeding the budget. Both rules are clamped to the window length, which fixed a real bug: a
1976
+ computed retain of 6 in a 5-turn window turned into `slice(-1)`, i.e. keeping exactly one turn.
1977
+ - The rolling summary is now a **fixed eight-section skeleton** (用户诉求 / 技术要点 / 涉及的文件与命令 /
1978
+ 错误与修复 / 待办 / 当前进展 / 下一步 / 关键上下文) with an explicit merge law: keep what is still true, drop
1979
+ what is stale, produce one summary, never copy the previous one verbatim. An empty section writes
1980
+ `(none)`; a section is never dropped. Free-form summaries were where compression quietly lost information.
1981
+ The extraction prompt also now asks only for what **the user stated**, not for the assistant's guesses.
1982
+ - A memory file that could not be loaded is announced at startup (`⚠ 记忆: …` plus a `MEMORY_LOAD_ISSUE`
1983
+ audit line) instead of silently presenting an empty memory, which reads as "it forgot me".
1984
+
1985
+ - Facts are now **selected for injection by relevance** instead of all being sent every turn. Thirty facts at
1986
+ ~72 characters each is about 2.1 KB per request, and the ones unrelated to the question are noise - which
1987
+ costs more than money. Selection ranks by direct containment first, then character-bigram overlap, then
1988
+ recent use (`usedAt`) and recency; the selected facts keep their chronological order so the block still
1989
+ reads as a list. The prompt now **says how many were left out** ("另有 N 条与这次问题关系较远,未列出"):
1990
+ claiming "here is everything I remember" while sending a subset is worse than sending less, because the
1991
+ model then believes it has seen it all.
1992
+ - Local data entering the system prompt (the rolling summary and the memory facts) is now wrapped in a
1993
+ `<weflow-local-data source="…">` frame, with **any occurrence of the frame tags inside the content
1994
+ neutralised**. The threat is frame spoofing: chat text, article text and a stored fact can all contain
1995
+ `</weflow-local-data>` followed by something that reads like a system instruction, and without escaping
1996
+ the data could close the frame and speak as the system. This does not make the model immune to
1997
+ instructions hidden in data - it only removes the ability to **close our frame**, which is the part we
1998
+ can guarantee. The idea came from the same `deepseek-harness` audit: it treats referenced session content
1999
+ as untrusted and keeps its injected instructions tag-safe for exactly this reason.
2000
+
2001
+ - Fact selection now has a **relevance floor**, applied **before** the budget cut. Measuring the earlier
2002
+ version showed the flaw: the budget is counted in characters, thirty short facts came to about 1,080
2003
+ characters, everything fitted, and "select by relevance" had degraded into sorting - i.e. full injection
2004
+ again (2,483 bytes of facts per request). With the floor, an unrelated question injects eight recent
2005
+ facts as a fallback (791 bytes) and a question matching one fact injects 390 bytes; the "另有 N 条" note
2006
+ appears in both cases. A relevance-only fallback is intentional: memory itself is context, so injecting
2007
+ nothing at all is worse than injecting the most recent few.
2008
+
2009
+ - Two more assistant tools, and the Python-calling code is now one implementation instead of two.
2010
+ - `search_chats`: "where did we talk about X" across every conversation. It runs `scripts/route_cards.py`
2011
+ (the decision model picks the query words, ranking is local against the message full-text index) and
2012
+ reuses that script's own egress gate; only the question and the candidate words leave the machine, never
2013
+ chat bodies - and the excerpts it does return go through `privacyGate.maskMessageBody`, the same rule
2014
+ `get_messages` follows, so strict mode masks them here too.
2015
+ - `who_owes_reply`: who is waiting on you, from `scripts/reply_debt.py --json`. It reports who/ how long /
2016
+ how likely and deliberately **not** what they wrote; ask for a specific person with `get_messages`, which
2017
+ does the masking properly.
2018
+ - `route_cards.py` gained a `--json` output for this (the JSON is the last line, since the human-readable
2019
+ progress lines still go first) with a test asserting that shape and that `--keyword` stays offline.
2020
+ - New `src/services/pythonBridge.ts`: the single way to call an in-repo Python script and get JSON back.
2021
+ There were two implementations before (`get_todos` via `execFile` with argv, the router via `spawn` with
2022
+ stdin), each with its own timeout and error handling - exactly the duplication this repo keeps finding.
2023
+ The bridge classifies failures (timeout / non-zero exit / no JSON / the script reporting `success:false`)
2024
+ instead of collapsing them into "execution failed", does not throw at callers, and is injectable so the
2025
+ tool branches can be tested without spawning a process - which is what finally makes `get_todos`
2026
+ testable, the one tool that had been left uncovered for that reason.
2027
+
2028
+ - Two more assistant tools, taking the set to 16.
2029
+ - `search_semantic`: meaning-based search for when literal words miss (`search_chats` matches literal strings
2030
+ only). The query travels in `WEFLOW_SEARCH_QUERY` rather than argv, following the rule this repo already
2031
+ tests for other readers - user text must not show up in a process list. Being explicit about the egress:
2032
+ the query is embedded by Aliyun (百炼 text-embedding-v4) and the candidate snippets are reranked by the
2033
+ decision model, both existing cloud paths in this repo, and it needs an index built first
2034
+ (`weflow-cli search-index`).
2035
+ - `export_chat`: write a conversation out as HTML with images. It is the first **write** tool the assistant
2036
+ has, so its boundary is fixed in code rather than left to the prompt: it only ever creates a **new**
2037
+ directory under `output/exports/` (the model cannot supply a path), the name carries a timestamp, and if
2038
+ that name is taken a numeric suffix is appended - "never overwrite" is a property, not an intention.
2039
+ The root is overridable with `WEFLOW_ASSISTANT_EXPORT_ROOT` (tests use a temp directory; a test run must
2040
+ not write into the repository).
2041
+ - Deliberately **not** tool-ified, with reasons recorded rather than left implicit: `evidence-review` and
2042
+ `vault promote` have their own preview-and-confirm gates for a human at a terminal, and routing a chat
2043
+ message around those gates would defeat the reason they exist; sending messages is outward-facing and
2044
+ irreversible, and the repository does not implement remote silent control; changing privacy settings, the
2045
+ allowlist or the sources stays on the machine.
2046
+
2047
+ - Three gaps in existing tools closed rather than three new tools added - each was a case where the CLI could
2048
+ already do it and the tool was just narrower than the question:
2049
+ - `get_sns` gained mode `users` ("who posts the most"), aggregated locally from the timeline it already
2050
+ fetches, so it adds no new egress; the CLI had this as a subcommand and the tool only had timeline/stats.
2051
+ - `export_chat` gained a `format` argument (`html` default, plus `txt`/`json`/`excel`). An unrecognised format
2052
+ is a parameter error, not a guess, and the reply names the format it wrote.
2053
+ - `search_favorites` no longer requires a keyword: with none it lists the most recent favourites, which is a
2054
+ question people actually ask. "The collection is empty" and "nothing matched that word" are now different
2055
+ sentences, as they were for the search case.
2056
+
2057
+ ### Changed
2058
+ - Image downloads during the daily run are concurrent (6-way). They were sequential at 0.37 s and 135 KB each - about 18 minutes per 190-article day - even though they come from `.qpic.cn`, WeChat's CDN, which a browser fetches in parallel anyway. Same three articles: 17.2 s → 2.1 s. The same change fixed the map: a failed download used to be recorded in `.image_map.json` **before** it was attempted, and the reader injects that map as `window._IMG_MAP`, so the page was told to look for a local file that did not exist. Only files that are actually on disk are mapped now, and duplicates in a page are fetched once (31 image links in one article were 17 distinct images).
2059
+ - LLM summaries are generated concurrently, so a 190-article day spends about 2 minutes there instead of 9 (measured 2.27/2.92/2.45 s per article). The calls are **prefetched, not the loop rewritten**: responses are filled back by their original index and the existing loop still does the parsing and the field writes in the same order, so every fallback branch behaves exactly as before - a failed call comes back as an error and the loop re-raises it into its own `except`. The per-article 0.3 s pacing moved into the worker, so the request rate to the provider is unchanged. The stage now prints `摘要完成 N/M 篇,耗时 Xs(6 并发;串行约需 Ys)`, the shape the classification stage already used.
2060
+ - Daily article fetching now asks for compression. `urllib` does not send `Accept-Encoding` by default, so this machine was downloading every 公众号 article page - 3-4 MB of inline JS and CSS - uncompressed. Measured on the same four articles: **3.3-3.5 MB and 33-40 s before, 0.7-0.8 MB and 6-10 s after**, with the decompressed page identical in size and `js_content` / `rich_media_content` still present, and the parsed markdown byte-for-byte the same length. On a 190-article day that is roughly **130 minutes of fetching down to 33** - and fewer bytes cross the wire than before, which is the same direction as the deliberate per-article throttle rather than against it. Where the rest of a many-source day goes is now measured too: image downloads ~18 min (0.37 s and 135 KB per image, sequential), the 8-12 s inter-article throttle ~32 min, the serial summary loop ~11 min - so the throttle is now the largest single line item.
2061
+ - Daily classification now runs concurrently **before** the serial summary loop instead of one article at a time inside it. Classification has no dependency on the summary, so serialising the two only ever meant queueing N network waits behind N model calls. Measured on 12 real articles: 3.3s at 6 workers against ~12s serial, so a ~190-article day goes from ~184s to about 30s. The concurrent stage prints one progress line (`分类完成 N/M 篇`, with the elapsed time and the serial estimate) because per-article logs stop being meaningful once it is parallel; the `M` is the number actually asked, so an article that failed to classify is visible rather than inferred.
2062
+ - The three-level cut points for relevance had to be chosen before there was anything to calibrate against. Observed scores span `[0,2]`, so the levels are split at 0.5 and 1.5. On a 6-article trial with the shipped criteria nothing crossed into 高, so the report's `relevance != '高'` gate stays about as closed as before - it is now **able** to open, which it never was.
18
2063
  - The HTML exporter now tries the conversation cache before the network when resolving an article thumbnail; it had been downloading covers it already had on disk.
19
2064
  - `collectMessagesInRangeDetailed` distinguishes why paging stopped. The original loop ended on a short page exactly as it ended on an exhausted conversation, so a short page caused by offset shifting on a live database looked identical to having reached the end. The original function is unchanged; the variant is used by the new sync path and available to the export path next.
2065
+ - Message reads adapt to the shard's actual columns instead of assuming a fixed schema. The read used to select fourteen columns while consuming six, so a WeChat version renaming any of the other eight made every shard raise - and the conversation came back **empty with no error** unless `--report-shards` was used. The read now selects only the columns it consumes, and a shard that lost one is read anyway with the loss named in `missingColumns`. A shard where none of `create_time`/`local_id`/`server_id` survives is reported as `SCHEMA_MISMATCH` and makes `coverage` partial. `export_chat_html` reads rows by position, so there a missing column keeps its slot as `NULL` rather than shifting the fields after it - a shifted export would have been silently wrong data rather than an obvious failure.
2066
+ - `messages --from <unix>` / `--to <unix>` bound the read in SQL, and `sync` passes its window lower bound through. **This measured no faster** on the conversation used for development (519 ms unbounded vs 514 ms from the newest message, 925 messages over 4 shards) because the cost is process start plus 256000-round PBKDF2 per shard, not row transfer. It is there for very large conversations and as the prerequisite for a stable cursor; `sync` still applies the window itself, so backends without range support stay correct.
2067
+ - Two closed vocabularies that the code compares by literal are now declared once instead of twice. The message **anchor columns** (`create_time`, `local_id`, `server_id`) had three copies - a named one, a second literal in the same file's `ORDER BY`, and a third in the exporter - and moved into `nt_common` alongside the other shared NT plumbing. Their **order is behaviour**: one use is a set membership test (order irrelevant) and the other is the sort key, so listing `local_id` first would silently reorder every conversation read. A test pins `create_time` first, the tuple type (a set would let the order follow the interpreter's hash seed), and the literal's absence from both readers. The relevance levels moved to `_utils` beside `TOPICS`, with `DEFAULT_RELEVANCE` naming what an unclassified article is recorded as - the value every failed classification path lands on, and the reason the corpus once read 中 for 2199 of 2201 articles even though 中 reads as a positive judgement rather than "not judged". `extract_todos.py`'s identical `['高','中','低']` is deliberately left uncoupled: that is the `--urgency` vocabulary, which merely shares three characters.
20
2068
 
21
2069
  ### Fixed
2070
+ - The fetch guard in the assistant (`isSafeUrl`, used by `read_favorite` before it fetches
2071
+ a link found in a favourite) had two holes, found by testing it for the first time. **IPv6 was
2072
+ not handled at all**: `[::ffff:127.0.0.1]` (an IPv4-mapped loopback), `[fd00::1]` and
2073
+ `[fe80::1]` were all allowed, so a favourite could have pointed the assistant - a process
2074
+ holding the local database handle - at loopback or link-local space. In the other direction,
2075
+ the private-range regexes were matched against any hostname, so the legitimate public domain
2076
+ `10.example.com` was refused as unsafe. The dotted-quad checks now apply only when the
2077
+ hostname really is four dotted octets, and IPv6 is refused by prefix (`::`, `::1`, `::ffff:`,
2078
+ `fc00::/7`, `fe80::/10`). Measuring also retired an assumption: Node normalises integer IPv4
2079
+ forms (`2130706433`, `0x7f000001`) to `127.0.0.1` before this function runs, so those were
2080
+ never a bypass - now pinned by a test instead of believed. The guard remains a denylist of
2081
+ known forms, not a proof that an address is publicly routable (NAT64 is uncovered), and the
2082
+ code says so. See D-040.
2083
+
2084
+ - `assistant start` now actually starts the daemon, and reports success only when it does. The child
2085
+ was spawned without `--yes` while `assistant run` confirms through `inquirer` on a stdin the
2086
+ daemon had set to `ignore` - so the child died on the prompt while the parent printed
2087
+ `✓ 守护进程已启动 (pid …)` and wrote a pid file. `~/.weflow-cli/` contained **no `assistant.log`
2088
+ and no `assistant.pid` at all**, which is how the documented flow turned out never to have
2089
+ completed. The env marker that appeared to guard the path (`WEFLOW_ASSISTANT_DAEMON=1`) was read
2090
+ by nothing and was constructed as an env **key** containing `=`; both are gone. The parent
2091
+ confirms and passes `--yes` explicitly, and success is now observed: the child is watched for
2092
+ 700 ms and a dead one is reported with its exit code and the log tail, **without** writing a pid
2093
+ file. A spawn `'error'` event is handled as well - without a listener Node turns it into an
2094
+ uncaught exception in the caller. Verified live: it now reports
2095
+ `子进程启动后立即退出 (code 1);日志尾部: Error: 未登录消息通道, 先运行 weflow-cli login-wechat`,
2096
+ which is the actual blocker on that machine (no `wechatOcToken`, empty allowlist). See D-039.
2097
+
22
2098
 
2099
+ - A daily run wrote **two different topics for the same article**. `.articles.json` defaulted a missing topic to `''` while the step that names the folder and writes the md frontmatter defaulted it to `学术` - seven lines apart, neither reporting anything. The 2026-09-04 output shows the split directly: 178 articles, every md under `学术/` carrying `topic: 学术` (including "OpenAI 深夜发布 GPT-6 Astra" and an AI-tool launch), and every entry in that day's `.articles.json` carrying `topic: ""`. Downstream reads the JSON, so the admission gate `topic != FOCUS_TOPIC and relevance != '高'` dropped the whole batch without a word. It is reachable without anything unusual: `daily --no-ai`, or a daily run with no API key, skips classification entirely, so no article has a `topic` key at all while the write phase still runs. The fallback is now one constant (`_utils.DEFAULT_TOPIC`) applied by one function that both normalises and groups (`biz_daily._group_by_topic`), so "the topic used for the folder" and "the topic written to the JSON" cannot be different values by construction. It validates **membership** in `TOPICS` rather than mere emptiness, and the run prints how many articles fell back, so a whole-batch fallback cannot pass as a normal classification. `test/default_topic_test.py` pins the grouping key, the md frontmatter and the JSON entry to the same value, and the fallback literal to one place; the checks were mutation-tested. The **same shape seven lines away** sat in `tags`: the md writer defaulted a missing key to `[topic]` and the JSON writer to `[]`, so those same 09-04 articles read `tags: ['学术']` in the md and `tags: []` in the JSON. Both now call `_tags_for_write`, which follows the convention the codebase already had (three classification paths write `[topic]` themselves) while still keeping a *present but empty* list empty - that is a different case, and collapsing it would just be a new default written in two places.
23
2100
  - A leaked database handle when a shard opened but its key was rejected: the connection was left open, which on Windows keeps the file locked.
2101
+ - `coverage: 'partial'` now also covers a shard that opened but could not be read as asked (`SCHEMA_MISMATCH`, `WINDOW_UNAVAILABLE`) rather than only `READ_FAILED`. All three describe rows that were not read. A shard that never opened still reports `unverified`, unchanged. A shard read with columns missing is **not** partial - its rows did come back.
2102
+ - A shard whose `Name2Id` table is absent no longer loses its messages: sender names are unavailable, the rows are still returned.
2103
+ - A windowed read can no longer pass as covered when a shard cannot express the window. On a shard without `create_time` the read has nothing to filter on: it now says `WINDOW_UNAVAILABLE` instead of returning rows that the window filter then silently drops while `coverage` still said `complete`. No real WeChat schema observed so far triggers this - it is a hypothetical-path fix, tested against synthetic shards.
2104
+ - `awaiting --html <path>` writes a one-page, **self-contained**, shareable summary: headline counts, a table of verdicts with probabilities, debt age, conversation kind and evidence strength, and the uncertain bucket. It deliberately carries no chat text - only probabilities, counts and day counts - which is what makes it safe to show someone else. Contact display names are escaped, and the renderer passes through a fixed set of fields, so a message body attached to a row cannot leak into the page. The footer states what the numbers are not: no gold standard, run-to-run variance near the threshold, and that thin evidence is not a conclusion. See `--html`.
2105
+ - `scripts/quality_eval.py`: the way to finally check whether the probabilities mean anything. `sample` draws a stratified sample of real articles and scores each one through the **production** path, then writes a file with two blank fields per article (`topic`, `include`); `score` reads it back and prints topic agreement for both Jev and the stored labels, a **calibration table** (per probability bucket, what fraction the human actually said should be included), and a threshold sweep naming the cut point that best matches the human. Labels live in `~/.weflow-cli/labels/`, never in the repo. Every number it has produced so far has been a comparison against an imperfect baseline; this is the first thing that can replace that with a person's answer. 49 articles sampled and scored for ~$0.005.
2106
+ - `decide --over <glob> --ask <text>` batch mode: one glob times one set of yes/no questions expands into N×M questions in a single request, and the response carries a `batch` mapping so the caller never parses question names to find out which file an answer belongs to. `--max-chars` (default 1500) is reported back in the response, because the evidence you feed is what sets the ceiling - measured: the same 132 questions scored 77.3% agreement against a baseline at 14 lines per file and 88.6% at 1500 characters. `capabilities --json` now declares the two input paths separately (`readsLocalData: {request: false, over: true}`), since only `--over` reads local files.
2107
+ - `weflow-cli decide --request <file>`: a local judgement primitive. Takes a caller-supplied `{state, questions}` and returns typed answers with probabilities, plus the token count and the cost of the call, from one request. It reads no local data - the state is whatever the caller provides. Requests are validated locally so a malformed one fails with a reason instead of a service-side 422. **Not exposed over MCP**: an MCP client driving local outbound calls is a new egress surface and needs its own decision (recorded as `mcpExposed: false` in `capabilities --json`). See D-034.
2108
+ - `weflow-cli awaiting`: who is waiting on a reply. Asks one decision-model request per conversation and prints a ranked list; `--dry-run` previews what would be sent without any egress, `--yes` runs, and it is registered in `capabilities --json` (D-018). The underlying script is `scripts/reply_debt.py`: it asks one decision-model request per conversation whether the thread is sitting on a reply the user owes, and prints a ranked list with probabilities, debt age, and conversation kind. It is **not** the same question as `extract_todos.py`, which scans up to 80 conversations into a *single* LLM call asking whether any task was mentioned - that call has nowhere to record *who* is waiting. Judgements here are per-conversation, so each one knows whose it is. 25 conversations judged in ~5s at 6 workers. Each row prints the strength of its evidence, because a score derived from a two-character message is not the same claim as one derived from a paragraph. See D-033.
2109
+ - Search results are reranked by a decision model. `semantic_search.search()` scored by embedding similarity and took `argsort[:top_k]` - there was no second pass at all, and the keyword fallback (used whenever embeddings are unavailable) was cruder still. It now asks one question per candidate in a **single** request (~1s for 20 candidates; measured 0.84s at 2 questions against 0.91s at 12) and reorders by the probabilities, keeping `score` as-is and adding `rerankScore`. `--no-rerank` restores the previous behaviour. See D-032. Verified on real data: an article about PM2.5 and plant water-use efficiency, planted at position 4 of 8, came out first at 0.94 against 0.01-0.04 for the rest.
2110
+ - Transient decision-model failures are retried (429/5xx/529, short backoff). A live `HTTP 529 system_overloaded` was hit while building the reranker - the service launched on 2026-09-15 and being saturated is plausible. Auth failures are not retried.
2111
+ - A preview no longer requires the API key it is meant to help you decide about spending. `awaiting --dry-run` used to check for a key first and refuse without one, which put the cost of entry *before* the menu.
2112
+ - Non-text messages (images, voice, stickers) no longer reach a decision model as blank lines. WeChat stores them with empty `message_content`, so `reply_debt.py` sent `对方:` with nothing after it and got a confident answer back from the blank. They now carry a type label. See D-033.
2113
+ - `build_index` now indexes articles. `collect_articles()` filtered directory entries with `glob(' marriage*.md')` - a leading space and a `marriage` prefix, matching **0 of 188** files in a real topic directory. So the semantic index had never contained a single article, only chat messages, and nothing reported it. **The next `search-index` run will index articles for the first time and therefore consume embedding calls it never spent before** - that is a deliberate rebuild, not something a routine command should do silently.
2114
+ - `semantic_search` imports `numpy` and `sqlcipher3` lazily now (`require_numpy()` / `require_sqlcipher()`, the pattern `nt_decrypt.require_sqlcipher` already used). Previously a missing dependency called `sys.exit()` at import time, so the keyword-only and reranking paths - which need neither - were unusable on such a machine, and no test could import the module at all.
2115
+ - Batch mode in `decide` shipped without tests and crashed on first use: a deletion patch had left dead code in `main()` that the batch branch fell into, referencing an undefined variable. Found by running it, not by reading it, which is the point - `test/decide_test.py` now covers the batch path, including that it reaches the model at all.
2116
+ - `relevanceScore`, `topicConfidence` and `includeScore` never reached `.articles.json`, only the markdown frontmatter - and the report reads the JSON first. So the probability fields were invisible on the report's **primary** path. Introduced with the fields themselves, found by running a real dry-run and seeing the new section claim there were no probabilities, while the frontmatter plainly had them. Their serialization is now a named function with a test.
2117
+ - `dashscopeApiKey` is a first-class config key now: declared in `CliConfig`, **encrypted at rest** like the other secrets, and settable with `config set`. It had been read by `rag_chat` and `semantic_search` while not existing in the schema at all, so `config set dashscopeApiKey` was refused and the scripts' own error text could only tell the user to hand-edit `config.json`. Their reads now go through `_utils.get_dashscope_key`.
2118
+ - `config set favPassphrase` works. It was in `ENCRYPTED_KEYS` and read by `biz_daily` and `reply_debt`, but was missing from the `configurableKeys` allowlist - so a first-class secret could be neither set nor rotated through any documented path, and hand-editing a file whose field is ciphertext does not produce something usable.
2119
+ - Two static checks now enforce what that audit was looking for: every key a script reads is declared in `CliConfig` (`test/config_keys_declared_test.py`, with a justification-required exception list), and no script reads an encrypted key without decrypting it.
2120
+ - `nt_decrypt` and `export_chat_html` shared four same-named helpers that were **not the same code**, and they are now one implementation in `scripts/nt_common.py` (`discover_message_shards`, `derive_database_key`, `table_columns`, plus the exclusion set). The two differed in contract, not just in wording: `discover_message_shards` in `nt_decrypt` returned `[the configured database]` when it globbed no shards, while the exporter's returned an empty list and left the compensation to its call site. A second caller that forgot to compensate would silently read zero shards. `derive_database_key` and `table_columns` were byte-identical apart from docstrings. The never-empty contract is now the only one, the exporter's `if not shards: shards = [db_path]` is gone, and `test/nt_decrypt_shards_test.py` pins the single implementation by **identity** rather than by "the two agree" - two copies that happen to agree is exactly how this drifted in the first place.
2121
+ - `nt_decrypt.load_contact_names` no longer leaks its connection when the read fails: `conn.close()` sat at the end of the `try`, so any exception before it left the handle open, which on Windows keeps the file locked (the same shape as the shard-read leak fixed earlier). It also keeps the names it did read instead of discarding all of them.
2122
+ - The keyword-search fallback no longer returns generated report artifacts as articles. Searching for 环境 空气质量 returned **行动建议 — 2026-08-30**, a file the daily pipeline writes, not an article. The artifact exists at **two depths** (`<day>/行动建议.md` and `<day>/<topic>/行动建议.md`, four of each on the real data), so filtering by path is not enough - the fix uses the same predicate `quality_eval` already applies: a real article carries `url:` in its frontmatter. The index-building path never had this problem because it only walks topic directories, which is why it went unnoticed: **the two paths disagreed, and the unfiltered one is the one that actually runs** (the index has never been built on this machine).
2123
+ - Four scripts read `deepseekApiKey` straight out of the config file while every other reader decrypts it first: `annual_report`, `classify_daily`, `extract_todos` and `rag_chat`. Since that key is in `ENCRYPTED_KEYS`, `config set` stores it as `lock:<ciphertext>` - so those four would hand `lock:...` to the provider as an API key and report an auth failure that says nothing about why. **It has not surfaced only because on this machine that one key happens to be plaintext**, written by something that bypassed `config set`; every other encrypted key on disk is ciphertext. Rotating the key with `config set` would have broken all four at once. They now go through `_utils.get_api_key`, and `test/encrypted_config_test.py` statically fails the build if any script reads an encrypted key without decrypting it - while accepting the repo's existing `*_enc` convention (hold the ciphertext, decrypt later), which seven correct call sites use.
2124
+ - Four declared-but-unused things are gone, each of which described behaviour the code did not have: `biz_daily.MAX_ARTICLES = 50` with a comment claiming it capped fetching (never read; `--limit` is what works, and a normal day is 150-270 articles); a `CONFIG_PATH` in the same file (config goes through `_utils.load_config`); `_utils.parallel_map` (a concurrency helper with no callers); and fifteen lines of **unreachable** code after `search()`'s return, which returned a `dict` while the live code returns a `list` - a dead block documenting a contract the function no longer has. Their imports went with them.
2125
+ - The article prompt no longer contradicts itself about the topic list. `TOPIC_PROMPT` generated its "must be one of" line from `TOPICS` (six categories) but hardcoded **five** in the two reminders further down, and the judging rules had no entry for 政治 at all - a category that accounts for 26% of the corpus. Both reminders and the rules are now generated from the shared table, so the LLM path is told to pick from six categories *and* given a definition for each. **This can change topic output on the LLM path** (which is the fallback when no decision-model key is configured): a category that previously had no criteria now has one.
2126
+ - The topic taxonomy has a single definition. `TOPICS` had been copied into six files and `TOPIC_CRITERIA` existed separately in `jev_client`; both now live in `_utils` and every consumer imports them, so the prompt path and the decision-model path judge by the same table. `test/topic_taxonomy_test.py` pins it by identity, not equality - copying a list and getting it right is exactly how it drifted the first time.
2127
+ - The admission filter's log line claimed the threshold was applied even when it was not: on articles without `includeScore` it printed `(阈值 includeScore >= 0.5)` while the old `relevance == '高'` rule was doing the filtering. It now names the rule it actually used, including the mixed case. A log that misdescribes what it did is worse than no log.
2128
+ - The daily report's admission rule is now actually applied. `if topic != FOCUS_TOPIC and relevance != '高': continue` existed only in the markdown-scan *fallback* loader; the primary loader (`.articles.json`, present on every normal run) returned every article unfiltered, so the rule had never taken effect. Both loaders now call one predicate, and `--include-all` restores the previous collect-everything behaviour. **This changes what the report contains**: non-focus articles will be fewer, because previously there were no filters at all.
2129
+ - `relevance` is now assigned on **every** path through the daily classification loop. It used to be set on two of five: `category_hint` hard-coded it to `中`, and the AI-exception and short-content paths left it unset for the writer's `fm.get('relevance', '中')` to catch. Measured over the 2201 stored articles, 2199 of them carried that default. That alone would have left `generate_ai_report.py`'s gate filtering on topic only; the gate turning out to be unreachable on the primary loader made even that generous.
24
2130
 
25
2131
  ## 1.6.4
26
2132
 
27
2133
  ### Fixed
28
2134
 
29
- - Restored four pieces of matching logic in the HTML exporter that the `90b165a` clone-consolidation merge had silently dropped, all of which lowered media coverage without failing loudly:
2135
+ - Restored four pieces of matching logic in the HTML exporter that the `2d368c6` clone-consolidation merge had silently dropped, all of which lowered media coverage without failing loudly:
30
2136
  - the `unique:<local_id>` fallback, which matches an image when that id resolves to exactly one distinct picture in the conversation (content-deduped, so the shard-collision risk that rules out a bare `local_id` does not apply);
31
2137
  - `is_encoded_media_type`, needed because WeChat stores some forwards as high-bit variants of type 49 — the mask turns those into a plain 49, which is a registered type, so the branch guarding them had become unreachable;
32
2138
  - the guard that keeps a type-49 row carrying a title or url on the link-card path instead of hiding it behind a cached thumbnail;