weflow-cli 1.9.0 → 1.10.0
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.
- package/ARCHITECTURE.md +62 -26
- package/CHANGELOG.md +948 -0
- package/OPERATIONS.md +65 -11
- package/README.en.md +14 -5
- package/README.md +21 -6
- package/bin/weflow-cli.ts +348 -39
- package/dist/bin/weflow-cli.js +363 -68
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/index.d.ts.map +1 -1
- package/dist/mcp-server/index.js +19 -1
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/dbPathService.d.ts +12 -0
- package/dist/src/core/dbPathService.d.ts.map +1 -1
- package/dist/src/core/dbPathService.js +20 -3
- package/dist/src/core/dbPathService.js.map +1 -1
- package/dist/src/panel/server.d.ts.map +1 -1
- package/dist/src/panel/server.js +35 -0
- package/dist/src/panel/server.js.map +1 -1
- package/dist/src/services/assistantService.d.ts +19 -0
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +20 -0
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +27 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +169 -35
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/configService.d.ts +2 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +3 -2
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/messageQuery.d.ts +18 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -1
- package/dist/src/services/messageQuery.js +29 -0
- package/dist/src/services/messageQuery.js.map +1 -1
- package/dist/src/services/wechatClient.d.ts +125 -0
- package/dist/src/services/wechatClient.d.ts.map +1 -0
- package/dist/src/services/wechatClient.js +344 -0
- package/dist/src/services/wechatClient.js.map +1 -0
- package/dist/src/services/wechatMedia.d.ts +44 -0
- package/dist/src/services/wechatMedia.d.ts.map +1 -0
- package/dist/src/services/wechatMedia.js +155 -0
- package/dist/src/services/wechatMedia.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +11 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +55 -11
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/types.d.ts +4 -0
- package/dist/src/types.d.ts.map +1 -1
- package/docs/CONTACT_DB_SCHEMA.md +1085 -0
- package/docs/DECISIONS.md +1274 -0
- package/docs/EXTENDING.md +69 -7
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +6 -0
- package/docs/HEALTH-CHECK.md +12 -7
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
- package/docs/MCP.md +2 -2
- package/docs/PROJECT_STATE.md +62 -5
- package/docs/RELEASING.md +115 -1
- package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
- package/logo.png +0 -0
- package/mcp-server/index.ts +18 -3
- package/package.json +5 -3
- package/resources/js/graph3d/NOTICE.txt +35 -0
- package/resources/js/graph3d/d3-binarytree.min.js +2 -0
- package/resources/js/graph3d/d3-dispatch.min.js +2 -0
- package/resources/js/graph3d/d3-force-3d.min.js +2 -0
- package/resources/js/graph3d/d3-octree.min.js +2 -0
- package/resources/js/graph3d/d3-quadtree.min.js +2 -0
- package/resources/js/graph3d/d3-timer.min.js +2 -0
- package/resources/js/graph3d/layout.mjs +63 -0
- package/resources/js/graph3d/three.min.js +6 -0
- package/resources/js/graph3d/viewer2d.js +448 -0
- package/resources/panel/index.html +12 -0
- package/resources/panel/mascot-blink-1.png +0 -0
- package/resources/panel/mascot-blink-2.png +0 -0
- package/resources/panel/mascot-lift-down-1.png +0 -0
- package/resources/panel/mascot-lift-down-2.png +0 -0
- package/resources/panel/mascot-lift-down-3.png +0 -0
- package/resources/panel/mascot-lift-down-4.png +0 -0
- package/resources/panel/mascot-lift-down-5.png +0 -0
- package/resources/panel/mascot-lift-down-6.png +0 -0
- package/resources/panel/mascot-lift-up-1.png +0 -0
- package/resources/panel/mascot-lift-up-2.png +0 -0
- package/resources/panel/mascot-lift-up-3.png +0 -0
- package/resources/panel/mascot-lift-up-4.png +0 -0
- package/resources/panel/mascot-lift-up-5.png +0 -0
- package/resources/panel/mascot-lift-up-6.png +0 -0
- package/resources/panel/mascot-tickle-1.png +0 -0
- package/resources/panel/mascot-tickle-2.png +0 -0
- package/resources/panel/mascot-tickle-3.png +0 -0
- package/resources/panel/mascot-tickle-4.png +0 -0
- package/resources/panel/panel.css +111 -0
- package/resources/panel/renderer.js +480 -8
- package/scripts/_utils.py +1203 -977
- package/scripts/biz_daily.py +25 -10
- package/scripts/chat_notes.py +15 -5
- package/scripts/check_readme_commands.py +426 -0
- package/scripts/compile_wiki.py +163 -57
- package/scripts/contact_schema.py +339 -0
- package/scripts/data/wechat_emoji.json +225 -0
- package/scripts/fav_notes.py +4 -1
- package/scripts/graph_2d.py +151 -0
- package/scripts/graph_3d.py +646 -0
- package/scripts/health_check.py +163 -26
- package/scripts/nt_decrypt.py +3 -3
- package/scripts/panel_frames.py +683 -0
- package/scripts/pipeline.py +21 -10
- package/scripts/quality_eval.py +5 -0
- package/scripts/rag_chat.py +33 -16
- package/scripts/reply_debt.py +26 -6
- package/scripts/semantic_search.py +130 -22
- package/scripts/vault_rag.py +42 -16
- package/scripts/vault_search.py +44 -8
- package/scripts/wechat_emoticon.py +6 -0
- package/scripts/wiki_lint.py +94 -17
- package/src/core/dbPathService.ts +22 -4
- package/src/panel/server.ts +33 -0
- package/src/services/assistantService.ts +26 -0
- package/src/services/assistantTools.ts +2452 -2319
- package/src/services/configService.ts +5 -2
- package/src/services/messageQuery.ts +29 -0
- package/src/services/wechatClient.ts +380 -0
- package/src/services/wechatMedia.ts +166 -0
- package/src/services/wechatMessageService.ts +62 -11
- package/src/types.ts +4 -0
- package/dist/src/utils/wechatEmoji.d.ts +0 -5
- package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
- package/dist/src/utils/wechatEmoji.js +0 -35
- package/dist/src/utils/wechatEmoji.js.map +0 -1
package/docs/EXTENDING.md
CHANGED
|
@@ -71,8 +71,8 @@ interactive menu - `showInteractiveMenu()` plus the `switch (action)` that maps
|
|
|
71
71
|
|
|
72
72
|
`test/cli-menu.test.ts` keeps those in sync: menu entries and `switch` cases must match **in both directions**, and
|
|
73
73
|
every `runCmd('x')` / `runSubCmd('p','c')` the menu calls must name a command that actually exists (that test asks the
|
|
74
|
-
CLI's own `--help`, because the source cannot tell top-level commands from subcommands -
|
|
75
|
-
as of 2026-
|
|
74
|
+
CLI's own `--help`, because the source cannot tell top-level commands from subcommands - 114 `.command('...')` call sites
|
|
75
|
+
as of 2026-10-07, against however many real commands `--help` reports). This matters because both failure modes are silent: a menu entry with no `case` does nothing
|
|
76
76
|
when picked, and `runCmd` is written as `if (cmd) await ...`, so a renamed command makes the entry do nothing too.
|
|
77
77
|
|
|
78
78
|
Two conventions that hold across the command surface: every command that a script or an AI might drive has a `--json`
|
|
@@ -98,13 +98,35 @@ prints JSON). Three rules that this project has been bitten by:
|
|
|
98
98
|
gives a **GBK** stdout: Chinese comes out as GBK bytes (mojibake if you were expecting UTF-8) and a character
|
|
99
99
|
outside GBK (`✓`, an emoji) raises `UnicodeEncodeError` and kills the run. 34 of the 55 scripts call
|
|
100
100
|
`sys.stdout.reconfigure(encoding='utf-8', errors='replace')` in `main()` so both paths behave the same; if you add a
|
|
101
|
-
script you intend to run by hand, do the same
|
|
102
|
-
that
|
|
101
|
+
script you intend to run by hand, do the same - and note this is no longer advice: `test/script_stdout_encoding_test.py`
|
|
102
|
+
fails any script that prints a character GBK cannot encode without calling it (40 of the 60 scripts call it as of
|
|
103
|
+
2026-10-07; the rest print nothing that GBK cannot encode). A probe that forgets to set that env var will show you a decoding bug
|
|
104
|
+
that does not exist in the real path. **The same variable also hides a real one**: because this environment (and the
|
|
105
|
+
bridge) export `PYTHONIOENCODING=utf-8`, a by-hand `python scripts/x.py > out.txt` never crashes *here* - but a
|
|
106
|
+
user's own shell has stdout at the locale encoding (measured with `python -E`: `gbk`, locale cp936), where the first
|
|
107
|
+
character GBK cannot encode (`✓`, `⚠️`, `⇒`) raises `UnicodeEncodeError` and kills the run. So a
|
|
108
|
+
"spawn it and see if it crashes" test is a false negative on this machine; `test/script_stdout_encoding_test.py`
|
|
109
|
+
inspects the sources statically instead, and requires `sys.stdout.reconfigure(encoding='utf-8', errors='replace')`
|
|
110
|
+
in any script whose stdout literals contain a character GBK cannot encode. The same test covers the other half -
|
|
111
|
+
**text-mode file I/O** - and requires `encoding='utf-8'` on every builtin `open()` (and `write_text`/`read_text`):
|
|
112
|
+
without it a Chinese Windows box reads and writes those files as GBK while everything downstream decodes UTF-8.
|
|
113
|
+
(`Image.open()`, `tarfile.open()` and friends are exempt by construction - they are binary decoders, which is why
|
|
114
|
+
the check matches the *builtin* `open` only.)
|
|
103
115
|
- **Fail loudly.** Exit non-zero, or return a JSON object whose `success` is false with an `error` string. "No JSON on
|
|
104
116
|
stdout" must never be read as "no results" - that conflation is why `get_todos` distinguishes "no pending todos" from
|
|
105
117
|
"extraction has never been run".
|
|
106
118
|
- **State goes in over stdin, not argv.** Command lines are visible in the process list; the drafting tool passes the
|
|
107
119
|
conversation over stdin for exactly that reason, and `semantic_search.py` takes the query through the environment.
|
|
120
|
+
- **A generator whose output must open without a server has to carry its own libraries.** `scripts/graph_3d.py`
|
|
121
|
+
inlines three.js and six d3 modules into the page, because a `file://` page cannot `fetch` its data (CORS) and a CDN
|
|
122
|
+
reference would make a local-first feature need the network. Those files live in `resources/js/graph3d/` (shipped
|
|
123
|
+
via the `resources/` entry in package.json's `files`), the Node layout helper that loads **the same** UMDs sits
|
|
124
|
+
beside them, and `NOTICE.txt` there records versions and licences. Rename or upgrade one and you must change the
|
|
125
|
+
list in **two** places - `graph_3d.py`'s `LIB_D3` and `layout.mjs`'s `LIBS` - which is why
|
|
126
|
+
`test/graph-3d-cli.test.ts` asserts the two agree and that the emitted page contains no external reference at all.
|
|
127
|
+
That script is also the one to copy if your generator's cache is big: `--cache` exists so tests can point it at a
|
|
128
|
+
temporary directory instead of overwriting the real one (the first version of the test did exactly that, on top of a
|
|
129
|
+
background run).
|
|
108
130
|
|
|
109
131
|
Tests for Python live in `test/*_test.py` and run on the same filesystem as everything else (`npm test` covers
|
|
110
132
|
TypeScript; `python -m unittest discover -s test -p '*_test.py'` covers these).
|
|
@@ -140,14 +162,48 @@ description is the material for the page.
|
|
|
140
162
|
`Wiki/Concepts` for the article line, `Chat/Concepts` for the conversation line - so a source that writes into a
|
|
141
163
|
directory nobody reads is invisible, and `compile_wiki` writes whichever line you point `--output` at (one line per
|
|
142
164
|
run, by design). If your source belongs to an existing line, you are done; if it deserves its own, it goes into
|
|
143
|
-
`_utils.
|
|
144
|
-
|
|
145
|
-
|
|
165
|
+
`_utils.KNOWLEDGE_LINES` (**as of 2026-10-10 each entry is a line id plus its own root plus the concepts directory
|
|
166
|
+
inside it**, so a new line is a new tuple, not a new path string) and into every other declaration that
|
|
167
|
+
`test/concept-dirs-agreement.test.ts` pins (that test counts the sites itself rather than trusting a number written
|
|
168
|
+
here) - that test fails until every reader of concept pages knows about the new directory, which is the point.
|
|
169
|
+
|
|
170
|
+
Two things come for free and one does not. For free: graph node ids become line-qualified (`concept_key`), link
|
|
171
|
+
disambiguation follows automatically (`_utils.LinkNamer` reads the other lines' names and only qualifies names that
|
|
172
|
+
more than one line has), and `wiki lint` starts reporting the new line's overlap with the others under
|
|
173
|
+
`crossLineSameName`. Not for free: a
|
|
174
|
+
**display name** - add it to `_utils.LINE_LABELS` and to `assistantTools.VAULT_LINE_LABELS`, which the same test
|
|
175
|
+
pins against each other. Without them the line shows up in reports as a raw directory name, which is not a crash
|
|
176
|
+
and therefore easy to miss.
|
|
146
177
|
|
|
147
178
|
The guard to copy: `test/chat_notes_test.py` writes a card and then runs the **real consumer**
|
|
148
179
|
(`compile_wiki.scan_articles`) over it, asserting the links and descriptions come back. That is the shape of every
|
|
149
180
|
producer/consumer pair in this project - do not assert on the markdown text, assert that the other side reads it.
|
|
150
181
|
|
|
182
|
+
## Recipe G: a new panel face or frame asset
|
|
183
|
+
|
|
184
|
+
The floating ball's artwork lives in `resources/panel/` and is served from there at runtime (the daemon's static
|
|
185
|
+
whitelist, not `dist/`). Adding one is four wiring points plus a contract, and **three of the four fail silently**:
|
|
186
|
+
|
|
187
|
+
1. `resources/panel/<file>.png` - 256x256, 8-bit RGBA, non-interlaced. The repo's only PNG decoder lives in
|
|
188
|
+
`test/panel-tray-pixels.test.ts` and rejects anything else; a frame returned as RGB means the generation is bad, not
|
|
189
|
+
that a background needs removing.
|
|
190
|
+
2. `PANEL_FILES` in `test/panel-packaging.test.ts` - the directory is reconciled against that hand-written list, so a new
|
|
191
|
+
file fails the suite until it is registered.
|
|
192
|
+
3. `STATIC_FILES` in `src/panel/server.ts` - this is not packaging: the page fetches through this whitelist, and a missing
|
|
193
|
+
name 404s at runtime. The symptom is the ball flashing empty for one frame, with `PANEL_ASSET_MISSING` visible only in
|
|
194
|
+
the HTTP response body, where the page cannot show it.
|
|
195
|
+
4. `panel.css` - a `body.<class> #ball .face { background-image: url('/panel/<file>.png') }` rule, plus the prefetch list
|
|
196
|
+
in `renderer.js` if the face can appear on press. A face fetched on demand costs about 15 ms - one 60 Hz frame - so the
|
|
197
|
+
first press after opening would show an empty ball.
|
|
198
|
+
|
|
199
|
+
**If the asset is a frame of an animation, the contract applies:** everything inside the inscribed circle (0 solid pixels
|
|
200
|
+
past r=128, farthest solid distance under 0.98 x 128 measured **from the canvas centre** with solid = alpha >= 128), the
|
|
201
|
+
apex anchored to the resting frame, and the scale taken from **the character's own bounding box** rather than the canvas,
|
|
202
|
+
which also carries the speech bubble and any markings. `scripts/panel_frames.py --check` enforces it, `--normalize` applies
|
|
203
|
+
it, and `--check-raw` compares a raw generation's silhouette against the resting frame before you accept it. Frames that form an ordered animation go in `SCALES` (the contract also asserts the shrink is monotonic); a family of poses that share one size goes in `TICKLE` - `all_frames()` is what the generator, the normalizer and the gates iterate. Re-run all
|
|
204
|
+
three after any regeneration: a frame that is no longer the same character passes every other assertion in the suite, which
|
|
205
|
+
is exactly what happened with the first attempt.
|
|
206
|
+
|
|
151
207
|
## What will catch you
|
|
152
208
|
|
|
153
209
|
| Invariant | Test |
|
|
@@ -159,7 +215,13 @@ producer/consumer pair in this project - do not assert on the markdown text, ass
|
|
|
159
215
|
| A config key exists in all five places | `test/config-keys.test.ts` |
|
|
160
216
|
| The MCP subset contains no write/send/publish tool | `test/assistant-tools.test.ts` |
|
|
161
217
|
| Panel IPC surface is exactly the declared method list | `test/panel-packaging.test.ts` |
|
|
218
|
+
| A panel asset is registered, whitelisted, served, and still the same character | `test/panel-packaging.test.ts`, `test/panel-lift-frames.test.ts` |
|
|
162
219
|
| Interactive-menu entries match `switch` cases, and the commands they call exist | `test/cli-menu.test.ts` |
|
|
220
|
+
| Every flag declared on a Python-backed command is **actually forwarded** to the script (the declaration is TS, the parsing is Python, the step between is hand-written - a dropped flag silently runs the command in its default mode) | `test/contact-schema-cli.test.ts` |
|
|
221
|
+
| The row above, **upside down**: a flag forwarded in the argument list but never declared. Then the machine form is the only output, asking for it by name is `unknown option`, and the script's human mode is unreachable - nothing is dropped, so nothing looks wrong (found on `vault search` on 2026-10-11) | `test/vault-search-cli.test.ts` |
|
|
222
|
+
| ...and that check is **per command, not repo-wide, on purpose.** A hand sweep of all 26 python-backed commands on 2026-10-11 found no further instances; the five it flagged are by design, and generalizations of the check hit all five: conversation identifiers travel through the environment rather than argv, `--engine` / `--interest` / `--date` are values **the CLI computes** (not flags a user types), `--pick` / `--rate-max` / `--rate-window` are enforced in TS (`outboundSend`), and `chat`'s `--interactive` is derived from "no question was given". So a blanket rule would need an allowlist entry for each, and an allowlist that long stops being read. When adding a python-backed command, copy the two-direction check from `test/vault-search-cli.test.ts` | (see the two rows above) |
|
|
223
|
+
| Every **leaf** command (one with an `.action`) declares `--json` - the convention two rows up, finally enforced. Measured 2026-10-11: 113 leaves, 111 declared, 2 allowlisted in the test **with their reason** (their action is a convenience table; the machine path is the `list` subcommand). The allowlist is itself checked for staleness, so a fixed exception cannot rot | `test/json-surface.test.ts` |
|
|
224
|
+
| ...and when a `--json` command rejects an argument, the error is JSON too, not colored prose on stdout. Use `requireCliChoice` / `parseCliInteger` / `requireCliDate` rather than writing the branch by hand - the four hand-written copies of the `--line` check are why three of them had lost the `--json` half. The **argument-validation family is audited and guarded**: 80 sites in three spellings (helpers, `includes`/`Set.has`, regex `.test`), 0 defects (2026-10-11), and every helper call in a `--json` command must pass `opts.json` (its default is `json = false`, so forgetting is silent). Define that family by *behaviour*, not by one spelling: the first pass counted 74 and missed a `Set.has` check and five regex fields. **The ~100 runtime error sites are NOT audited** ("not configured", "no WeChat process", permission denied) - whether each is reachable under `--json` depends on that command's interactive gate, so each needs reading. The one family found and fixed this way (2026-10-12) was `weread`'s nine `!res.ok` one-liners. Two blind spots of the filter that nearly hid them, both worth remembering: it treated a line as handled when an **unrelated** `opts.json` sat within five lines (a `parseCliInteger` call), and it missed sites whose variable is `!infoRes.ok` because the pattern named `res`. A window around a line cannot tell "mentions `opts.json`" from "is governed by `opts.json`" | `test/json-error-surface.test.ts` |
|
|
163
225
|
| A knowledge source's output is readable by the wiki aggregator, links and descriptions intact | `test/chat_notes_test.py`, `test/compile_wiki_test.py` |
|
|
164
226
|
| Every declaration of the concept directories agrees, and nothing that imports the constant stops iterating it | `test/concept-dirs-agreement.test.ts` |
|
|
165
227
|
|
|
@@ -324,6 +324,12 @@ AI 调用前应生成不含正文的预览,说明数据范围、是否联网
|
|
|
324
324
|
5. 为搜索结果增加消息/文章引用对象,不改变现有原始数据字段。
|
|
325
325
|
6. 为 MCP 增加不透明游标和稳定错误码。
|
|
326
326
|
7. 使用合成数据建立同步、搜索、导出和 MCP 的端到端验收样例。
|
|
327
|
+
8. 让**参数解析错误**在 `--json` 下也走 JSON。现在漏了必需选项时 commander 直接往 stderr 打一句、stdout 是**空的**
|
|
328
|
+
(2026-10-12 用"未配置状态驱动全部 `--json` 叶子命令"量到:83 条里 2 条命中,`scene add` / `scene bind`)。
|
|
329
|
+
做法大致是给 `program` 装一个会抛的出口(`exitOverride` + `configureOutput`),捕获后若 argv 里有 `--json`
|
|
330
|
+
就发 `{success:false, code:'INVALID_ARGUMENT', error:'<commander 那句>'}`。**代价**是 `--help` / `--version`
|
|
331
|
+
也走同一条出口,而全库的 `--help` 调用面很宽(含 `scripts/check_readme_commands.py` 那 277+ 条文档命令),
|
|
332
|
+
所以要先有一条覆盖 `--help`/`--version` 的判据再动 —— 否则把帮助信息弄坏的后果比这点收益大得多。
|
|
327
333
|
|
|
328
334
|
首轮完成后,再根据实际失败记录决定是否进入阅读状态、AI 执行层和助手收发队列,避免在数据边界尚未稳定时继续扩大功能面。
|
|
329
335
|
|
package/docs/HEALTH-CHECK.md
CHANGED
|
@@ -23,6 +23,7 @@ WeFlow 健康检查 · 2026-09-19 10:04
|
|
|
23
23
|
|
|
24
24
|
[OK ] 版本一致 CLI X.Y.Z / package X.Y.Z
|
|
25
25
|
[OK ] 环境自检 已初始化=True 消息库=True
|
|
26
|
+
[OK ] 微信客户端 4.1.15.13(D:\WeiXin\4.1.15.13) ⚠ 残留版本目录(没有 Weixin.dll,加载会失败): 4.1.15.11(1 个文件)
|
|
26
27
|
[OK ] Python 依赖 齐全
|
|
27
28
|
[OK ] 会话新鲜度 最新会话 2026-09-19 10:03(0.0 天前)
|
|
28
29
|
[OK ] 读取路径一致 (wxid_6xx1ehe4h…) json 最新 2026-09-19 10:03 / html 最新 2026-09-19 10:03:12
|
|
@@ -31,7 +32,7 @@ WeFlow 健康检查 · 2026-09-19 10:04
|
|
|
31
32
|
[OK ] 导出格式 excel 6788 字节
|
|
32
33
|
[OK ] 计划任务 均在位且上次成功
|
|
33
34
|
|
|
34
|
-
全部通过(
|
|
35
|
+
全部通过(10 项)
|
|
35
36
|
```
|
|
36
37
|
|
|
37
38
|
全程约 1 分钟,其中大部分是 `export html` 扫描媒体索引的固定开销。
|
|
@@ -42,11 +43,13 @@ WeFlow 健康检查 · 2026-09-19 10:04
|
|
|
42
43
|
| --- | --- |
|
|
43
44
|
| **版本一致** | `1.6.0` 的 `--version` 写死成 `1.5.1`,导致「我到底跑的哪个版本」无法回答,Issue #8 的诊断因此卡住 |
|
|
44
45
|
| **环境自检** | 未初始化 / 缺 Python 依赖时,命令报的是「数据库连接失败」,指向错误的方向 |
|
|
46
|
+
| **微信客户端** | 体检从没报过客户端版本,而这是"数据读不出来 / 客户端起不来"时第一个要问的。判据只能是**活进程命令行的 `--user-lib-dir`** —— exe 里那个版本字面量只是**兜底**(实测它恰好与目录同名,所以"读字面量"是个看着会对的错判据);磁盘上**有**版本目录也不代表能用(本机就有一个只剩 1 个文件的残留目录,指向它的加载会失败)。同一份进程表还给出:**客户端自报的数据目录**(与配置推出来的根**按真身比**,本机那条是软链)、**崩溃记录**(只报条数与最近时间)、以及 `update\update.data` 在场时那句"命令行给的版本未必是加载的那个"。**取不到就写"未知"**,并把这个字段整个缺失当成**不通过**(那意味着 CLI 没重建) |
|
|
45
47
|
| **Python 依赖** | `nt_decrypt.py` 是子进程,缺 `sqlcipher3` 的表现和密码错一模一样 |
|
|
46
|
-
| **会话新鲜度** |
|
|
47
|
-
|
|
|
48
|
+
| **会话新鲜度** | 读取只覆盖部分分片时,会话列表停在十几天前,而每条命令都返回「成功」。⚠️ **但"这段时间没聊天"是同一个观测** —— 所以提示里两种解释都写,并点出判别器在隔壁:`读取路径一致` 绿着多半是没聊天,两项一起红才更像读取断了 |
|
|
49
|
+
| **选择样本会话**(下面两项的前置) | 挑不出样本会话时,`读取路径一致` 与 `导出格式` 会**整段不跑**。没有会话时会报红;**有会话、却一条都拿不到 `username`**(字段长变了、列表是半截的)原来直接返回空 ⇒ 那两项静默跳过、报告照样写"全部通过",只是项数悄悄少了两个。现在这一支也报红并说明"这两项没跑,不代表通过" |
|
|
50
|
+
| **读取路径一致** | 同一会话,`export json` 走 NT 路径、`export html` 走 Python 路径。二者曾差 1114 条消息——json 只读到第一个分片。**它曾经会假红**:判据是"两边报的最新消息精确到分钟相同",而 json 先导、html 后导(中间一两分钟)⇒ 检查期间来一条新消息就必然不一致。放宽容差会把这一项唯一要抓的东西(分片半读,繁忙群里轮转就在分钟级)放过去,所以改成**不一致时再读一次 json 仲裁**:那一读能看到 html 那条 ⇒ 是"消息是新的"(判过并说明);看不到 ⇒ 两条路径确实不一致(判红)。仲裁只在本来就不一致时才多花一次导出 |
|
|
48
51
|
| **导出格式 json/txt/excel** | `excel` 曾**从未成功过**,报错只有「导出失败」四个字 |
|
|
49
|
-
| **计划任务** |
|
|
52
|
+
| **计划任务** | 提醒脚本静默停跑,没有任何人会知道。**它还会假绿**:判据是从 `schtasks` 的文本输出里找 `Last Result` / `上次运行结果` 那一行,而系统语言或输出格式一变就找不到它 —— 那时两个清单都是空的,它会报"均在位且上次成功"。现在读不出来就报**"没有验证"**(红),与"取不到就说未知"是同一条纪律。**同一条纪律还修了另一处(2026-10-11)**:任务刚注册、还没到第一次触发时,`schtasks` 报的是 `SCHED_S_TASK_HAS_NOT_RUN`(`267011` / `0x41303`)—— 原来它落进"上次运行失败"那一支,提示还写着"脚本可能抛异常了",会把刚注册的人引去查一个并不存在的异常。现在它是**自己的状态**("还没跑过"),**仍然算不通过**:这一项要的是"跑过一次且成功",而"一直没跑过"正是它要防的。(这两个 watcher 任务的**注册命令**此前从没写下来过,只在 `RELEASING.md` 的「新动态邮件提醒」一节里有名字 —— 已补上。)|
|
|
50
53
|
|
|
51
54
|
### 「读取路径一致」为什么用时间戳而不是条数
|
|
52
55
|
|
|
@@ -85,12 +88,14 @@ schtasks /create /tn "WeFlow Health Check" ^
|
|
|
85
88
|
## 目前已知、但**未修**的问题
|
|
86
89
|
|
|
87
90
|
体检脚本覆盖不到这些;它们需要人判断,所以留在这里而不是塞进自动检查。
|
|
91
|
+
**修好的行留在表里并标"已修"** —— 这一节的价值一半在教训,其中一行正是因为没人回头核而多挂了些日子。
|
|
88
92
|
|
|
89
93
|
| 问题 | 现象 | 为什么没自动修 |
|
|
90
94
|
| --- | --- | --- |
|
|
91
|
-
|
|
|
92
|
-
|
|
|
93
|
-
| `
|
|
95
|
+
| 判断层按**地区**不可用 | `decide.py` 与日报的 Jev 分类都拿到 `HTTP 451: {"title":"Typesafe is not available in your region."}` | **不能修**(服务端策略;本机 2026-10-10 实测)。功能不坏,是**逐篇/整轮退回老路径**:助手按设计回退(日志里那行 `判断层不可用: HTTP 451…`),日报每篇退回 LLM 解析。**已改**:同一条失败在日报日志里**折叠成一次**(`另有 N 篇因**同一条**失败被折叠`),此前上百篇会刷上百行同一句话;要连这一次尝试也省掉就用 `--classifier llm`(`OPERATIONS.md` 里已写明)。 |
|
|
96
|
+
| 未知 talker 的退出码不一致 | `messages <拼错的wxid>` 退出 0 且 `success:true`;同样输入 `export` 退出 1 | **退出码刻意不动**("没有消息"不是错误,脚本与 MCP 依赖它),但**歧义已经说出来**(2026-10-09):`messages` 遇到 `wxid_`/`@chatroom`/`@openim` 形状、而会话与联系人都不认识它时,人看的输出补一句「这个 ID 既不在会话里、也不在联系人里……先确认有没有拼错」,`--json` 里多一个可选的 `note` 字段(`success` 与退出码都不变)。真实命令跑过:`messages wxid_zzz_not_a_real_id --json` → `{"success":true,…,"note":"这个 ID 既不在会话里、也不在联系人里:…"}`,退出码 0。判定逻辑是纯函数 `emptyMessagesNote`(`src/services/messageQuery.ts`),接线处有守卫测试 |
|
|
97
|
+
| `whitelist list` / `blacklist list` 空列表 | 曾只输出一个换行,没有「为空」提示 | **已修**(2026-10-09 复核):不带参数时打印 `白名单为空` / `黑名单为空` 加一条「怎么添加」(`bin/weflow-cli.ts:2269`、`:2457`);`whitelist list --json` 与 `blacklist list --json` 都返回 `{"success":true,"entries":[]}` —— 三条都用真实命令跑过,不是读代码推断的 |
|
|
98
|
+
| `dashscopeApiKey` **曾**无法通过 `config set` 写入 | 脚本会读它,但它当时不在 `CliConfig` 里 ⇒ `config set` 拒绝、只能手改 config.json | **已修**(`favPassphrase` 是同一轮发现的):键已声明、加密存储、`config set` 可写;守卫见 `test/config_keys_declared_test.py` |
|
|
94
99
|
| 朋友圈/收藏密钥缺失 | `fav list` 曾把「没配密钥」报成「需 4.x NT 连接」——已修,现在会明确说缺密钥 | — |
|
|
95
100
|
|
|
96
101
|
## 相关文档
|
|
@@ -187,7 +187,7 @@ python scripts/quality_eval.py score <该文件> # 准确率 + 校准曲线 +
|
|
|
187
187
|
- 怎么重排:`sort(key=lambda t: (t[0] is None, -(t[0] or 0.0), t[1]))`(`460`)——**没拿到分的按原序垫到最后,不当 0 分**。新分写进 `rerankScore`(仅非 None 时),原始 `score` 保留。
|
|
188
188
|
- 回退全是 fail-soft(这是设计,活体跑出过 `HTTP 529`):没有客户端或候选 <2 → 原样返回(`420-421`);调用异常 → WARN + 原顺序(`442-444`);全部返回 None → WARN + 原顺序(`455-457`)。
|
|
189
189
|
- **它唯一的真实风险是候选编号错位**:模型拿 `c5` 的内容回答 `c3` 时,最该被选中的那条会**无声下沉**,没有任何报错。`docs/PROJECT_STATE.md:83` 记了这一条。
|
|
190
|
-
-
|
|
190
|
+
- ✅ **`--no-rerank` 现在两边都有**(2026-10-11 接上的通道):CLI 的 `search` 把它转发给脚本,`--dry-run --json` 的预览里也带 `noRerank`。此前它只在 Python 侧 —— 而 `docs/OPERATIONS.md` 已经在教用户敲 `weflow-cli search "..." --no-rerank`,用户会拿到 `unknown option`。**是文档命令面那道检查发现的**(`scripts/check_readme_commands.py` 扫文档代码块):它按"今天真实的 CLI"校验,于是把这条缺口指了出来 —— 而只扫两份 README 时它扫不到。新增 `test/search-cli.test.ts` 钉住这类(声明的旗标必须被分类、被读到、该转发的真的转发)。
|
|
191
191
|
|
|
192
192
|
### 4.7 通用原则(六个触点共同的形状)
|
|
193
193
|
|
|
@@ -203,7 +203,7 @@ python scripts/quality_eval.py score <该文件> # 准确率 + 校准曲线 +
|
|
|
203
203
|
| D-032 | `:506` | 决策模型做 rerank:一次请求、取 top 20 |
|
|
204
204
|
| D-033 | `:549` | 给人看的机器判断都要带证据(`reply_debt` 的五问 + 两条通用规则) |
|
|
205
205
|
| D-034 | `:646` | 把决策模型作为本地原语暴露,但**不**经 MCP |
|
|
206
|
-
| D-035 | `:697` |
|
|
206
|
+
| D-035 | `:697` | 助手的单轮快路:**已发、默认关**(`assistantFastRoute` = `off`|`log`|`on`;2026-09-22 起 Status: Active) |
|
|
207
207
|
| D-036 | `:759` | 检索用决策模型选查询词,**不**用它排会话 |
|
|
208
208
|
|
|
209
209
|
---
|
package/docs/MCP.md
CHANGED
|
@@ -118,10 +118,10 @@ client-local config that is not in version control.
|
|
|
118
118
|
| `wechat.get_sns` | Read the Moments timeline or statistics. | Decrypted WeChat database |
|
|
119
119
|
| `wechat.get_weread` | Read WeRead shelf, notebooks, or search books. | `wereadApiKey` config |
|
|
120
120
|
| `wechat.get_todos` | List todos extracted from chat history. | `scripts/extract_todos.py`, Python |
|
|
121
|
-
| `wechat.search_knowledge` | Fuzzy-search concept pages in the knowledge base. | Vault Wiki output |
|
|
121
|
+
| `wechat.search_knowledge` | Fuzzy-search concept pages in the knowledge base. Searches **one of the two lines by default** (the article one); pass `line: "chat"` or `"all"` for the conversation line. | Vault Wiki output |
|
|
122
122
|
| `wechat.search_memory` | Search long-term assistant memory. | `~/.weflow-cli/assistant_memory.json` |
|
|
123
123
|
| `wechat.search_chats` | Find which conversation discussed something (literal words). | Decrypted WeChat database, **words leave the machine - `confirm: true` required** |
|
|
124
|
-
| `wechat.search_semantic` | Find chat messages by meaning, not literal words. | `dashscopeApiKey` config, network - **query and hits leave the machine, `confirm: true` required** |
|
|
124
|
+
| `wechat.search_semantic` | Find chat messages by meaning, not literal words. Defaults to the article line; pass `line: "chat"` or `"all"`. | `dashscopeApiKey` config, network - **query and hits leave the machine, `confirm: true` required** |
|
|
125
125
|
| `wechat.who_owes_reply` | Rank the conversations waiting on a reply. | Decrypted WeChat database, **text leaves the machine - `confirm: true` required** |
|
|
126
126
|
| `wechat.draft_reply` | Draft candidate replies. | Decrypted WeChat database, **text leaves the machine - `confirm: true` required** |
|
|
127
127
|
| `wechat.export_chat` | Export one conversation to HTML, txt, json or excel. | Decrypted WeChat database, **writes under `output/exports/`** |
|
package/docs/PROJECT_STATE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Project State
|
|
2
2
|
|
|
3
|
-
> Last reviewed: 2026-10-
|
|
3
|
+
> Last reviewed: 2026-10-02 (the historical backfill can fetch one topic at a time - `--topic AI` uses the classifier the script already had, `_guess_topic`, which reads only an article's title and its account name and never the body, so it selects exactly the articles a full run would file under that topic: over 2025-09-05 ~ 2026-03-02 the untouched backlog is 9,207 articles across 156 days (official accounts only, `gh_%`), 1,758 of them AI, and a full run needs 3,929 network fetches against the AI run's 1,208 (the rest already carry enough text in the database); the window has since been fetched in full - **8,754** articles over 157 days in an archive whose index matches the directory on **every** day, then carried through reading notes and cards into the Vault, which now holds **49,953** concept pages with **0** broken links. A day written that way is **partial and says so** - `topicFilter` in `.articles.json`, the missing topics named in its README, and `day_done` refusing to treat it as finished, so a later full run still covers it; auditing that turned up the same defect already shipped in `--limit-per-day`, where a sliced day read as complete and everything past the limit was silently out of scope, which now carries its own marker and is redone on the next run (D-060); previously, 2026-09-28 (the Vault's copy of an article is cleaned as it is copied in - the eight WeChat interface phrases go, the frontmatter and any fenced code block stay, and the fetched original in `output/biz-daily` stays byte-for-byte (D-055); the rule that decides which days that copy covers had been keyed off a `backfilled` marker that only one of the two writers sets, so **12 days were never copied and never reported as skipped** - it now asks whether the day holds an article, 187 → 199 days; `wiki lint`'s duplicate-title report was **rebuilt**, not re-tuned: the old rule (a common substring of ≥5 characters covering ≥40% of the shorter title) had gone from useful at 5,062 pages to **424,697 groups** at 22,374, so it now reports two tiers - 314 groups that differ only in spacing/hyphen/case/plural and use the **same normaliser as `wiki compile --merge-duplicates`** (so the list is directly executable; measured, every group is one that tool would merge), and 378 groups where one name is the other plus a qualifier, labelled as a lead rather than a conclusion because hand-sampling puts its precision near half (a "hub" filter takes out the worst of it - `agent` alone is contained in 731 names). Per-directory, because the two concept directories are two knowledge bases and the merge tool runs per directory - the first version compared across both and so told the reader that two cross-line groups were mergeable when the tool could never touch them; the merge was then run on the resulting list, absorbing **341 pages** (338 article-line, 3 chat-line) into their duplicates, and `wiki lint` still reports **0 broken links** because `aliases` catch the old names. That run also exposed a latent defect in the merge itself: it carried `sources:` over from the surviving page only, so **all 315 merged groups** lost at least one card name from their frontmatter. That key drives `来源/文章|聊天|收藏` tag inference, so the fix is to union it, and the 315 pages were repaired from the pre-merge backup. Measured afterwards: the loss changed no tag, because **0 of 22,033 pages carry sources from more than one line** - latent, not live; the knowledge base now holds **35,463** article copies, **34,092** reading notes and **22,374** concept pages across both lines, most of the article ones built with no model call; previously, 2026-09-27 (the two knowledge bases - articles and conversations - were split into one Vault with two directories, `Wiki/Concepts` and `Chat/Concepts`, which meant teaching five readers that a second directory exists, since reading one of two halves the results silently rather than failing; `wiki lint` went from 7,872 broken links to 0; previously, 2026-09-26: the knowledge base has a health check and a gate on what deserves a page - 120 article notes produced 387 concepts of which 37 were durable, and the 108 resulting pages (86 from articles, 6 from conversations, 6 from WeChat favourites, 5 from the user's own notes - a fourth source added the same day, which reads hand-written notes read-only and marks its summaries as model readings) passed `wiki lint` with 0 breaks, and the generated vault now explains itself (generated-vs-hand-edited, `verified: false`, `知识/概念` and `来源/文章|聊天|收藏` nested tags, and the graph-filter string to use - conventions borrowed from a local Obsidian vault built for exam prep); a 1200-token cap was truncating the chat line's JSON on long conversations and is now 2000; the WeChat channel reports an expired token instead of retrying forever; conversations now feed the knowledge base as cards the existing wiki pipeline aggregates; drafted replies: text only, never sent, and never judged against the user's own last message; the assistant may not invent a reason for a tool failure; every extension point in docs/EXTENDING.md now names the test that catches a missed step; extension is in-repo or over MCP, by decision). This is the current maintenance snapshot, not a release note. Keep it factual and update it with meaningful project changes.
|
|
4
4
|
|
|
5
5
|
## Purpose
|
|
6
6
|
|
|
@@ -14,15 +14,23 @@ WeFlow CLI is a local-first command-line tool and MCP server for user-authorized
|
|
|
14
14
|
- Compiled/package command: `node cli.cjs <command>`; CLI, MCP, database, export, and assistant services resolve resources from the same package root in both source and compiled layouts.
|
|
15
15
|
- Build check: `npm run build`.
|
|
16
16
|
- Regression check: `npm test`.
|
|
17
|
+
- CI: three jobs (`node`, `python`, `release-consistency`) on ubuntu. **Both test jobs had been failing on every single run for at least 60 runs before 2026-10-11**, so nothing in CI was actually gating anything; the causes were Windows path semantics in `wechatClient`, a stale dependency list in the python job, and six tests that read the **developer's machine** (knowledge-base root, privacy config, timezone) rather than a fixture. Green as of 2026-10-11. Failing test names are emitted as **annotations**, because the step logs live in blob storage this machine cannot reach (`gh run view --log-failed` vs `--json`, measured). Five `health_check` cases that used to carry `skipIf(os.name != 'nt')` - and therefore never ran in CI at all - now pin the platform instead, so the scheduled-task row has coverage there too.
|
|
18
|
+
- Command surface: `scripts/check_readme_commands.py` checks **three hand-written command surfaces** - every `weflow-cli ...` line (and every `node …/cli.cjs <cmd>` / `npx tsx bin/weflow-cli.ts <cmd>` invocation, which is the same thing written differently) in the fenced code blocks of the **user-facing docs** (the two READMEs, the root docs, `docs/*.md` except `CHANGELOG.md`/`DECISIONS.md`, which quote past commands), every `python|py scripts/x.py --flag` call in those same blocks (judged by AST - the scripts are never run, per the repo's discipline), and every hand-written `cli:` **and `preview:`** string in `capabilities --json` (the `preview:` half is the two-phase convention's first step, i.e. a promise users and agents act on; that output's MCP half is derived from the real registry, so only the hand-written half needs this) - against the **built CLI** (it walks the CLI's own `--help` `Usage:` lines and compares flags against that command's help), and runs in the CI `node` job after the build. The doc-command surface has an anti-empty-scan; `--self-test` carries ten positive controls. Measured state 2026-10-11: **277 doc commands + 24 script calls + 46 invocations from the 45 `cli:`/`preview:` strings, 0 mismatches**. This is the check for the `1.5.0` failure - two months of drift left the README telling users to run `init --path`, which the CLI answered with `unknown option` - and on 2026-10-11 it found the same shape again: `docs/OPERATIONS.md` taught `search --no-rerank`, which the CLI did not have (now wired; `test/search-cli.test.ts` pins that class).
|
|
19
|
+
- npm package contents: what ships is `package.json` -> `files`, and it is an allowlist of **directories and globs** - a file added just outside a listed pattern is dropped with nothing going red. `test/pack-contents.test.ts` asserts the positive side against a real `npm pack --dry-run --json`: every tracked file under `scripts/` ships except the dev-only `.ts`/`.mjs` ones (the `.mjs` is a verification tool that needs a GPU browser), every local image the two READMEs reference ships, and no `dist/` file lacks a matching source (`tsc` never prunes, so a renamed source used to keep shipping - four such files were removed on 2026-10-11). The negative side (nothing from `output/` or `models/`, no keys) is the CI `release-consistency` job. Both halves mattered on 2026-10-11: the published `1.9.0` tarball was missing `scripts/data/wechat_emoji.json` (the emoji fallback map `scripts/wechat_emoji.py` reads) and `logo.png`, so npm installs rendered a batch of face codes as literal text and the package page showed a broken logo.
|
|
17
20
|
- Downstream data contract: local JSON export is the first compatibility boundary for `she-love-me` and future consumers; see `docs/DATA_CONTRACT.md`.
|
|
18
21
|
- Supported focus: Windows WeChat 4.x; Linux WeChat 4.x has an NT database path; macOS requires user-provided local access credentials for initialization.
|
|
22
|
+
The version actually verified on this machine is **4.1.15.13** (2026-10-11) - and the way to read that is the live
|
|
23
|
+
process's `--user-lib-dir`, **not** the version literal inside `Weixin.exe` (that one is only a fallback for when the
|
|
24
|
+
primary load path fails; two of this machine's four version *directories* are install leftovers, one of which holds a
|
|
25
|
+
single file - see `docs/CONTACT_DB_SCHEMA.md`, which also records which directories are actually usable).
|
|
19
26
|
|
|
20
27
|
## Verified Capabilities
|
|
21
28
|
|
|
22
29
|
| Area | Current state | Main entry points |
|
|
23
30
|
| --- | --- | --- |
|
|
24
31
|
| Local chat data | Query sessions, contacts, messages, favorites, Moments cache, and exports. HTML chat exports decode NT compressed media records, locally cached WeChat 4.x V2 image containers, entity-escaped emoji XML, and remote emoticons; app cards preserve links and embed available公众号/Bilibili covers. Media matching prefers server-message resources, exact local-message-ID plus timestamp pairs, and a unique local-ID fallback; high-bit encoded image rows are resolved through the same conversation cache. | `sessions`, `contacts`, `messages`, `export`, `fav`, `sns` |
|
|
25
|
-
| Initialization | Verify and reuse existing local database access by default; refresh only when needed. Missing-key tests can run without changing saved configuration. | `init`, `init --refresh`, `init --test-missing-keys`, `config forget-keys`, `check` |
|
|
32
|
+
| Initialization | Verify and reuse existing local database access by default; refresh only when needed. Missing-key tests can run without changing saved configuration. Data-root discovery asks the **running client** first (its command line carries `--wechat-files-path`; the other two routes guess from the folder name) and reports which source won; a hint still has to pass the same structural check. | `init`, `init --refresh`, `init --test-missing-keys`, `config forget-keys`, `check` |
|
|
33
|
+
| Environment / diagnosis | `check` (and the scheduled `health_check`) reports the **WeChat build actually loaded**, read from the live process's `--user-lib-dir` rather than from the version literal inside `Weixin.exe` (that one is only a fallback - D-107), and flags version directories that cannot work (no `Weixin.dll`). When the client is not running it says *unknown*, and a missing field counts as a failure so a stale build cannot pass. Verified on this machine: `4.1.15.13` loaded, `4.1.15.11\` (one file) flagged as a leftover. | `check`, `check --json`, `scripts/health_check.py` |
|
|
26
34
|
| Relationship temperature | Who is going quiet, who is heating up, and who is reachable **only** here. Reads the local message database directly: read-only, no model, no network (D-062). Three groups - `cooling` (substantial history then silence, ranked by messages x silent days), `heating` (last 7 days at least twice the long-run daily rate), `only-wechat` (no phone/email/meeting-up ever appears in the history). Only 1:1 chats, only text messages, and only accounts whose **id shape** is a person: anything with `@` (`@chatroom`/`@openim`/`@weclaw`), `filehelper` or `gh_` is out - ranking by volume alone produced no information, and the first irreplaceability rule was inverted (it is trivially true of bots, so it put a coffee-shop bot first). Accounts that look like a person but whose name reads as a shop are **flagged, not dropped**, and every exclusion is printed with its reason. Deliberately **not** in the assistant or MCP tool tables: its output is an inference about third parties. | `bonds` |
|
|
27
35
|
| Official-account daily | Filter configured sources, preserve source categories, backfill incomplete yesterday output before an unqualified today run, fetch articles, classify them, create summaries, generate a local HTML reader, and synchronize reader favorites into local files. Topic (6-way), relevance (3-level) and "does this belong in today's report" (yes/no) are decided by TypeSafe's Jev when `typesafeApiKey` is configured - concurrently, before the serial summary loop (12 articles in 3.3s at 6 workers against ~12s one at a time), and by the previous LLM-parse path otherwise; `--classifier llm` forces the latter. The report's admission rule is applied on both loader paths and can be bypassed with `--include-all`. | `daily`, `daily favorites`, `daily-stats`, `daily-server` |
|
|
28
36
|
| Knowledge workflows | Wiki compilation (`compile_wiki` aggregates `[[concept]]` links into Vault pages; **the article corpus produced none as of 2026-09-26** (the 1,633 article notes then in the Vault carried only their own topic link and `## 关联网络` path links, measured over the whole corpus), so an article-side concept-extraction step was missing; `article-notes` (added the same day) is that step, and it works: 30 article notes produced 110 concepts and 10 concept pages on this machine, which are now the first knowledge pages the assistant can search (`vault search`, `search_knowledge`). The corpus has grown since - 7,119 concept cards, and 22,374 concept pages across both lines as of 2026-09-28. The degeneracy reported in that trial was an artefact of a 120-card sample: with all 710 September articles carded, the topic field splits into 学术/新闻/政治 (62/33/25 of 108 pages), so the earlier "the classifier puts everything in one bucket" judgement is withdrawn. Two upstream noise sources showed up in that trial. The first - WeChat reader UI text inside article summaries - is **fixed**, and the backfill this line used to defer has since been run: measured over the corpus it was 1,484 of 1,633 notes (the corpus has grown to 34,092 since), the cleaner runs on both write paths, and `create_reading_notes --clean-summaries` took the existing notes to **0**; the Vault's `Sources/WeChat` copies were cleaned on 2026-09-28 as well (D-055), so the residue now survives only in `output/biz-daily`, which is the archive. The second is **measured but not fixed**: of 224 notes whose titles read as news, 40 are labelled `学术` - that classifier belongs to the daily pipeline and changing its criteria without labels would be a guess. Fed by both sources: `output/biz-daily` articles and, since 2026-09-26, `output/chat-notes` conversation cards produced by the confirmed `chat-notes` command - which, since 2026-10-01, carries voice messages as **text** rather than a bare `[语音]`: it reads the local faster-whisper transcript cache (content-addressed, `output/.voice-cache` - one definition, in `wechat_voice`) and labels each one `[语音·本机转写·未校对]`, because a machine transcript of someone's speech may be wrong and the model must not quote it as fact. Measured on the 30-day window: **54 of 125** voice messages carry text that used to be a placeholder; the ones still missing are counted and printed, and `--transcribe-voice` fills them locally (the cache is the progress, so an interrupted run resumes); **the graph is also kept free of duplicate nodes by `wiki compile --merge-duplicates`** (74 groups merged, 80 pages removed, and 98 links that pointed at the removed names still resolve through `aliases` - see D-053); **most of the article line's pages were never written by a model** - `wiki compile --pages-from-cards` assembles a page from material the cards already carry (the definition is the card's own `desc`, the related concepts are the other concepts from the same article, the source is that article) and marks it `summary_by: card`. That label is deliberate and is not the same as "no model was involved": the `desc` line was written by `article_notes`, so the quality is that line's quality, and a few read as meta-descriptions of where the term appeared rather than as definitions. 17,383 pages at ¥0; the model-based route was priced at ¥31 and rejected, because every one of those concepts is mentioned by exactly one article (D-054); **the pages accumulate only if `wiki compile --refresh-sources` is run** - `wiki compile` itself only builds pages for concepts that do not have one, so an existing page never gains an edge (measured: one run added 3,164 edges across 324 article-line pages; on 2026-10-01, after the whole
|
|
@@ -98,18 +106,38 @@ called `get_messages` and refused without ever reaching for `draft_reply`, so an
|
|
|
98
106
|
['draft_reply']` (the first version of that case) would have failed on the first run and sent someone chasing a
|
|
99
107
|
defect that does not exist; asserting the **outcome** instead is what the memory cases already argued for
|
|
100
108
|
(`memory-recall` judges "was it used", not "was `save_memory` called"). Two of the ten are **out of scope here by design**: `fetch_article` and `search_public` leave the machine, and the harness rejects any egress other than the model call ("a second exit means something leaked"). `get_concepts` and `get_review` were in that gap too and are now covered (see below), which required the harness to be able to lay down fixture files and set the environment variables that point the tools at them. The write cases also brought a new floor, `neverSucceeds`: if the named tool was called at all, that call must have produced nothing (read from the trace), so both "asked the user instead of writing" and "called the tool and was refused" pass while an actual write fails - the first live run of `todo-ambiguous-refused` failed because the expectation first written was too narrow (`mustCall: set_todo_status`), and the model had done the better thing (asked which of the two todos to mark). One case (`ambiguous-contact`) runs over its efficiency budget of 3 most times - measured over four runs on 2026-09-30 it used **6, 6, 2 and 4** tool calls, so it varies rather than having shifted once (the earlier note that "a single run cannot say" is now answered by those four runs). The floors pass every time and the traces show it **re-querying the same thing** (`list_sessions` two or three times) rather than failing, so this is a routing-efficiency question, not a broken promise. **A fixture gap found the same day did not explain it**: `listContacts` had never been stubbed, so `list_contacts` in the eval hit the real service against a temp home with no contacts and always came back empty - two cases were therefore running on "the real service happens to find nothing" while this entry claims synthetic data, and a change in that service would have moved the eval silently. The stub was added (contacts derive from the case's own sessions), and four more runs of the case gave **2, 6, 2 and 2** - the spread is unchanged, so the extra calls are the model exploring routes, not the fixture (the slow runs also reach for `search_chats`). Recorded as a negative result rather than a fix and asserts floor conditions: was the tool that could answer actually called, and were tools avoided when none was needed; did tool usage stay bounded; **is a failing tool reported as failing** (exit code 2 produces a reply quoting the error and offering an alternative, not "no one is waiting"); is an unknown contact ever given invented content; is memory judged by the fact landing in long-term memory rather than by whether `save_memory` was called; does an ambiguous contact name get a question back instead of a guess; are two asks in one message both served; and is a refused fetch (an internal address in favourites) reported as a refusal rather than summarised. Synthetic data, temporary home directory (the real audit and memory files stay untouched - a second writer would clobber the daemon's), and only the model call may leave the process. The expectations are the author's, **not human labels**, so this measures floors and not quality; a first green run proves little (cases and behaviour share an author) and the value is in re-running it after a change. Assertions must key off the tool's own diagnostics, never the model's wording - this suite's own first version asserted "查不到" and produced two false failures on "查不了". The criterion that follows from it, applied once more in `todos-never-extracted`: an expectation that moves with the model's phrasing is a budget, not a floor. That case first asserted "the reply mentions extraction" as a hard floor and passed three clean runs; putting the old tool behaviour back (mutation) still passed once before failing once - the model has its own ways of saying it - so the assertion now rides the soft channel, and the deterministic guard for the distinction lives in `test/assistant-tools-branches.test.ts`. Not in `npm test`: it needs network and money. | `npm run eval:assistant`, `--only=<case>`, `--json` |
|
|
109
|
+
| Graph pages (3D / 2D) | `weflow-cli wiki graph` renders the concept graph as a **self-contained** HTML page - on this machine 49,956 concepts / 145,794 links / 9.05 MB (2026-10-02). The libraries, the data and the coordinates are all inlined, so it opens by double-click with **no server and no network**: a `file://` page cannot `fetch` its data (CORS), and a CDN reference would make a local-first feature depend on the network. The layout (250 ticks - the slow part, measured at ~55 s for half this size) is computed at build time by Node and cached under `output/.graph3d-cache/`, so changing only the rendering does not recompute it. The vendored three.js r147 and six d3 modules live in `resources/js/graph3d/` with versions and licences in `NOTICE.txt` beside them. It can be filtered to the structure that matters: `--min-degree N` keeps only concepts whose **whole-graph** degree is at least N (measured: 3,825 of the 49,956 have 10 or more links, and the filtered page is 1.09 MB against the full page's 9.05 MB), and `--line wiki|chat` draws one corpus at a time (48k article concepts vs 1,527 chat ones - different vocabularies that hide each other's structure when merged). Links pointing at removed nodes are dropped with them, because a dangling link makes the force layout fail with "node not found"; the layout cache is keyed by a hash of the data so building the small page no longer evicts the 50k-node coordinates. `test/graph-3d-cli.test.ts` pins that the emitted page carries **no** external reference, that the two library lists (`graph_3d.py` / `layout.mjs`) agree, that `--dry-run` writes nothing, that the filters change the counts and drop dangling links, that the CLI declares **and** forwards both flags, and that a hand-run without the CLI's `PYTHONIOENCODING` still prints readable UTF-8 (this machine's console is GBK). **`--flat` adds a 2D canvas page** (`output/knowledge-graph-2d.html`): the layout is a *separate* build-time solve rather than a flattening of the 3D coordinates - the z axis carries structure, so dropping it stacks unrelated clusters on top of each other - and it is much cheaper (3,825 nodes / 250 ticks in **2.4 s**, against ~55 s for the 3D solve at 25k), so the page is 0.71 MB and needs **no libraries at all** (the layout is precomputed; the renderer is plain canvas, so not even three.js is inlined). Interaction is drag-to-pan, wheel-to-zoom, hover for a name, click to dim everything but a concept and its neighbours, and a search box; `--min-degree` / `--line` apply unchanged, and the 2D and 3D coordinates cache separately (the digest includes the dimension). `test/graph-2d-cli.test.ts` carries a **render smoke test**: it runs the viewer over a fake canvas context and counts the `arc` / `lineTo` / `fillText` calls, because a JS error in a viewer yields a blank page with no failing assertion anywhere. **Performance was then measured in a real headless Edge over CDP** (Node built-in WebSocket, no puppeteer): one `draw()` at 49,956 nodes cost 12.78 ms, of which 50,000 `arc` calls were 9.0 ms against 3.4 ms for the same number of `rect` calls - so sub-2px dots are now squares and interaction redraws are coalesced to one per frame (the handlers used to redraw on every `pointermove`, which is what actually felt laggy). After: 7.6 ms per frame and a 200-event drag storm in 6.9 ms instead of ~2.6 s. Labels are capped and de-overlapped, because at 50k the top-degree concepts are all hubs in the middle and eighty names landed on top of each other. **The 2D solve uses its own force parameters** (stronger repulsion, longer links): the 3D numbers in one dimension fewer pack everything into a featureless disc. The layout cache key covers the data, the dimensions, the tick count **and** those parameters - it used to cover the data only, which silently served stale coordinates after a parameter change (`test/graph_layout_key_test.py`). At 50k a 2D force layout is still a texture rather than a map; the `--min-degree 10` page (3,825 nodes) is the one that reads as structure. |
|
|
101
110
|
| Assistant | Optional WeChat Bot-channel assistant: 31 local tools (12 originally; plus chat search, reply debt, semantic search and chat export, with export in html/txt/json/excel, Moments "who posts most", favourites without a keyword, and drafted replies). The single write tool (`export_chat`) can only create a new directory under `output/exports/` - the model supplies no path and an existing name gets a numeric suffix, so "never overwrite" is enforced in code. `draft_reply` writes nothing and **sends nothing** (D-047). `evidence-review`, `vault promote`, sending messages and every configuration change are deliberately **not** reachable from a chat message: the first two have their own human-facing confirm gates, sending is irreversible, and configuration is meant to be changed on the machine. 31 local tools, three-layer memory (working window / rolling summary / long-term facts), PII redaction before any cloud call, and a 100-message/day quota. It denies **every** sender until an allowlist is configured. The **single-round fast path** (D-035) is shipped but **off by default** (`assistantFastRoute` = `off` | `log` | `on`): it resolves "needs local data? which capability?" in one decision call and pre-dispatches that tool, so the loop's first request already sees the result (two round trips become one). Only the six tools with closed or empty arguments are routable; everything else falls back, and falling back is byte-identical to the previous behaviour. Measured on 17 questions against the real decision layer: 0 wrong routes, 10 routed concretely, ~1.33 s per decision, 2 fell back (`scripts/assistant_route_probe.ts`; those expectations are the author's, not human labels). Covered by synthetic harnesses (stubbed `callLLM`, stubbed `chatService`/`wereadService` singletons, a replaced `fetch`, a stubbed message channel, an injected route decider, temp `HOME`): per-message logic, **all 31 tool branches** (2026-09-29: the last uncovered one, `get_daily_report`, got the `WEFLOW_ASSISTANT_BIZ_DAILY_DIR` injection point its own coverage note had been asking for - read per call, so a test can swap fixtures; it is now asserted against a fixture instead of a "there may or may not be data" shape check), the resident loop itself - who is denied, the three group controls, the serial queue, the 100/day quota with its date rollover - the **privacy wiring** (which `aiEngine` values count as local inference, where `engineConfig` sends the request, and `reviewEvidence` refusing to send chat text to the cloud without `--allow-cloud`), and the fast path (correct / wrong / uncertain / log-only / no dispatch without the audit line). `get_todos` is executed through `pythonBridge`'s injectable runner (empty list, urgency and deadline rendering, the 15-row cap, a classified failure whose `stderr` is redacted, and "no JSON" not being read as "no todos"). It also keeps "no pending todos" apart from "extraction has never been run" (D-044): the emptiness has a cause of its own on a machine where `todos extract` was never invoked, so the tool names that cause and the command that would fill the list instead of reporting "nothing to do". The list is extracted from chat logs only by an explicit, confirmed command; nothing schedules it. Note on that claim's history: an earlier revision of this line - and the commit message for the `pythonBridge` refactor - asserted this coverage before any such test existed; it was written in prose only, and the file's own header comment said the opposite. The tests are real as of this revision. The assistant is time-aware: the system prompt opens with the current local date and weekday, and `get_messages` takes `since`/`until` (a date or `3d`/`2w`/`12h`) so a specific past day is reachable - before this, a day far enough back could not be fetched at all. An identical tool call repeated within one turn is answered with a note instead of being re-executed. The tool list is built per machine: a tool whose prerequisite is missing (semantic search without `dashscopeApiKey`, WeRead without `wereadApiKey`) is not offered at all, and the fast path - which dispatches directly - checks the same predicate; the filter only drops tools that *cannot* run, never ones that merely have no data yet. Name resolution searches the recent sessions first and then falls back to the contact book (remark / display name / nickname / alias), refusing anything that is not unique - measured on 378 real contacts outside the recent sessions: 362 resolved, 0 resolved to the wrong person. | `login-wechat`, `assistant` |
|
|
102
|
-
| 本机面板 (local panel) | A second entrance to the same assistant, for when logging into the Bot channel is not worth it: `weflow-cli panel` opens an always-on-top window (Electron) or a chromeless Edge/Chrome `--app` window when Electron is absent. **The window is only a client** - every turn goes through the daemon's own `runTurn`, so the allowlist path, the 100/day quota counter, the serial queue and the memory file all stay single-writer (D-045). The daemon exposes a **loopback-only** HTTP endpoint (`127.0.0.1:8766`, not configurable) that is deliberately stricter than the daily reader: **every** request needs a per-run token (including `/api/status`), Origin is a second gate, POSTs must be JSON, and only one turn may be in flight (a second one gets 429 rather than being queued, because queueing means it will run and spend quota). The token lives in `~/.weflow-cli/assistant_endpoint.json` (atomic write; readers probe `kill(pid,0)` and verify `service` + `startedAt`, because SIGTERM leaves the file behind) and never reaches a command line: the Electron shell reads it itself and installs it as an `HttpOnly` cookie, while the browser fallback uses a one-time 60-second code from `/api/pair`. The memory bucket decides whether the panel is the same brain as WeChat - it resolves to the single allowlisted id, else it says on screen that it uses a separate bucket (`assistantPanelUser` pins it). Verified live: the browser fallback opened, the pair-code flow returned the page with an `HttpOnly` cookie, a replayed code 401s, and a real question through the endpoint answered from local data while the shared quota counter went 0 -> 3. The floating ball was verified on Windows 11 with Electron 42 (76x76, no caption, always-on-top, renderer authenticated via the cookie, ball visible on screen); with Electron absent the same command falls back to a chromeless Edge/Chrome window and says it cannot give the ball. Ball-to-chat resize and the global hotkey were then exercised **without a click** (DevTools protocol; a differential registration test), which is how two bugs surfaced that a screenshot could not have shown - a non-resizable window ignoring `setSize` on Windows (collapse left it at the expanded size) and a `ready-to-show` listener attached too late (the window sometimes stayed hidden). **Right-clicking in the panel opens a quick-reply menu listing the contacts in `quickReplyContacts` (a config key added for it; the list rides on `/api/status`, which the page already polls every 30 seconds, so no new endpoint and no new IPC). Picking a name asks the assistant to draft for that person through the existing `/api/ask`, so quota, the serial queue, memory and the copy button all come along unchanged. The menu states its own cost (drafting sends that conversation to two cloud models), an empty list shows the command to run instead of an empty box, and the menu is a **native** one (built in the main process from `quick-menu.cjs`) so it draws outside the window - the ball is 96x96 and never has to expand, which is what the first attempt got wrong; the browser fallback does not take over right-click at all, because the native menu's Copy belongs to the user there. That menu's **last item hides the ball** (the only item in it that reaches no model), and it is the one item still present when the list is empty - otherwise a user who never configured the key would get a menu of two grey lines with nothing clickable, including no way to dismiss the ball. It hides rather than quits, so it is deliberately reversible, and the label names the way back (`关闭悬浮球(托盘图标能再打开)`) because the ball runs with `setSkipTaskbar(true)` and would leave nothing on the taskbar to click; the exit actions stay in the tray menu, which already separates them from show/expand (no test ever drives a real right-click, so the menu's *contents* are covered by executing every item's callback in `test/panel-quick-menu.test.ts`). Dragging the ball used to **grow the window by the drag distance** whenever it happened in the chat window: `dragMove` re-read the current size and wrote it back, and that read lags (measured with a probe driving the same IPC sequence: 0 drift with `resizable: false`, exactly the drag delta with `resizable: true`). The extra width showed up as a gap between the ball and the bubble, and expanding re-asserted the exact size - which is why clicking the ball appeared to reset it. The size is now captured once at `dragStart`. **Pressing the ball switches it to a second, closed-eyes face, and releasing reverts it immediately** (a 900ms hold after release was tried first and the user called it stuck; the generated face is composited in only inside an eye-shaped mask, because the endpoint repainted the whole canvas and returned no alpha). The ball stays put and the conversation unfolds beside it** - clicking it opens a bubble to its left (flipping to the right when there is no room, top-aligning when the bubble would run off the top of the work area) and clicking it again closes it; the ball is a toggle and never fades out. The window is "ball + gap + bubble" as one piece (508x560), with the ball pinned to its own corner so it does not move at all; `bubbleLayout` and `ballRectInWindow` in `ball-position.cjs` compute that geometry (pure, tested with synthetic displays, including a round-trip that expand-then-collapse returns the ball to where it started). The mode has one source of truth (the main process announces `{mode, side, anchorY}`; the page only requests), so the ball, the collapse button, the tray and the hotkey take the same path, and `prefers-reduced-motion` stops both halves. Measured on a real window with the real page at 150% scaling: the ball's offset inside the window is exactly 0 at every sample and its screen position varies by at most 1px across a whole expand and collapse (23 and 30 samples), with identical resting positions before and after. Two bugs came out of that measurement rather than out of reading the code: the tween rounded each edge independently so the edge the ball hangs on wobbled 1-2px (since fixed by pinning the corner), and collapsing placed the ball at the window's top-left instead of the corner it sits on, which would have made it jump to the bubble's opposite corner. The platform wrinkle behind some of this: at 150% scaling a request for a 76x76 window yields a ~79px client width (Windows enforces a minimum), which is why the remembered position is the window's, not the ball's visual edge. That arithmetic is a pure module tested against **synthetic** monitor layouts (negative-x second displays, a removed display, a rect larger than the work area) - this machine has one display, so a real second monitor remains unperformed. **The ball's coloured halo is now painted only in the three non-idle states** (`busy` / `offline` / `quota`); while idle the ball is just the mascot, with nothing behind it. It had been on permanently - which was the original request (an animated background) - but that made the mascot PNG's own transparency invisible and, because the same layer is the status light, made offline / busy / quota-out look identical. The `@keyframes drift` and `@property --hue` machinery stays (it drives the busy state); what changed is when the layer is drawn. The test was **rewritten to the new contract, not weakened**: the first version searched the whole stylesheet for each `body.<state> #ball .glow`, which also matches that state's separate `animation: none` rule, so removing a state from the gradient selector still passed - caught by a mutation check, and the assertion now requires the state to appear in the selector of the rule that actually carries the `radial-gradient`. **The right-click menu now has a second section of eight one-click functions** (谁在等我回话 / 待办 / 日报 / 统计 / 阅读统计 / 会话 / 朋友圈 / 微信读书), admitted by a single rule: the action must be completable by **one fixed sentence**, because a native menu cannot collect input - so search/export/read-one-message are excluded rather than shipped as items that do nothing. Each prompt names its tool, which is a request to the model and **not a hard binding** (the assistant still chooses from 19 tools; forcing it is a decision for when a wrong pick is actually observed). The prompt travels with the menu item and the page keeps no id→prompt table (two tables drift into "the menu says A, clicking does B", silently - a test asserts `renderer.js` contains no action id); the cost line is **derived** from the action list rather than written down, because a stale cost warning is worse than none, and it now names which items are cloud calls and which are local reads. The pick payload is `{kind:'contact'|'action', …}` and the page ignores anything else, so a menu from the previous version cannot be dispatched as a draft nobody asked for. All of it verified by executing every item's callback and by two mutation checks (dispatching an action as a draft, and the page keeping its own id table). **The ball's eyes now follow the mouse** (D-059): the main process samples `screen.getCursorScreenPoint()` every 120 ms and pushes the position **only when it changes**, the page eases toward it and stops its rAF loop once it settles. The renderer cannot see the cursor itself - the ball is 96x96 and the pointer is outside it almost always - which is why this needs the main process at all. The ball's face is now **two layers**: `mascot-base.png` (the same art with the iris region repainted as sclera) plus `mascot-iris.png` (the iris, pupil and highlight), composited at zero offset **pixel-identical** to `mascot.png` - a test decodes all three PNGs and asserts that, because same-size / whitelisted / listed-in-`PANEL_FILES` all stay green if the layers are re-cut one pixel off, and a misalignment is visible only to a person. Travel is clamped to an **ellipse** (about 2.6 px sideways, 0.8 up, 2.6 down at the 96 px ball) because those are the measured sclera margins - past them the iris separates from the rim and the eye reads as a sticker sliding. The iris layer is hidden on `busy` / `offline` / `quota` / `ball-happy`, whose faces have their eyes baked in (otherwise there would be two pupils). `prefers-reduced-motion` leaves the eyes still. The cursor position is in-process only, narrowed in `preload.cjs`, never logged and never sent anywhere. **Not yet verified live** - the wiring and the pixel identity are covered by the suite, but the effect has not been run on a real window. **The ball can also be pushed to a screen edge to hide behind it**: drag it until it touches a monitor's left or right edge and let go, and it swaps to a dedicated drawing of the cat **peeking out from behind that edge** - head and front paws out, body hidden, the drawing's left side being a straight cut that lands exactly on the screen edge (`mascot-peek.png`; the right edge reuses it mirrored). The window stays **fully on screen**, flush against the edge - the illusion comes from the artwork, so nothing goes off-screen, the whole ball stays clickable, and a restart simply puts the ball back at the edge rather than losing it. The first click (or drag) brings the normal ball back, and that click deliberately does **not** expand the conversation - on a hidden ball a click means "come back", not "let's talk". Only left and right: top and bottom fight the taskbar and Windows' edge snapping. Two rules carry the effect and both are pinned by tests because everything else stays green without them: the ball's circular clip must be off in this state (a circle would slice the straight cut into an arc) and hover-zoom must be off as well (scale 1.02 lifts the cut 1px off the edge - a visible seam). The snap threshold is deliberately below the normal 24px edge margin, so a ball merely parked is never mistaken for one being pushed off; a test pins that boundary, because raising it past the margin would hide the ball on every release while every other test stayed green. | `panel`, `panel --status`, `panel --ask` |
|
|
111
|
+
| 本机面板 (local panel) | A second entrance to the same assistant, for when logging into the Bot channel is not worth it: `weflow-cli panel` opens an always-on-top window (Electron) or a chromeless Edge/Chrome `--app` window when Electron is absent. **The window is only a client** - every turn goes through the daemon's own `runTurn`, so the allowlist path, the 100/day quota counter, the serial queue and the memory file all stay single-writer (D-045). The daemon exposes a **loopback-only** HTTP endpoint (`127.0.0.1:8766`, not configurable) that is deliberately stricter than the daily reader: **every** request needs a per-run token (including `/api/status`), Origin is a second gate, POSTs must be JSON, and only one turn may be in flight (a second one gets 429 rather than being queued, because queueing means it will run and spend quota). The token lives in `~/.weflow-cli/assistant_endpoint.json` (atomic write; readers probe `kill(pid,0)` and verify `service` + `startedAt`, because SIGTERM leaves the file behind) and never reaches a command line: the Electron shell reads it itself and installs it as an `HttpOnly` cookie, while the browser fallback uses a one-time 60-second code from `/api/pair`. The memory bucket decides whether the panel is the same brain as WeChat - it resolves to the single allowlisted id, else it says on screen that it uses a separate bucket (`assistantPanelUser` pins it). **The panel can show what the assistant remembers** (2026-10-03): a `记忆` button in the bubble header fetches `GET /api/memory` and renders a **read-only** list of the long-term facts, each with the time it was recorded and the quote it came from, so a wrong memory can be traced back to the sentence that produced it. The bucket is decided **server-side** (the same `memoryBucket` the panel already reports) and a query parameter cannot steer it - otherwise the route would be a way to read another memory bucket. **That payload never goes to a model**: it exists for the user to audit what the assistant believes about them, and it is deliberately separate from the fact injection in `buildSystemPrompt`, which has its own budget and redaction. Facts are capped at 30 (`FACTS_MAX`), so the view is bounded by construction; the page renders with `textContent` only, and a test feeds in a memory shaped like an `<img onerror=…>` tag to prove it shows up as text. Verified live: the browser fallback opened, the pair-code flow returned the page with an `HttpOnly` cookie, a replayed code 401s, and a real question through the endpoint answered from local data while the shared quota counter went 0 -> 3. The floating ball was verified on Windows 11 with Electron 42 (76x76, no caption, always-on-top, renderer authenticated via the cookie, ball visible on screen); with Electron absent the same command falls back to a chromeless Edge/Chrome window and says it cannot give the ball. Ball-to-chat resize and the global hotkey were then exercised **without a click** (DevTools protocol; a differential registration test), which is how two bugs surfaced that a screenshot could not have shown - a non-resizable window ignoring `setSize` on Windows (collapse left it at the expanded size) and a `ready-to-show` listener attached too late (the window sometimes stayed hidden). **Right-clicking in the panel opens a quick-reply menu listing the contacts in `quickReplyContacts` (a config key added for it; the list rides on `/api/status`, which the page already polls every 30 seconds, so no new endpoint and no new IPC). Picking a name asks the assistant to draft for that person through the existing `/api/ask`, so quota, the serial queue, memory and the copy button all come along unchanged. The menu states its own cost (drafting sends that conversation to two cloud models), an empty list shows the command to run instead of an empty box, and the menu is a **native** one (built in the main process from `quick-menu.cjs`) so it draws outside the window - the ball is 96x96 and never has to expand, which is what the first attempt got wrong; the browser fallback does not take over right-click at all, because the native menu's Copy belongs to the user there. That menu's **last item hides the ball** (the only item in it that reaches no model), and it is the one item still present when the list is empty - otherwise a user who never configured the key would get a menu of two grey lines with nothing clickable, including no way to dismiss the ball. It hides rather than quits, so it is deliberately reversible, and the label names the way back (`关闭悬浮球(托盘图标能再打开)`) because the ball runs with `setSkipTaskbar(true)` and would leave nothing on the taskbar to click; the exit actions stay in the tray menu, which already separates them from show/expand (no test ever drives a real right-click, so the menu's *contents* are covered by executing every item's callback in `test/panel-quick-menu.test.ts`). Dragging the ball used to **grow the window by the drag distance** whenever it happened in the chat window: `dragMove` re-read the current size and wrote it back, and that read lags (measured with a probe driving the same IPC sequence: 0 drift with `resizable: false`, exactly the drag delta with `resizable: true`). The extra width showed up as a gap between the ball and the bubble, and expanding re-asserted the exact size - which is why clicking the ball appeared to reset it. The size is now captured once at `dragStart`. **Pressing the ball switches it to a second, closed-eyes face, and releasing reverts it immediately** (a 900ms hold after release was tried first and the user called it stuck; the generated face is composited in only inside an eye-shaped mask, because the endpoint repainted the whole canvas and returned no alpha). The ball stays put and the conversation unfolds beside it** - clicking it opens a bubble to its left (flipping to the right when there is no room, top-aligning when the bubble would run off the top of the work area) and clicking it again closes it; the ball is a toggle and never fades out. The window is "ball + gap + bubble" as one piece (508x560), with the ball pinned to its own corner so it does not move at all; `bubbleLayout` and `ballRectInWindow` in `ball-position.cjs` compute that geometry (pure, tested with synthetic displays, including a round-trip that expand-then-collapse returns the ball to where it started). The mode has one source of truth (the main process announces `{mode, side, anchorY}`; the page only requests), so the ball, the collapse button, the tray and the hotkey take the same path, and `prefers-reduced-motion` stops both halves. Measured on a real window with the real page at 150% scaling: the ball's offset inside the window is exactly 0 at every sample and its screen position varies by at most 1px across a whole expand and collapse (23 and 30 samples), with identical resting positions before and after. Two bugs came out of that measurement rather than out of reading the code: the tween rounded each edge independently so the edge the ball hangs on wobbled 1-2px (since fixed by pinning the corner), and collapsing placed the ball at the window's top-left instead of the corner it sits on, which would have made it jump to the bubble's opposite corner. The platform wrinkle behind some of this: at 150% scaling a request for a 76x76 window yields a ~79px client width (Windows enforces a minimum), which is why the remembered position is the window's, not the ball's visual edge. That arithmetic is a pure module tested against **synthetic** monitor layouts (negative-x second displays, a removed display, a rect larger than the work area) - this machine has one display, so a real second monitor remains unperformed. **The ball's coloured halo is gone entirely** (2026-09-29): the element, the three state rules and the `--hue` / `drift` / `breathe` drivers were all deleted, and `test/panel-packaging.test.ts` forbids reintroducing any of them (the user raised the same complaint twice, so the answer was removal rather than "draw less"). **Pressing the ball plays a twelve-frame "picked up by the scruff" animation**: six frames up while it is held (`mascot-lift-up-1..6.png`) and six back down on release (`mascot-lift-down-1..6.png`, then the resting frame `mascot-base.png`), hard cuts about 60 ms apart, the first frame synchronous so a press never shows an empty ball, and no tail after release. The twelve frames are **generated art** (`gpt-image-2-ca` via `/images/edits`; pipeline and contract in `scripts/panel_frames.py`) normalized to: 256x256 RGBA, 0 solid pixels past r=128, farthest solid distance under 0.98 x 128 **measured from the canvas centre with solid = alpha >= 128**, the apex anchored to the resting frame, and the scale taken from **the cat's own bounding box** (`170 x s`) rather than the canvas - the canvas also carries the speech bubble and the shock marks, and letting those set the scale would make a decoration decide the character's size. The silhouette is asserted rather than assumed: the first generation drew the hood as a pointed cone with no ears, moved the cat's aspect ratio from 0.794 to 0.650, and **every geometry assertion still passed**, so the suite now pins the largest connected component's width and aspect ratio. `prefers-reduced-motion` plays no animation and keeps the squint face, which is now that face's only role. **Running the pointer across the ball makes it squirm as if tickled** (2026-10-02): four generated poses (`mascot-tickle-1..4.png`, normalized at scale 1.0 - a tickle must not change the ball's size) cycle every 110 ms, and there is **no tilt** any more (2026-10-02, D-066): the 4-degree rotation that used to be computed in the renderer from the pointer speed was removed because it shared one `rotate` on `#ball` with the lift, which made a casual sweep look like a small take-off - rotation belongs to the lift alone now, and 260 ms without movement settles the poses back. The rotation is about the ball's centre, which is what makes it safe under the circular clip - rotation preserves every pixel's distance from the centre, so the ears cannot be rotated out of the circle (translation would). It is skipped under `prefers-reduced-motion` and while the ball is peek-hidden (that artwork has a straight cut edge that a rotation would open into a seam), and a press settles it so the lift owns the ball. **Not yet verified live** - geometry, wiring and the sequence are covered by `test/panel-lift-frames.test.ts` and `test/panel-lift-sequence.test.ts`, but nobody has watched the animation on a real window yet. The test was **rewritten to the new contract, not weakened**: the first version searched the whole stylesheet for each `body.<state> #ball .glow`, which also matches that state's separate `animation: none` rule, so removing a state from the gradient selector still passed - caught by a mutation check, and the assertion now requires the state to appear in the selector of the rule that actually carries the `radial-gradient`. **The right-click menu now has a second section of eight one-click functions** (谁在等我回话 / 待办 / 日报 / 统计 / 阅读统计 / 会话 / 朋友圈 / 微信读书), admitted by a single rule: the action must be completable by **one fixed sentence**, because a native menu cannot collect input - so search/export/read-one-message are excluded rather than shipped as items that do nothing. Each prompt names its tool, which is a request to the model and **not a hard binding** (the assistant still chooses from 19 tools; forcing it is a decision for when a wrong pick is actually observed). The prompt travels with the menu item and the page keeps no id→prompt table (two tables drift into "the menu says A, clicking does B", silently - a test asserts `renderer.js` contains no action id); the cost line is **derived** from the action list rather than written down, because a stale cost warning is worse than none, and it now names which items are cloud calls and which are local reads. The pick payload is `{kind:'contact'|'action', …}` and the page ignores anything else, so a menu from the previous version cannot be dispatched as a draft nobody asked for. All of it verified by executing every item's callback and by two mutation checks (dispatching an action as a draft, and the page keeping its own id table). **The ball's eyes now follow the mouse** (D-059): the main process samples `screen.getCursorScreenPoint()` every 120 ms and pushes the position **only when it changes**, the page eases toward it and stops its rAF loop once it settles. The renderer cannot see the cursor itself - the ball is 96x96 and the pointer is outside it almost always - which is why this needs the main process at all. The ball's face is now **two layers**: `mascot-base.png` (the same art with the iris region repainted as sclera) plus `mascot-iris.png` (the iris, pupil and highlight), composited at zero offset **pixel-identical** to `mascot.png` - a test decodes all three PNGs and asserts that, because same-size / whitelisted / listed-in-`PANEL_FILES` all stay green if the layers are re-cut one pixel off, and a misalignment is visible only to a person. Travel is clamped to an **ellipse** (about 2.6 px sideways, 0.8 up, 2.6 down at the 96 px ball) because those are the measured sclera margins - past them the iris separates from the rim and the eye reads as a sticker sliding. The iris layer is hidden on `busy` / `offline` / `quota` / `ball-happy`, whose faces have their eyes baked in (otherwise there would be two pupils). `prefers-reduced-motion` leaves the eyes still. The cursor position is in-process only, narrowed in `preload.cjs`, never logged and never sent anywhere. **Not yet verified live** - the wiring and the pixel identity are covered by the suite, but the effect has not been run on a real window. **The ball can also be pushed to a screen edge to hide behind it**: drag it until it touches a monitor's left or right edge and let go, and it swaps to a dedicated drawing of the cat **peeking out from behind that edge** - head and front paws out, body hidden, the drawing's left side being a straight cut that lands exactly on the screen edge (`mascot-peek.png`; the right edge reuses it mirrored). The window stays **fully on screen**, flush against the edge - the illusion comes from the artwork, so nothing goes off-screen, the whole ball stays clickable, and a restart simply puts the ball back at the edge rather than losing it. The first click (or drag) brings the normal ball back, and that click deliberately does **not** expand the conversation - on a hidden ball a click means "come back", not "let's talk". Only left and right: top and bottom fight the taskbar and Windows' edge snapping. Two rules carry the effect and both are pinned by tests because everything else stays green without them: the ball's circular clip must be off in this state (a circle would slice the straight cut into an arc) and hover-zoom must be off as well (scale 1.02 lifts the cut 1px off the edge - a visible seam). The snap threshold is deliberately below the normal 24px edge margin, so a ball merely parked is never mistaken for one being pushed off; a test pins that boundary, because raising it past the margin would hide the ball on every release while every other test stayed green. | `panel`, `panel --status`, `panel --ask` |
|
|
103
112
|
|
|
104
113
|
| Assistant tool surface | The chat assistant now reaches the same ceiling the MCP surface has, minus the exclusions. Ten tools were added on 2026-09-29 (`list_contacts`, `get_review`, `get_concepts`, `fetch_article`, `search_public`, `format_article`, `list_themes`, `lint_wiki`, `check_skills`, `set_todo_status`), seven MCP-only hand-written tools were **moved** into the shared table (same names and arguments, one implementation; the hand-written table went 11 entries to 4), and four tools were **widened** instead of duplicated (`get_daily_report` gained the human-readable daily plus cross-date search, `get_todos` gained the item id and grouping, `get_weread` gained five modes, `get_stats` gained the knowledge-base half). What stays out is unchanged and named in D-058. Two residual risks are recorded there rather than left to be discovered: `search_public` sends a **model-authored query** to a third-party search page with no preview (the user chose this explicitly; `fetch_article` by contrast is allowlisted to `mp.weixin.qq.com` and re-validates every redirect hop), and `set_todo_status` can flip one existing todo between done/pending with no confirmation gate (bounded: no create, no delete - and excluded from MCP entirely). | `list_contacts`, `get_review`, `get_concepts`, `fetch_article`, `search_public`, `format_article`, `list_themes`, `lint_wiki`, `check_skills`, `set_todo_status` |
|
|
105
114
|
| 技能与场景 (skills & scenes) | Two assistant features that give a conversation shape without giving the model new powers. **Skills** are `SKILL.md` packages scanned from a configurable root list (`skillDirs`, default `~/.claude/skills` and `~/.weflow-cli/skills`) and surfaced two ways: the system prompt carries the catalogue (id, name, one-line description, version, state) and two read-only tools, `list_skills` and `read_skill`, return the catalogue and one body. Nothing is installed and nothing is executed - a skill is **material**, the prompt points at its path and asks the model to read it first. Referencing a skill that is missing, disabled or unreadable renders that state and instructs the model to continue and say which part it could not do, rather than dropping the reference or implying the capability exists (`{{skill:<id>}}` in a scene instruction resolves the same way). Disabling is one config key (`skillDisabled`) or one line in the skill's own frontmatter (`enabled: false`). **Scenes** are named prompt presets (keywords, extra instruction, output spec, required skills, enabled) in `~/.weflow-cli/assistant_scenes.json` (`weflow-scenes/v1`, unknown version quarantined rather than migrated). A turn picks at most one through three tiers - explicit binding, keyword hit, last used in that conversation - and a tie between two equally long keyword matches produces **no** scene plus a trace note explaining why, rather than a coin flip. Managed by `weflow-cli scene …` (two-phase for every mutation) and by one built-in chat command (`场景 <id>` / `场景 无` / `场景`), which accepts only an argument that names an existing scene so ordinary sentences beginning with 场景 fall through to the model. **The model cannot write scenes at all** - there is no such tool, and `capabilities --json` reports `modelWritable: false`; a scene enters the prompt wrapped by `frameLocalData`, and ids are refused if they contain quotes or angle brackets (they would close the `source="…"` attribute). Verified on the real machine: `skill check` reports 28 skills / 0 unreadable / 0 collisions / 5 non-conforming-id warnings / 1 self-disabled, and end-to-end tests with a stubbed model assert the scene section reaches the system prompt and the trace records the matching tier. | `skill list/show/check`, `scene list/show/add/remove/enable/disable/bind/unbind`, `场景 <id>` in chat |
|
|
106
115
|
|
|
107
|
-
The
|
|
116
|
+
The 873-test TypeScript regression suite (counted from `npm test` on this revision; it grows with every capability) covers home-path expansion, custom NT data-root discovery, bounded daily-favorites synchronization, confirmed no-AI daily generation, no-AI Vault promotion, Vault content mutation previews, Vault initialization and synchronization confirmation, MCP configuration write confirmation, source/compiled Python resource lookup, outbound PII redaction, local-inference bypass, strict message-body masking, MCP path/date/URL validation and live tool discovery, message-contract preservation, local-date export bounds, export result metadata, evidence-package safety, assistant routing and lifecycle confirmation, message-channel authentication confirmation, todo and access-control mutation confirmation, message-send preview/confirmation, local-reader port validation, redacted configuration status, access-list JSON, database-key reset confirmation, human-gated initialization and key capture, content-free account-scan and assistant-log diagnostics, confirmed Vault RAG, confirmed semantic search and RAG chat, drafted-reply confirmation and its refusal path, structured todo reminders, strict knowledge limits, report previews, skill parsing and its path confinement, scene matching precedence, the three-surface tool alignment (contact listing that keeps wxids out, per-hop redirect re-validation, refusal on ambiguous todo text, cross-date article search), cover-image signature validation, and the pipeline's Vault default (it copies the day into the Obsidian Vault and compiles concept pages **only** when asked - `--with-vault` / `--with-wiki` - because Obsidian's own graph view is live and re-renders whenever a vault file changes, so a graph that "rebuilds itself when the content updates" was really the vault being rewritten; `test/pipeline-vault-default.test.ts` pins the default, since writing the vault succeeds silently and nothing else would have caught a flip back), and the ball's state faces **recovering from a transient failure** (the `offline` face used to latch permanently - it was set when a status poll failed and the success path never cleared it, so an `assistant stop`/`start` was enough to leave the ball looking aggrieved until the window was reopened, which is how the user reported it: the mascot PNGs were byte-identical to the repo, so the artifact was innocent), and **the ball swinging while it is held** (a continuous sine rather than a speed-derived tilt: the swing starts the moment the ball is picked up - a click sways too, because a click *is* a pick-up, so the old "a click must not tilt" assertion was dropped rather than inverted - and the direction you drag adds a **bias towards that side**, and **the whole swing is scaled by the drag speed**, not just the lean: one "energy" value drives both, so hanging still gives a 6-degree sway (being picked up is enough to start it), 400 px/s gives 8.9, 900 gives 12.6, and 1800 px/s reaches 19.3 for a single move (about 22 in a continuous drag); the lean alone was scaled at first (saturating at 450 px/s, below any real drag) while the swing itself stayed a constant +/-12 degrees, which is the part the user actually sees and why he reported it as fixed twice, which is why the test pins that curve on a **virtual clock** (a real one measured `await wait(20)` at 25-31 ms, too coarse to set a speed, and a mutation restoring 450 px/s passed the whole suite because of it). Like the tickle this needs no new artwork and no part of the circle's budget, because rotation about the centre preserves every pixel's distance from it; the amplitude is a matter of taste, not geometry. The energy decays only after 1.2 s of stillness, **deliberately longer than the 900 ms period**: at the 240 ms tried first it had expired before the second peak arrived, so a leftward drag damped the rightward swing (measured -12.6 degrees, indistinguishable from not dragging at all) instead of amplifying the leftward one - half of what was asked for. `test/panel-dangle.test.ts` pins the start-at-pick-up, both extremes of the swing, the settle with its no-leaked-timer check, and the reduced-motion skip; the "both extremes" part is there because the first version sampled only the first 300 ms, which mutation testing showed could not fail on that decay bug, the peak it depends on never appearing in that window), plus **a hop when a reply lands and a blink every 7-13 seconds** (the hop has **no generated frames on purpose**: the model reliably squashes the body for a crouch - measured +9.2%/+8.0% on the outline metric, and milder wording did not help - which over three frames meant the ball visibly fattening and thinning four times in 270 ms, so the hop is a transform with a **budgeted** rise: the art already uses 125.25 of the circle's 128 radius, so the airborne step shrinks to 0.96 first and only then rises 7 px; the blink's two frames are **composited** rather than used as generated, because the model repaints the whole face and even a one-pixel difference reads as the entire ball flickering - only the eye band is pasted back, measured at 0 pixels differing outside it. Both are skipped under `prefers-reduced-motion`; the blink also waits until no other face is showing and the page is visible). The panel's copy button is executed rather than read: `test/panel-copy-button.test.ts` boots `renderer.js` in a jsdom page, goes through the real `submit -> ask() -> fetch -> addTurn` path, clicks the button, and asserts both the success label and that a **failed** clipboard write says so instead of claiming success - and says in its own header that jsdom cannot reproduce Chromium's drag-region behaviour, so it proves the handler and not the OS-level click. Core automation entry points now include `capabilities --json`, `config show --json`, `whitelist list --json`, `blacklist list --json`, `check --json`, `init --dry-run --json`, `scan --json`, `dbkey --dry-run --json`, `sessions --json`, `messages --json`, `contacts --json`, `export ... --json`, `fav export ... --json-result`, `sns ... --json`, `daily-stats --json`, `daily --no-ai --dry-run --json`, `daily-server --status --json`, `assistant status --json`, `assistant log --json`, `todos list --json`, `todos remind --json`, `evidence --json`, `sync run --dry-run --json`, `sync run --yes --json`, `sync status --json`, `sync verify --json`, `evidence-review --dry-run --json`, Vault mutation previews, confirmed `vault rag`, confirmed `search` and `chat`, daily-favorite previews, `search-index --dry-run --json`, `pipeline run --dry-run --json`, `wiki graph --dry-run --json`, `contact-schema --json` and `contact-schema --contacts --json`, report-generator previews, login/logout previews, and `mcp-config --output <file> --dry-run --json-result`. CI runs three jobs: `node` (build plus the TypeScript suite on Node 22.13), `python` (the Python suites on 3.11), and `release-consistency` (package.json and package-lock.json version agreement, plus a check that the npm package carries no outputs, models, databases or keys). The jobs are separate because a Node failure previously stopped the Python tests from running at all, which let them fail unnoticed.
|
|
108
117
|
|
|
109
118
|
The daily workflow supports `dailyAiEnabled=false` for a persistent no-AI mode, or `daily --no-ai` for a single run. Both the CLI and direct Python entry points honor the setting. Fetching, HTML generation, and local indexes remain available in that mode. Machine execution requires `daily --yes --json`; it keeps progress logs on stderr and returns a machine-readable completion result on stdout after checking the required local artifacts.
|
|
110
119
|
|
|
111
120
|
Date-bounded message exports and `wechat.export_messages` page through the selected conversation before applying the result limit. Older matching dates are therefore not hidden by a newer, nonmatching first page. Versioned envelopes report requested bounds, returned count, and conservative `mayHaveMore` metadata. `sync` adds a local checkpoint on top of the same overlapping-window approach, but **a stable incremental cursor is still not exposed** - it must be supported by every relevant database backend before it is advertised (D-027), and `capabilities --json` reports `stableCursor: false` for both.
|
|
112
121
|
|
|
122
|
+
## Not Verified (and what closing it would take)
|
|
123
|
+
|
|
124
|
+
Same shape as the launcher reverse-engineering report's closing section ("what we could not do, and why"): an
|
|
125
|
+
unverified claim gets written down **once**, together with the price of closing it, so it is not rediscovered every
|
|
126
|
+
few weeks. Each row is an index entry - the measurements and the reasoning live in the decision it names.
|
|
127
|
+
|
|
128
|
+
| Not verified | Why not here | What closing it would take |
|
|
129
|
+
| --- | --- | --- |
|
|
130
|
+
| The **semantic (vector) search path** (D-105) | The index has never existed on this machine, and building one needs `dashscopeApiKey` **and** spends embedding credit - so the per-line mask is pinned against a hand-made index, and the real-data checks used the keyword fallback (which is the path this machine actually takes). | One `search-index --yes`, then the same scoped checks re-run against the real index. |
|
|
131
|
+
| `update\update.data`'s **internal format** (D-107) | The report reverse-engineered the loader but not this file, and the file does not exist here. When it does exist the loaded build may not be the one the command line names, so `check` **states that** instead of guessing. | A machine where the file exists. |
|
|
132
|
+
| The **version token ↔ dotted version** mapping (D-107) | The **low 16 bits are decoded** (2026-10-11): byte 3 is the fourth component, byte 2 is the third `+ 0x10` - both known pairs agree. The high 16 bits are `F254` on both, and both versions are `4.1.x`, so major/minor cannot be separated with two samples (n=2: "both fit", not "solved"). | More (build, token) pairs, or the encoder. `update\download` and `update\patch` are empty on this machine, and the client's `log/*.xlog` (53 files, 373 MB) is **not plain text**: grep for the token there returns 0 matches, which is a **false negative** rather than evidence, and single-byte-XOR zlib/zstd does not unlock it either. The falsifiable prediction to test: `4.1.15.11 → 0xF2541F0B`. |
|
|
133
|
+
| The MMKV stores under `%APPDATA%\Tencent\xwechat\radium\mmkv\` (D-101) | Their format holds (crc32 precondition passes; one is empty), but the only key we can construct does not open them, and the `config/` root key is random and was never captured. | **Deliberately not attempted**: the remaining route is injecting into the client process, which this repo does not do (D-101). |
|
|
134
|
+
| **Why** the data root uses hard links (CONTACT_DB_SCHEMA §6.2) | The effect is measured (395 groups, 407 MB double-counted if you sum sizes); the cause is the report's `hardlink.dat` inference (multi-account / multi-version reuse) and has not been established. The file itself was read on 2026-10-11: it is **32 hex characters** (a 16-byte value), one copy on this machine, older than both installed client versions - so it is a marker or seed, not the link inventory (16 bytes cannot describe 395 groups). | Finding what consumes it. One testable guess, not yet tried: use those 16 bytes as an AES-128 key for the `radium\mmkv\` stores, judged by the D-101 oracle (crc32 consistent **and** the whole record sequence walks). The stores are on this machine; the tooling is not. |
|
|
135
|
+
| Issue #7 (HTML emoji export) | Open pending reporter verification. | The reporter's confirmation. |
|
|
136
|
+
| The CLI's **runtime** error paths under `--json` (~100 prose sites in `bin/weflow-cli.ts`) | The argument-validation family **is** audited (80 sites, 0 defects, 2026-10-11) and `weread`'s nine one-line failure branches are fixed, but the rest are runtime failures - "not configured", "no WeChat process", permission denied - whose reachability under `--json` depends on that command's interactive gate. A window around a line cannot tell "mentions `opts.json`" from "is governed by `opts.json`": that filter answered wrongly in **both** directions on 2026-10-11 (305 candidates, then 12, of which the real family was nine). | Reading each site, or driving each command into its failure state where that needs no credentials and no network - `weread`'s empty-`HOME` case is the worked example (`test/json-error-surface.test.ts`). Two named gaps: `export --contract` is verified **on this machine only**, because its check runs after `resolveTalker()`; and the human-facing error arm is not uniform across the CLI (most sites `console.error`, some `console.log`) - a separate, user-visible change that this work deliberately did not make. |
|
|
137
|
+
|
|
138
|
+
**What this is not**: a bug list. Everything above works on the paths this machine exercises; what is missing is
|
|
139
|
+
the *evidence* for the paths it does not.
|
|
140
|
+
|
|
113
141
|
## Security Baseline
|
|
114
142
|
|
|
115
143
|
- Process only data the user owns or is explicitly authorized to access.
|
|
@@ -127,6 +155,7 @@ Date-bounded message exports and `wechat.export_messages` page through the selec
|
|
|
127
155
|
- `init --dry-run --json` exposes a content-free plan. JSON execution returns `INTERACTIVE_REQUIRED`; database verification, directory discovery, key capture, and configuration writes remain an explicit terminal workflow.
|
|
128
156
|
- `vault rag` requires a read-only preview and explicit confirmation before reading local knowledge or calling the configured AI provider. The question is inherited by the Python worker through the environment rather than copied into process arguments.
|
|
129
157
|
- `search` and `chat` use the same preview/confirmation boundary. Queries, questions, and optional conversation restrictions are inherited through the worker environment instead of appearing in process arguments; machine callers cannot start interactive RAG chat.
|
|
158
|
+
- `vault search --json` is the exception to the rows above: its `results[].file` is an **absolute local path**. That is deliberate rather than overlooked - the flag spent its life as the command's only output (the TS action forwarded `--json` unconditionally while declaring no such flag), and that output was meant to be read on the terminal, where a path you can open is the point. Since 2026-10-11 it is opt-in: the default is the script's scored, human-readable list, which carries titles and scores but no paths. Nothing in the repo consumes the JSON (no MCP tool, no assistant tool, no script), so the shape is kept as-is rather than changed around no dependants; if it is ever fed to a model or pasted into an issue, this row is the thing to revisit.
|
|
130
159
|
- `daily-server --status --json` remains read-only. Machine startup uses `daily-server --dry-run --json` followed by `daily-server --yes --json`; the confirmed process is detached and remains bound to loopback. The legacy `fav-server` compatibility entry enforces the same preview and confirmation rules.
|
|
131
160
|
|
|
132
161
|
Documentation was synchronized with the current source baseline on 2026-09-19. Command behavior is defined by `bin/weflow-cli.ts`; detached reader startup waits for service readiness, and release packages can lag behind the GitHub source until published.
|
|
@@ -142,6 +171,7 @@ Documentation was synchronized with the current source baseline on 2026-09-19. C
|
|
|
142
171
|
- The article body is **not** available locally, so fetching cannot be skipped: a 公众号 message in the local database carries title, digest, URL and metadata, and stripping its XML tags leaves 0 Chinese characters (the ~5-12 KB that remains is URLs, base64 and ids).
|
|
143
172
|
- The OC Bot channel is separate from a personal WeChat message stream. `send` can only reply through an already established Bot-channel conversation with a valid context token; it cannot initiate messages to existing personal contacts, post as the user's personal account, or send into ordinary personal WeChat groups.
|
|
144
173
|
- The current official OC/iLink payload model has not been verified to support group events or group invitations. The project does not implement client automation or protocol bypasses to add a bot to groups.
|
|
174
|
+
- Inbound media on the OC Bot channel (image / voice / file / video) is downloaded **only when `wechatMediaDownload` is `true`** (`false` by default, so a default install makes no such request). When it is on, each item is decrypted locally (AES-128-ECB, the same scheme as the local image files) and written under `output/wechat-media/` (directory overridable with `WEFLOW_WECHAT_MEDIA_DIR`). Names are decided locally - a server-supplied name is reduced to a sanitised basename - and each item is capped at 25 MB; a download that fails, comes back empty or exceeds the cap leaves `filePath` empty and writes one log line naming the reason, never a partial file. The field and key order follow the vendor implementation (`third-party/WeKnora/internal/im/wechat/longpoll.go`): images use `image_item.aeskey` (hex) before `media.aes_key` (base64). **Verified on a real inbound message** (2026-10-10, after a fresh `login-wechat`): a text message arrived and was answered, then a real image arrived - 27,637 bytes, PNG magic, and **PIL opened it at 798x513 RGB**, which is what separates "decryption is right" from "some bytes landed". The log showed `忽略非文本消息 (image)` on the same message, i.e. the assistant keeps ignoring non-text for its reply. **And no consumer reads the saved path yet**: `components[].filePath` is populated and the file really is on disk, but the only reader of an inbound message (`assistantService`) takes `messageStr` + `messageKind` and carries the full object along unread - so the assistant still knows only "an image arrived", not where it landed. Letting a model look at that file would be a new class of egress and is deliberately left as its own decision (D-042 set the precedent for local images). **And it downloads from any sender, not just the allowlist**: the download happens while parsing the polled message, one layer earlier than "should the assistant answer this", so the allowlist governs replies and not downloads - anyone who can message the bot gets their media written to disk here.
|
|
145
175
|
- Cloud AI workflows may transmit user-selected, privacy-filtered input to the configured provider. Local inference avoids that network transfer.
|
|
146
176
|
- `vault promote ideas` and `vault promote all` run without AI by default. AI generation requires explicit `--with-ai` and an API key. CLI execution also requires preview and confirmation. `vault init` creates both the existing article-sync folders and the structured directories required by promotion workflows.
|
|
147
177
|
- `vault init`, semantic-index construction, the knowledge pipeline, and report generators require preview and confirmation for machine execution. Preview JSON contains counts and behavior flags but omits local paths, conversation names, and generated content.
|
|
@@ -304,7 +334,33 @@ not a gap. `Sources/` holds **raw material** (`Sources/WeChat/<date>/` from the
|
|
|
304
334
|
the reading notes, `Wiki/` holds the **article line's** generated concept pages plus `00-Overview.md`, and `Chat/` holds the
|
|
305
335
|
**chat line's** (`Chat/Concepts/`, `Chat/00-Overview.md`) - one Vault, two knowledge bases, split on
|
|
306
336
|
2026-09-27 at the user's request (D-051 records why every reader had to be told about both: reading one of
|
|
307
|
-
two directories is silently wrong, not broken).
|
|
337
|
+
two directories is silently wrong, not broken). Since 2026-10-10 the two lines also have separate **concept
|
|
338
|
+
namespaces** (D-104): the same name may hold a page on both lines, graph node ids are line-qualified
|
|
339
|
+
(`wiki:DeepSeek`), and `wiki lint` reports the overlap in its `crossLineSameName` section instead of counting
|
|
340
|
+
those pages as duplicates. Measured on 2026-10-10: **36** names have a page on both lines and **539** article-line
|
|
341
|
+
links point at one of them, so the rule this replaced ("one name, one page, library-wide") was never true of the
|
|
342
|
+
data on disk. What that rule protected is real and unchanged - a bare `[[DeepSeek]]` in Obsidian resolves
|
|
343
|
+
deterministically but not necessarily to the page you meant - and it is now **reported rather than prevented**;
|
|
344
|
+
the two ways to actually fix it (a root per line, or path-qualified links) are left to the step that splits the
|
|
345
|
+
roots, so until then clicking such a link is genuinely ambiguous.
|
|
346
|
+
|
|
347
|
+
**Resolved on 2026-10-10 the second way (D-106): the vault stays one library, and links that name a
|
|
348
|
+
both-lines concept now carry the line's path** (`[[Chat/Concepts/DeepSeek|DeepSeek]]`). Measured before the
|
|
349
|
+
rewrite: 36 shared names, 477 pages, 539 links (462 in concept pages, 36 in the index pages, 41 in the chat
|
|
350
|
+
cards; none anywhere else in the vault). The rewrite was checked by reverse audit rather than by sampling -
|
|
351
|
+
`ambiguousLinks` went 462 → 0 while every other lint count stayed identical (49,956 pages, 9 broken, 0 orphans,
|
|
352
|
+
1 empty, 0 within-line duplicates, 36 cross-line names, 10,324 aspirational) and the graph's three scopes are
|
|
353
|
+
byte-identical to before (48,429/144,628, 1,527/878, 49,956/145,795). **What is not solved**: a *new* collision
|
|
354
|
+
will be written bare again until `wiki compile --qualify-links` is re-run, which is why `wiki lint` grew an
|
|
355
|
+
`ambiguousLinks` section that names the fix. Since 2026-10-10 the **readers** carry the same
|
|
356
|
+
line too (D-105): `search`, `chat`, `vault search`, `vault rag` and the assistant's `search_semantic` /
|
|
357
|
+
`search_knowledge` all take `line: wiki|chat|all`, default to the article line where the user sees it (the
|
|
358
|
+
*functions* default to `all` so a caller that forgets cannot silently halve the search), and every output says which
|
|
359
|
+
line it searched. The semantic side carries this as a mask applied **before** the top-k sort over one shared index.
|
|
360
|
+
**Limitation to keep in view**: the vector path of semantic search has never run against a real index on this
|
|
361
|
+
machine - the index does not exist (building it needs `dashscopeApiKey` and spends the user's embedding credit), so
|
|
362
|
+
the mask is pinned against a hand-made fixture index and the real-data check was done on the keyword fallback (which
|
|
363
|
+
is the path this machine actually takes). Two things about this were
|
|
308
364
|
wrong on 2026-09-27 and are now fixed: the concept directory was **declared in two places** (`VAULT_DIRS` said
|
|
309
365
|
`007_Wiki/Concepts`, every writer and reader used the top-level `Wiki/Concepts`), so `007_Wiki/` was created and
|
|
310
366
|
never filled; and the historical import skipped `Sources/` entirely (it would have carried ~33 GB of images), which
|
|
@@ -400,6 +456,7 @@ step (the CLI's interactive-menu entry is the notable one).
|
|
|
400
456
|
## Related Documents
|
|
401
457
|
|
|
402
458
|
- Architecture and data flow: [ARCHITECTURE.md](../ARCHITECTURE.md)
|
|
459
|
+
- WeChat `contact.db` schema notes (the `ext_buffer` blobs): [CONTACT_DB_SCHEMA.md](CONTACT_DB_SCHEMA.md)
|
|
403
460
|
- Setup and troubleshooting: [OPERATIONS.md](../OPERATIONS.md)
|
|
404
461
|
- Security and reporting: [SECURITY.md](../SECURITY.md)
|
|
405
462
|
- MCP surface: [MCP.md](MCP.md)
|