weflow-cli 1.9.0 → 1.11.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.
Files changed (134) hide show
  1. package/ARCHITECTURE.md +62 -26
  2. package/CHANGELOG.md +1051 -0
  3. package/OPERATIONS.md +99 -13
  4. package/README.en.md +14 -5
  5. package/README.md +23 -8
  6. package/bin/weflow-cli.ts +364 -40
  7. package/dist/bin/weflow-cli.js +381 -69
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.d.ts.map +1 -1
  10. package/dist/mcp-server/index.js +19 -1
  11. package/dist/mcp-server/index.js.map +1 -1
  12. package/dist/src/core/dbPathService.d.ts +12 -0
  13. package/dist/src/core/dbPathService.d.ts.map +1 -1
  14. package/dist/src/core/dbPathService.js +20 -3
  15. package/dist/src/core/dbPathService.js.map +1 -1
  16. package/dist/src/panel/server.d.ts.map +1 -1
  17. package/dist/src/panel/server.js +35 -0
  18. package/dist/src/panel/server.js.map +1 -1
  19. package/dist/src/services/assistantService.d.ts +19 -0
  20. package/dist/src/services/assistantService.d.ts.map +1 -1
  21. package/dist/src/services/assistantService.js +20 -0
  22. package/dist/src/services/assistantService.js.map +1 -1
  23. package/dist/src/services/assistantTools.d.ts +27 -1
  24. package/dist/src/services/assistantTools.d.ts.map +1 -1
  25. package/dist/src/services/assistantTools.js +169 -35
  26. package/dist/src/services/assistantTools.js.map +1 -1
  27. package/dist/src/services/configService.d.ts +2 -0
  28. package/dist/src/services/configService.d.ts.map +1 -1
  29. package/dist/src/services/configService.js +3 -2
  30. package/dist/src/services/configService.js.map +1 -1
  31. package/dist/src/services/exportService.d.ts +43 -0
  32. package/dist/src/services/exportService.d.ts.map +1 -1
  33. package/dist/src/services/exportService.js +116 -11
  34. package/dist/src/services/exportService.js.map +1 -1
  35. package/dist/src/services/messageQuery.d.ts +18 -0
  36. package/dist/src/services/messageQuery.d.ts.map +1 -1
  37. package/dist/src/services/messageQuery.js +29 -0
  38. package/dist/src/services/messageQuery.js.map +1 -1
  39. package/dist/src/services/wechatClient.d.ts +125 -0
  40. package/dist/src/services/wechatClient.d.ts.map +1 -0
  41. package/dist/src/services/wechatClient.js +344 -0
  42. package/dist/src/services/wechatClient.js.map +1 -0
  43. package/dist/src/services/wechatMedia.d.ts +44 -0
  44. package/dist/src/services/wechatMedia.d.ts.map +1 -0
  45. package/dist/src/services/wechatMedia.js +155 -0
  46. package/dist/src/services/wechatMedia.js.map +1 -0
  47. package/dist/src/services/wechatMessageService.d.ts +11 -0
  48. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  49. package/dist/src/services/wechatMessageService.js +55 -11
  50. package/dist/src/services/wechatMessageService.js.map +1 -1
  51. package/dist/src/types.d.ts +4 -0
  52. package/dist/src/types.d.ts.map +1 -1
  53. package/docs/CONTACT_DB_SCHEMA.md +1085 -0
  54. package/docs/DECISIONS.md +1274 -0
  55. package/docs/EXTENDING.md +69 -7
  56. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +27 -0
  57. package/docs/HEALTH-CHECK.md +12 -7
  58. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
  59. package/docs/MCP.md +2 -2
  60. package/docs/PROJECT_STATE.md +64 -5
  61. package/docs/RELEASING.md +128 -1
  62. package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
  63. package/logo.png +0 -0
  64. package/mcp-server/index.ts +18 -3
  65. package/package.json +5 -3
  66. package/resources/js/graph3d/NOTICE.txt +35 -0
  67. package/resources/js/graph3d/d3-binarytree.min.js +2 -0
  68. package/resources/js/graph3d/d3-dispatch.min.js +2 -0
  69. package/resources/js/graph3d/d3-force-3d.min.js +2 -0
  70. package/resources/js/graph3d/d3-octree.min.js +2 -0
  71. package/resources/js/graph3d/d3-quadtree.min.js +2 -0
  72. package/resources/js/graph3d/d3-timer.min.js +2 -0
  73. package/resources/js/graph3d/layout.mjs +63 -0
  74. package/resources/js/graph3d/three.min.js +6 -0
  75. package/resources/js/graph3d/viewer2d.js +448 -0
  76. package/resources/panel/index.html +12 -0
  77. package/resources/panel/mascot-blink-1.png +0 -0
  78. package/resources/panel/mascot-blink-2.png +0 -0
  79. package/resources/panel/mascot-lift-down-1.png +0 -0
  80. package/resources/panel/mascot-lift-down-2.png +0 -0
  81. package/resources/panel/mascot-lift-down-3.png +0 -0
  82. package/resources/panel/mascot-lift-down-4.png +0 -0
  83. package/resources/panel/mascot-lift-down-5.png +0 -0
  84. package/resources/panel/mascot-lift-down-6.png +0 -0
  85. package/resources/panel/mascot-lift-up-1.png +0 -0
  86. package/resources/panel/mascot-lift-up-2.png +0 -0
  87. package/resources/panel/mascot-lift-up-3.png +0 -0
  88. package/resources/panel/mascot-lift-up-4.png +0 -0
  89. package/resources/panel/mascot-lift-up-5.png +0 -0
  90. package/resources/panel/mascot-lift-up-6.png +0 -0
  91. package/resources/panel/mascot-tickle-1.png +0 -0
  92. package/resources/panel/mascot-tickle-2.png +0 -0
  93. package/resources/panel/mascot-tickle-3.png +0 -0
  94. package/resources/panel/mascot-tickle-4.png +0 -0
  95. package/resources/panel/panel.css +111 -0
  96. package/resources/panel/renderer.js +480 -8
  97. package/scripts/_utils.py +1203 -977
  98. package/scripts/biz_daily.py +25 -10
  99. package/scripts/chat_notes.py +15 -5
  100. package/scripts/check_readme_commands.py +426 -0
  101. package/scripts/compile_wiki.py +163 -57
  102. package/scripts/contact_schema.py +339 -0
  103. package/scripts/data/wechat_emoji.json +225 -0
  104. package/scripts/export_chat_html.py +346 -6
  105. package/scripts/fav_notes.py +4 -1
  106. package/scripts/graph_2d.py +151 -0
  107. package/scripts/graph_3d.py +646 -0
  108. package/scripts/health_check.py +163 -26
  109. package/scripts/nt_decrypt.py +3 -3
  110. package/scripts/panel_frames.py +683 -0
  111. package/scripts/pipeline.py +21 -10
  112. package/scripts/quality_eval.py +5 -0
  113. package/scripts/rag_chat.py +33 -16
  114. package/scripts/reply_debt.py +26 -6
  115. package/scripts/semantic_search.py +212 -58
  116. package/scripts/vault_rag.py +42 -16
  117. package/scripts/vault_search.py +44 -8
  118. package/scripts/wechat_emoticon.py +6 -0
  119. package/scripts/wiki_lint.py +94 -17
  120. package/src/core/dbPathService.ts +22 -4
  121. package/src/panel/server.ts +33 -0
  122. package/src/services/assistantService.ts +26 -0
  123. package/src/services/assistantTools.ts +2452 -2319
  124. package/src/services/configService.ts +5 -2
  125. package/src/services/exportService.ts +129 -12
  126. package/src/services/messageQuery.ts +29 -0
  127. package/src/services/wechatClient.ts +380 -0
  128. package/src/services/wechatMedia.ts +166 -0
  129. package/src/services/wechatMessageService.ts +62 -11
  130. package/src/types.ts +4 -0
  131. package/dist/src/utils/wechatEmoji.d.ts +0 -5
  132. package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
  133. package/dist/src/utils/wechatEmoji.js +0 -35
  134. package/dist/src/utils/wechatEmoji.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -17,6 +17,1057 @@ purpose** - a hand-written "latest is X" is exactly the claim that goes stale he
17
17
 
18
18
  All notable user-facing changes are recorded here. This project follows [Semantic Versioning](https://semver.org/).
19
19
 
20
+ ## 1.11.0
21
+
22
+ - **Documents now travel with the export — all five formats, bodies included.** The same user report that asked
23
+ about images asked about *documents*; images got fixed, documents had not, and no format exported them (the page
24
+ showed `[文件] <name>` and stopped there). A file message carries only a name and an md5 — the body lives under
25
+ the account directory's `msg/file/` — so the export gained a **stat-only index** of that tree and matches on
26
+ `(normalized name, byte size)`, falling back to md5 only when several candidates share a name. Measured here:
27
+ 1,855 file messages, 1,489 unique matches, 154 resolved by md5, **212 with no local copy** (never downloaded or
28
+ already cleaned) — **88.6%**, and the remaining 11.4% is *counted*, not guessed. Bodies are copied into
29
+ `<output>/<talker>_assets/files/` under their **original name and extension**, and markdown links to them by the
30
+ original file name (an image embed would render as a broken picture); `json`/`txt`/`excel` carry the same through
31
+ the manifest (`media` + `mediaKind`/`mediaName`, `[文件: name]`, the 「媒体」 column). HTML deliberately does not
32
+ embed them — base64-inlining a 3 MB PDF into a page nobody can open is not "supporting documents". Verified end
33
+ to end on a 600-message slice: 61 documents / 27 MB on disk, every extension matching its magic bytes, 63 links
34
+ in the markdown with no broken target, manifest and JSON agreeing on the same 58 entries (and 0 without
35
+ `--media`). The manifest is now `weflow-media-manifest/v2` (values are objects carrying `kind`); v1 files still
36
+ read, because nobody should have to re-export after an upgrade.
37
+
38
+ Two traps, both found by measuring instead of reasoning. First, a quoted reply keeps the quoted text in
39
+ `<refermsg>` — which sits **inside** `<appmsg>` — so it carries a nested `type=6` block with its own
40
+ `<title>`/`<appattach>`. The first pass searched the XML for the first matching tag and reported 7,083 "files",
41
+ nearly all of them quoted text; the parser now reads `<appmsg>`'s **direct children** only. Second, because a
42
+ re-run copies nothing new, a count of "files we wrote" read `0` on the second export into the same directory
43
+ while 67 documents sat on disk — the CLI said "0 document(s)". `exported` (is it there) and `written` (did we
44
+ copy it just now) are separate counts now. `--media` also stopped extracting the **whole** conversation while
45
+ the format it feeds was sliced by `--limit` (67 documents extracted, 33 of them used); it takes the same window.
46
+
47
+ - **`export … json|txt|excel --media` puts the images in one command.** The markdown export is what extracts
48
+ media, so the other formats needed it run first - two steps, and a user who did not know that would see
49
+ `[图片]` and conclude the feature was missing. `--media` runs the same extraction (same Python path, same
50
+ `<output>/<talker>_assets/`, same manifest) and then writes the format it was asked for. Verified from a clean
51
+ state: one `export "…" json --media` produced the assets directory and a JSON in which 556 of 1,914 messages
52
+ carry their image's relative path.
53
+
54
+ - **Animated GIFs survive every export path - and now the files say so.** Asked whether GIFs come through, the
55
+ answer was measured rather than assumed: of the 556 images in one real conversation, **87 are GIFs** (animated:
56
+ `NETSCAPE2.0` plus frame blocks) and 27 are PNGs, and both the markdown assets and the HTML embeds carried them
57
+ intact (`image/gif` ×87 in the page, 84 of them animated) - `wechat_image.shrink` has always passed *animated*
58
+ GIFs through untouched, precisely because a JPEG re-encode would show a still of what the sender saw moving.
59
+ What was wrong was the **file names**: the new markdown path hard-coded `.jpg`, so 87 GIFs and 27 PNGs landed as
60
+ `img_….jpg` - content right, extension lying, which is what an editor decodes by. The extension is now sniffed
61
+ from the bytes (`detect_mime_from_bytes`, which the script already had), so it is
62
+ `.gif`/`.png`/`.jpg`/`.webp` as appropriate: measured after the fix, the four formats agree exactly - 87 `.gif`,
63
+ 27 `.png`, 442 `.jpg` references each (433 distinct jpg files, nine of them shared by two messages).
64
+
65
+ - **`export … md` exists, and the other formats now carry the same images.** A user reported that only HTML
66
+ exported images; that was true, and the root cause was not a missing switch. Images are matched through the
67
+ cache/account indexes, and only the Python exporter did that - so the fix routes markdown through the same
68
+ Python path (`--format md`) instead of reimplementing the matching in TypeScript. Markdown **writes the images
69
+ out** to `<output>/<talker>_assets/` and references them relatively, because markdown's job is to be fed to
70
+ editors and other tools, where `![](data:image/…)` is not understood. That pass also writes
71
+ `manifest.json`, and `json` / `txt` / `excel` exports in the same directory now pick it up automatically
72
+ (a `media` field, `[图片: <path>]`, and a 「媒体」 column respectively) - **the matching runs once**, which is
73
+ the only way two implementations would not drift.
74
+
75
+ Two things surfaced while building it, both worth recording. First, the manifest was keyed by `local_id`, and
76
+ **`local_id` repeats across shards** (a conversation's table can exist in more than one shard, each numbering
77
+ its own rows): measured on a 1,914-message chat, 763 messages matched 522 entries, so the non-markdown formats
78
+ would have pointed at the wrong picture. Keyed by `local_id:create_time` now, and all four formats land on 556
79
+ (= the number of messages with an image). Second, when the Python export failed, the TypeScript side fell back
80
+ to the basic HTML renderer and the CLI still printed `✓ 导出成功` - a failure dressed as success (it was found
81
+ by asking for markdown and getting an `.html` file back). Markdown no longer has a silent fallback at all (there
82
+ is no "basic markdown"), and the HTML fallback now says on stderr that it dropped the images.
83
+
84
+ - **The default semantic search path warned on stdout — and the warning was the important part.** `semantic_search.py`
85
+ has four `[WARN]` prints; three (the rerank ones) went to **stdout** while the rest already used
86
+ `file=sys.stderr`. Since `search` reranks by default, `search --json` answered with a line of prose *before* the
87
+ JSON and a machine caller got a parse error instead of results. Fixed, and the three paths now assert
88
+ `stdout == ''` in `test/semantic_rerank_test.py` — the two tests that already existed there asserted the warning
89
+ on **stdout**, i.e. they pinned the bug, so they were updated rather than duplicated beside them.
90
+
91
+ What the warning was saying, and why it stayed hidden for so long: on this machine the rerank layer **cannot run
92
+ at all** — `JevError … HTTP 451: Typesafe is not available in your region`. Rerank calls Typesafe
93
+ (`api.typesafe.ai`), a cloud decision service, not a local model, so on a geo-blocked machine the "default" path
94
+ is really the un-reranked one and the only signal was that stdout line. Two consequences are deliberately **not**
95
+ fixed here: the machine-readable result does not say the rerank failed (a degraded search is byte-identical to a
96
+ successful un-reranked one, because `rerank` returns just the list), and whether the index/rerank split is what
97
+ users should get on such a machine is a decision, not a patch. Both are written down in `docs/PROJECT_STATE.md`.
98
+
99
+ - **The semantic index was built and used on a real machine for the first time** (2026-10-13, 7-day chat /
100
+ 3-article window, 749 records, inside DashScope's free quota). The scoped search returns
101
+ `indexCounts: {all: 749, wiki: 383, chat: 366}` — the two lines add up to the whole index — and a query for
102
+ `开会` returns a chat message that says `…记得参加会议,8:30`: a semantic hit, not a keyword one. The default
103
+ 90/30 window is still unbuilt (it would exceed the free quota; the measured cost is roughly ¥1.3–2.2).
104
+
105
+ - **The semantic index would have embedded only the *older* part of its window, silently.** `semantic_search.py`
106
+ opened exactly one file — `config.ntDbPath` — with the account's global key. WeChat 4.x spreads messages across
107
+ shards and **each shard needs its own derived key**; the repo already had that path
108
+ (`nt_decrypt.connect_message_shards_detailed`, which also reports *which* shards failed). Measured here on
109
+ 2026-10-13 before the fix: the configured `message_0.db` holds 67,292 messages whose newest is **2026-08-30**,
110
+ while `message_3.db` holds 16,081 whose newest is **that same day's date** — so a build would have indexed 1,396
111
+ chat messages and **nothing newer than six weeks ago**, missing 5,217 that a correct read finds in the same
112
+ 90-day window (7-day window: 0 → 376). Three quieter losses went with it: the session list was `[:100]` —
113
+ unsorted, so effectively arbitrary, on a machine with 122 sessions; the id was `chat:<talker>:<ts>`, which merges
114
+ messages inside one second (measured: 12,106 rows on 12,058 distinct seconds); and one conversation's table can
115
+ exist in several shards, so a shared id would double-count. The build result now reports `shardsFailed`, because
116
+ "read everything" and "read what happened to be reachable" must not look alike.
117
+
118
+ `test/semantic_shards_test.py` pins each of the three with fake shard connections (no sqlcipher, no real vault),
119
+ and all three mutations — single shard, no-rowid id, `[:100]` — turn their own test red. **This is also why the
120
+ index is still not built**: building it first would have spent the embedding budget on a month the user is not
121
+ asking about. Closing that now costs one `search-index --yes` once `dashscopeApiKey` is set.
122
+
123
+ ## 1.10.0
124
+
125
+ - **`mcp-config --json-result` printed a tool list *after* the JSON.** Its read path printed the config JSON and
126
+ then, unconditionally, the `可用的 MCP 工具` listing and a `复制以上 JSON…` line on **stdout** - so
127
+ `mcp-config --json-result | jq` died at line 14, even though `capabilities --json` names `--json-result` as this
128
+ command's machine form. (The write path already returned early with its own JSON; only the read path leaked.)
129
+ It was found by driving the **read-only** commands on a *configured* machine - the other half of the sweep
130
+ described below, and the half that an unconfigured HOME cannot see, because a successful path is the only place
131
+ this appears. Both halves are now a local tool, `scripts/json_surface_probe.mjs` (`--unconfigured` /
132
+ `--read-only`): it reports what it drove and which commands it skipped, exits non-zero on findings, and prints
133
+ the one class it deliberately does not count (commander's own missing-required-option errors, which is a design
134
+ item, not a missing JSON arm). It is **not** a CI check - it needs a configured machine and one read-only
135
+ command legitimately takes minutes - so the fix is guarded by a test that does run in CI
136
+ (`test/json-error-surface.test.ts`: one JSON, no tool list).
137
+
138
+ - **Driving every `--json` command in an *unconfigured* state found two more prose-on-stdout failures, and one
139
+ guard hole.** Method: point `HOME`/`USERPROFILE` at an empty directory and run all 83 driveable `--json` leaves
140
+ (paths and required positionals asked of the CLI's own `--help`); an unconfigured machine makes each one fail
141
+ before it touches anything real, so the whole sweep is hermetic and needs no credentials. Seven outputs were not
142
+ a single JSON: **two were real** - `whitelist add` and `blacklist add` called `resolveTalker(target)` without
143
+ its machine-mode flag, so in `--json` mode a failed lookup printed `❌ 找不到 "x" 对应的会话…` and a
144
+ *successful* one printed the `→ 昵称 (wxid)` hint line **before** the JSON that the caller is parsing. Both now
145
+ pass `!!opts.json`; the human path is unchanged. The other five were the sweep's own noise: two false targets
146
+ (`mcp-config`, `fav export` use `--json-result`, which `capabilities --json` documents), one 60 s timeout
147
+ (`wiki graph`, genuinely slow), and two commands that need a *required option* the sweep did not supply
148
+ (commander's own error, on stderr - a machine caller sees empty stdout, but that is commander's parse error, not
149
+ this repo's code).
150
+
151
+ The `--json-result` false targets were worth chasing: they revealed that `test/json-surface.test.ts` matched
152
+ `.option('--json` as a **prefix**, so a leaf declaring only `--json-result` passed the "every leaf has a
153
+ machine-readable flag" check by accident. It now compares the flag name exactly and accepts the two documented
154
+ spellings (`--json`, and `--json-result` where a file-format argument is also called `json`); a third spelling
155
+ fails the guard, mutation-checked. The talker family gets a static rule too - every `resolveTalker` call site
156
+ must pass the machine-mode flag - because the *success*-path half (that hint line) cannot be driven here without
157
+ editing the user's actual whitelist.
158
+
159
+ - **The `weread` commands answered their failures in prose, even under `--json`.** Nine call sites (`shelf` /
160
+ `stats` / `notes` ×2 / `search` / `book` / `review` / `discover` / `profile`) were the same copy-pasted
161
+ one-liner: print `✗ <error>` on **stdout** and exit - with the command's own `if (opts.json)` branch sitting
162
+ *after* it, so `weread shelf --json` could only answer a failure with a line of prose. They now go through one
163
+ `failWeread` helper whose machine arm is `{success:false, code:'WEREAD_FAILED', error}`. The human arm is
164
+ untouched (still `console.log`, not `stderr`): moving CLI errors to stderr is a separate consistency job, the
165
+ repo currently does both, and it is not smuggled into this fix.
166
+
167
+ Finding them took three passes over a filter that kept being wrong. "Prose log with no `opts.json` nearby" left
168
+ 305 candidates, nearly all progress lines inside commands that exit early with `INTERACTIVE_REQUIRED`; also
169
+ skipping commands that have any json gate above left 12, of which 5 were the weread family - but the family is
170
+ **nine**, because four more sat three lines below an *unrelated* `opts.json` (a `parseCliInteger` call) and so
171
+ looked handled. Grepping the actual idiom (`!res.ok`) and reading all nine is what found them; the window
172
+ around a line cannot tell "mentions `opts.json`" from "is governed by `opts.json`", which is the same
173
+ distinction that fooled one of the two guards earlier the same night. What is *not* claimed: the other ~100
174
+ runtime error sites are still unaudited, which is now stated where the family is described rather than left
175
+ implied. One tripwire guards the shape that got copied nine times - `test/json-error-surface.test.ts` fails on
176
+ any one-line branch that logs prose and exits with no json arm - and it carries a self-control so "the scan sees
177
+ nothing" cannot be confused with "the file has nothing".
178
+
179
+ Three states were checked on the real machine, not just reasoned about: with credentials, `weread shelf --json`
180
+ returns the shelf and `weread stats --mode bogus --json` returns `{success:false, code:'WEREAD_FAILED', error:
181
+ 'API 错误: 参数格式错误'}` (before this change: one line of prose); and with `HOME` pointed at an empty
182
+ directory, `weread shelf --json` answers the single object `{success:false, code:'WEREAD_FAILED', error:
183
+ '未设置 WEREAD_API_KEY…'}` - no traceback, no prose. That last state is also CI's, so the behavioural test
184
+ drives exactly it (empty `HOME`/`USERPROFILE`, `WEREAD_API_KEY` deleted) instead of depending on this machine's
185
+ credentials or on the network.
186
+
187
+ - **The argument-validation family was audited end to end: 80 sites, zero defects left.** Breakdown: 60
188
+ `parseCliInteger` calls, 5 `requireCliDate`, 5 `requireCliChoice`, and 10 hand-written value checks in three
189
+ spellings - `!['a','b'].includes(opts.x)` (4), `new Set([…]).has(opts.x)` (1), and a regex `.test(opts.x)` (5).
190
+ Exactly one call site was missing its `json` argument - `dbkey --timeout` - and it is **unreachable** under
191
+ `--json` (that command answers `INTERACTIVE_REQUIRED` further up and exits), so it was harmless; it passes
192
+ `opts.json` now anyway, which lets the rule have *no* exceptions. `test/json-error-surface.test.ts` asserts it
193
+ statically (every validation-helper call in a `--json` command carries `opts.json`, ~70 sites seen, anti-empty-scan),
194
+ because the helper's default is `json = false` and a missing argument is silent - the same silence that let three of
195
+ the four `--line` copies above lose their JSON half.
196
+
197
+ **The first count was 74, and the missing six are the interesting part**: that pass defined the family as
198
+ "`parseCliInteger` + the two helpers + `![…].includes(opts.x)`" and so could not see a `Set.has` check in
199
+ `pipeline run` or five regex-validated fields - a family defined by *one spelling* of the thing it is looking for.
200
+ Re-measuring with the wider definition found all six, and all six already answered JSON correctly, so the
201
+ conclusion survived; what did not survive was the number, which is why the correction is written down rather than
202
+ quietly edited. Behaviour now covers the hand-written branch too: four more cases in `test/json-error-surface.test.ts`
203
+ drive one command per spelling (`vault sync-weread`, `chat-stats`, `review`, `pipeline run`) with an invalid value
204
+ under `--json`. Two gaps stay open and are named in that file: `export --contract`'s check runs after
205
+ `resolveTalker()` and so needs a configured machine (**verified here, not in CI**), and the hand-written branches
206
+ emit `field` but not the human-readable `error` string the helper includes.
207
+
208
+ What is *not* covered, and should not be read as covered: the other ~100 `console.log(chalk.red(...))` sites are
209
+ **runtime** errors (not-configured, no WeChat process, permission denied). Whether they are reachable when `--json`
210
+ was passed depends on each command's interactive gate, so it takes reading each one, not a scan - a first pass that
211
+ looked for "no `opts.json` nearby" flagged 305 of them, nearly all progress lines inside commands that answer
212
+ `INTERACTIVE_REQUIRED` before printing anything.
213
+
214
+ Finding the one defect took two rounds against the check itself. First it reported `0 missing` while a planted
215
+ mutation sat in the file: the `declares --json` test called `block.lastIndexOf('\n', 0, at)` - three arguments to a
216
+ two-argument method - so it searched backwards for the literal `'0'`, put the line slice on the wrong line, and
217
+ skipped the entire `run` command block. A mutation that does not fire looks exactly like a hole in the guard; here
218
+ it was a bug in the check, and an independent Python re-implementation of the same logic is what showed the
219
+ difference (it reported both sites).
220
+
221
+ - **A `--json` command's *errors* are JSON too - and the four hand-written copies of that check are now one.**
222
+ `--line` was validated in four commands (`vault search`, `vault rag`, the semantic `search`, `chat`), each with
223
+ its own block, and only the first honoured `--json`: the other three answered an invalid `--line` with colored
224
+ prose on **stdout**, so `vault rag q --line zzz --json | jq` died on the first character while the exit code
225
+ said "1" and nothing else - not even which argument was wrong. Same defect as the entry above, found the same
226
+ afternoon by asking the siblings the same question ("what does this one print when `--json` is on and an argument
227
+ is wrong?"). The four blocks are replaced by `requireCliChoice(value, choices, field, json)`, which generates the
228
+ message and sits next to `parseCliInteger` / `requireCliDate` - both of which have always honoured `--json`, which
229
+ is exactly why the one-off copies didn't. `test/json-error-surface.test.ts` drives all four commands and pins both
230
+ halves (JSON under `--json`, the human sentence without it); all four return **before** touching local data, so CI
231
+ can run them. Reverting any one call site to the old prose form turns exactly that command's case red.
232
+
233
+ - **"Every command a script or an AI might drive has a `--json` mode" is now checked rather than asserted.**
234
+ `docs/EXTENDING.md` has stated that convention for a while and nothing enforced it - which is how `vault search`
235
+ ended up as the one command whose only output was JSON while the flag itself did not exist (the entry below).
236
+ `test/json-surface.test.ts` walks the CLI's own source, treats every command with an `.action` as a leaf, and
237
+ fails naming the command and its line if a leaf declares no `--json`. Measured on 2026-10-11: **113 leaves, 111
238
+ declaring `--json`, 2 exceptions** - `whitelist` and `blacklist`, whose bare form is a convenience table while
239
+ the machine path is their `list` subcommand - and those two live in an allowlist **with their reason**, checked
240
+ for staleness so a fixed exception cannot sit there unnoticed. The test reads names, not help text: parsing
241
+ `--help`'s `Commands:` section folds long descriptions onto continuation lines, and the first attempt counted 62
242
+ "commands", prose included. It also ignores matches inside `//` comments from the start, because a commented-out
243
+ `.option('--json', …)` is a flag nobody can type - a lesson the `vault search` guard taught an hour earlier, when
244
+ its own mutation was written as a comment and the check passed.
245
+
246
+ - **`awaiting --json` puts its progress on stderr, so stdout is exactly one JSON object.** The scan printed two
247
+ progress lines unconditionally - `扫描 N 个…` and `判断完成 n/m,耗时…` - so `awaiting --dry-run --json | jq` died on
248
+ the first character, and the early exit ("最近 N 天没有活跃会话") answered with a bare sentence instead of JSON,
249
+ which is the case an idle machine hits first. Both now follow the convention the two lines below them in the same
250
+ file already used (`file=sys.stderr`), and the empty scan answers in the same shape as the preview with the counts
251
+ at zero. `--html` writes the report path into the JSON (`report`) rather than only printing `已写出:…`: moving
252
+ that line to stderr would have traded one loss for another, since a machine caller would no longer learn where the
253
+ file went. `test/reply_debt_test.py` drives the real `main()` for this - boundaries stubbed, stdout captured,
254
+ parsed **in full**, so one stray character fails it - and both mutations (progress back on stdout, the early exit
255
+ ignoring `--json`) turn it red. It needs no WeChat database, which is what lets it run in CI. Found by a
256
+ stdout-purity probe over 12 `--json` commands: eleven were already clean, and the twelfth is the entry below.
257
+
258
+ - **`weflow-cli vault search` has a `--json` flag now - and it is no longer the only thing it can print.** The
259
+ action had `'--json'` hard-coded into the argument list, so the flag did not exist (asking for it answered
260
+ `error: unknown option '--json'`, exit 1, empty stdout) while the script's human mode - `vault_search.py` prints a
261
+ scored, readable list when it is *not* given the flag - was unreachable from the CLI. Even the script's own comment
262
+ says the JSON was a stand-in for a reader (it prints it because "CLI 直接把它打给人看"), so nobody was choosing
263
+ machine output on purpose. This is the `--no-rerank` entry above with the sign flipped: there the flag was
264
+ declared and never forwarded, here it was forwarded and never declared - and unlike that one, no doc mentioned it,
265
+ so the command-surface check could not have found it; only running the command does. The validation branches
266
+ (`--type`, `--line`) now answer in JSON when `--json` is on, the way `parseCliInteger` / `requireCliDate` already
267
+ do, instead of writing colored prose to stdout that a machine caller cannot parse.
268
+ `test/vault-search-cli.test.ts` pins it end to end - held the flag and stdout is one JSON object; the default is
269
+ not JSON and still says `找到 N 条`; an invalid `--type` under `--json` is still one JSON object and exits 1 -
270
+ because "what does it print by default" is the whole question, and a text assertion on the source could not
271
+ answer it. It passes whether or not a vault is on disk - both states were measured, an empty directory and one
272
+ that does not exist - which matters because the second is the only state CI is ever in. The same file carries the
273
+ static half in the direction that was missing: **every flag forwarded to the script must be declared, and every
274
+ declaration must be forwarded and read**. That second direction is what the paragraph above describes - a
275
+ hard-coded `'--json'` with no `.option` - and a behaviour test cannot see it, because the command still runs fine;
276
+ only typing the flag by hand fails. Its own first mutation proved the point twice over: the mutation was a
277
+ commented-out `.option('--json', …)`, and the check passed because the *comment* still matched. Commented-out
278
+ declarations are a real way to disable a flag, so matches inside `//` are now ignored, and the re-run failed on
279
+ the intended assertion.
280
+
281
+ - **`vault search --days` exists too.** `vault_search.py` has taken a window all along - its own docstring
282
+ demonstrates `--days 30` - but the CLI never forwarded it, so the article corpus was pinned to the script's
283
+ 90-day default and "only look at this week" was unreachable from this path. Exposed with the shape
284
+ `search-index --days` already uses: the CLI's value is what runs, and the default matches the script. This was
285
+ the last non-path flag of that command not on the CLI; `--vault` / `--biz-daily` stay off it on purpose (paths
286
+ are not copied into command arguments), and `vault rag` was already complete against its script.
287
+
288
+ - **The five health-check cases for the scheduled-task row now run in CI.** They were `skipIf(os.name != 'nt')`,
289
+ and since CI runs on Linux that meant this row - including the "not run yet" state fixed the same day - had **no
290
+ coverage in CI at all**: a guard that only ever ran on the author's machine. The harness now pins the platform
291
+ (`patch.object(hc.os, 'name', ...)`) the way the TS side pins `process.platform`, so the cases exercise the code
292
+ rather than the host; a non-Windows case was added too, pinning the deliberate choice that the row reports OK and
293
+ says why there. Pinning `os.name` is safe here because `os.path` fixes its implementation at import and
294
+ `subprocess` is already stubbed on this path.
295
+
296
+ - **`weflow-cli search --no-rerank` now exists.** `docs/OPERATIONS.md` had been telling users to run it while the
297
+ CLI answered `unknown option` - the flag existed in `scripts/semantic_search.py` all along, the TS command simply
298
+ never forwarded it, and `docs/DECISIONS.md` (D-032) described it as if it worked. That is the `1.5.0` shape
299
+ again, caught this time by the command-surface check rather than by a user. The flag is forwarded to the script,
300
+ the `--dry-run` preview reports `noRerank`, and `test/search-cli.test.ts` pins the class: every declared option of
301
+ `search` must be classified (argv vs local-only), actually read, and forwarded when it is meant to be. (Its own
302
+ first version located the wrong command block - `search` also exists as a subcommand elsewhere - so the test now
303
+ proves it anchored on the right one before asserting anything.)
304
+
305
+ - **`dist/` no longer ships compiled files whose source is gone.** `tsc` never prunes, so a deleted or renamed
306
+ source leaves `.js` / `.d.ts` / `.map` behind - and `dist/` is on the packaging allowlist, so those went out
307
+ as-is. An audit on 2026-10-11 found four (`dist/src/utils/wechatEmoji.*`, while `src/` had lost that file a
308
+ month earlier): dead weight today, but the shape of "two implementations on one module path, neither reported".
309
+ They are gone (the tarball goes from 547 to 543 files), and `test/pack-contents.test.ts` now fails on any `dist`
310
+ file whose source does not exist - mutation-checked by planting one.
311
+
312
+ - **The 3D graph viewer was really rendered, not just syntax-checked.** `docs/PROJECT_STATE.md` carried it as
313
+ unverified (it needs WebGL, and jsdom can only run the 2D viewer). `scripts/graph3d_check.mjs` now opens the
314
+ generated page in a GPU-backed Edge over CDP and reads the page's own `window.__viz` hook - judging by the
315
+ `renderer.info.render` **counters** (how many points and lines the GPU was actually handed), not by pixels (a
316
+ WebGL canvas is black outside the frame, so a screenshot proves nothing) and not by frame rate (the page renders
317
+ on demand, so an idle `requestAnimationFrame` yields fake triple-digit numbers - the generator says so itself).
318
+ Measured on `knowledge-graph-3d-core.html` (3825 nodes / 12337 links, ANGLE + RTX 5060 over D3D11): **3 draw
319
+ calls, 3938 points, 12337 lines**, layout 68-172 ms. Note `triangles` stays 0 - balls and links are `Points`
320
+ and `LineSegments`, so that counter is the wrong oracle here. It is **not** in CI (it needs a GPU browser), so
321
+ it is a reproducible local check rather than a regression guard; the source-shape test stays as well.
322
+
323
+ - **The health check no longer calls a freshly registered task a failure.** A task that has not fired yet reports
324
+ `SCHED_S_TASK_HAS_NOT_RUN` (`267011` / `0x41303`), and that landed in the "last run failed" branch with the hint
325
+ "the script probably threw" - sending a new user to hunt an exception that does not exist (measured on this
326
+ machine when the two watcher tasks were registered on 2026-10-11). It now has its own state, "not run yet", which
327
+ still counts as **not passing**, since what the row wants is "ran once and succeeded" and a task that never runs
328
+ is exactly what it exists to catch. Registering those two tasks also made this health check green here for the
329
+ first time (**10/10**), and `docs/RELEASING.md` now records the registration commands - they had never been
330
+ written down, even though the check keys off the task names.
331
+
332
+ - **CI had been failing on every run - at least the last 60 - so nothing it guards was actually being enforced.**
333
+ Three causes, none of them in the feature under test. (1) `wechatClient` split Windows paths with the *platform*
334
+ `path`, so on a Linux runner `basename('D:\WeiXin\4.1.15.13\Weixin.exe')` was the whole string: the version row
335
+ said "unknown" and the install root came out as `.`. It now uses `win32` - identical on Windows, correct
336
+ everywhere. (2) The python job installed `zstandard pycryptodome`, plus a comment claiming the rest of
337
+ `requirements.txt` was not needed by those test files. That comment had gone stale: two tests import
338
+ `sqlcipher3` and one imports `numpy`, both declared dependencies, so the job failed on imports rather than
339
+ assertions. (3) Six tests read the **developer's machine** instead of a fixture: `search_knowledge` / `get_stats`
340
+ / `get_concepts` / `lint_wiki` take the knowledge-base root from `WEFLOW_ASSISTANT_VAULT_DIR`, which those files
341
+ never set (their siblings `biz-daily` / `reviews` / `skills` did), so on a clean checkout they took the "nothing
342
+ compiled yet" branch - one of them still described itself as "the part that does not depend on the environment".
343
+ The transcript contract test compared a local-timezone rendering against Python's fixed +8 (8 hours apart on a
344
+ UTC runner), and the MCP `draft_reply` test expected a preview where a clean checkout defaults to strict privacy.
345
+ All six now pin what they mean. Green as of 2026-10-11.
346
+ - **That work exposed two real bugs, both "green while doing nothing".** `lint_wiki` answered "两个知识库都没有
347
+ 断链/孤儿/空页/同名" even when it had skipped both lines because the directories did not exist - a clean bill of
348
+ health for something it never checked, which any user who has not compiled the wiki also meets. And the TS
349
+ transcript line used the machine's timezone where Python uses a fixed +8, so the two entry points disagreed by
350
+ hours outside China; both are +8 now (the Python constant also drives the reply-debt day counts, so moving
351
+ *that* side would have shifted an analysis number).
352
+ - **CI failures now name the failing tests in an annotation.** The step logs live in blob storage this machine
353
+ cannot reach - measured: `gh run view --log-failed` timed out 12 times in a row while `gh run view --json` kept
354
+ working - which made "CI is red" and "nothing is readable" the same sentence, for 60 runs. Annotations travel
355
+ over the API that does work.
356
+
357
+ - **README commands are now checked against the real CLI, so a drifted command fails CI instead of a user.** The
358
+ documented failure is from `1.5.0`: 128 commits and two months of drift left the README telling people to run
359
+ `init --path`, which answered `unknown option`. `scripts/check_readme_commands.py` walks the CLI's own `--help`
360
+ output token by token and reads the `Usage:` line to decide which command was actually resolved - the exit code
361
+ cannot answer that, because `--help` short-circuits (`wiki <bogus> --help` prints wiki's help and exits 0, so the
362
+ first version of this check reported "0 problems" from a branch that could never run). It scans fenced code blocks
363
+ only: in prose, "manage with `weflow-cli scene`, or bind..." reads as a subcommand named `or`. A finding is either
364
+ a command that does not exist (a bare word right after a command that has subcommands) or a flag absent from that
365
+ command's help. Its default scan set is the **user-facing docs** (the two READMEs, the root docs, and `docs/*.md`
366
+ except `CHANGELOG.md` / `DECISIONS.md`, which quote past commands and would false-positive) - scanning only the
367
+ READMEs would have missed the `--no-rerank` case above. On this repository it checks 272 commands across 24 docs
368
+ and finds 0 mismatches, which is the interesting part given the two knowledge bases, the panel and the rest of
369
+ 1.10.0 landed in between. `--self-test` carries the
370
+ positive controls this repository requires of a scanner (a check must be shown able to go red), and both run in the
371
+ CI `node` job after the build. **The same check now also covers `capabilities --json`**: its `cli:` fields are
372
+ hand-written (the MCP half of that output is derived from the real registry), the output is the documented entry
373
+ point for learning this project, and it currently checks out - 21 strings, 0 mismatches, which is exactly the
374
+ point: accurate today is not the same as watched. **A third surface is covered as well**: `python|py
375
+ scripts/x.py --flag` inside the same docs' code blocks. The judge there does **not** run the scripts - this
376
+ repo's standing discipline is "pure AST, never import" (`test/script_stdout_encoding_test.py` says why: CI's
377
+ python job installs two dependencies, so importing would fail spuriously), so the scripts' `add_argument` calls
378
+ are read as the declaration table. Measured 2026-10-11: **24 documented calls, every script present, every flag
379
+ declared**. The doc-command surface keeps its anti-empty-scan (the default corpus always has commands); an
380
+ explicit `--files <fixture>` no longer trips it, since a fixture may legitimately hold only one kind.
381
+ **`preview:` strings are covered too** - the two-phase convention's first half is a claim like any other, and it
382
+ was the half nothing checked (45 `cli:`/`preview:` strings, **46 invocations** once the output's own notation is
383
+ normalised: `<a|b>` is "or" and keeps its first alternative, a `|` *outside* brackets means two spellings, `…`
384
+ truncates). That normalisation cost one round: keeping the angle brackets read better but broke the path at
385
+ placeholders that **are** command names (`<whitelist|blacklist> <add|rm> <target> --dry-run --json`), so it
386
+ reported six flags as missing that were not - an independently written throwaway probe, using the same rule,
387
+ found 0, and that agreement is what settled the choice.
388
+ **CLI invocations written another way are covered too**: `node …/cli.cjs <cmd>` and
389
+ `npx tsx bin/weflow-cli.ts <cmd>` inside those docs were invisible to the scan (it looked for lines
390
+ *starting* with `weflow-cli`), yet they name the same commands and the same flags - five of them
391
+ (`docs/DEEPSEEK_V4_FLASH施工文档.md` x3, `docs/RELEASING.md` x2, and they all check out). They are judged by
392
+ the same `--help` rule, with one deliberately narrow condition: the file name must be `cli.cjs`/`weflow-cli.ts`,
393
+ so `node scripts/graph3d_check.mjs --flag` is not compared against the CLI's table. The scan now covers
394
+ **277** doc commands.
395
+
396
+ - **The npm package now contains two files it had been silently missing.** `package.json`'s `files` is an allowlist
397
+ of directories and globs, and both files sat just outside the patterns: `scripts/data/wechat_emoji.json` (read by
398
+ `scripts/wechat_emoji.py`, whose reader swallows a missing file into an empty map) and `logo.png` (the image both
399
+ READMEs display). Measured on the published artifact rather than inferred: the registry's `weflow-cli@1.9.0`
400
+ tarball holds 501 files and neither of these, while shipping all 118 emoji PNGs. The two install routes therefore
401
+ rendered chat faces differently - for npm users, 37 Chinese face codes (`[乒乓]`, `[冷汗]`, `[开红包]`, ...) and 67
402
+ English aliases came out as literal bracketed text instead of the nearest emoji, and the package page showed a
403
+ broken logo. `test/pack-contents.test.ts` covers the half nothing did: the CI `release-consistency` job only
404
+ checks what must *not* be in the package, never what must be in it. Note the cost - `logo.png` is a 1254x1254
405
+ unrecompressed 1.4 MB PNG displayed at 280px, so the tarball goes from 10.49 MB to 11.87 MB; shrinking it is a
406
+ separate decision (the panel mascot is already handled that way - a derived, right-sized file rather than the
407
+ original). **Verified on an installed copy** of the 1.10.0 tarball (packed, then `npm install` into a
408
+ temp prefix on 2026-10-11): `ALL_NAMES = 222` rather than the artwork-only 118, `[乒乓]` / `[冷汗]` /
409
+ `[开红包]` render as the nearest emoji, and `--version` answers `1.10.0`.
410
+ - **`health_check` no longer calls the two read paths inconsistent just because a message arrived during the
411
+ check.** That row compares the newest message each path reports, and `json` runs first while `html` runs a minute
412
+ or two later - so a message landing in between produced exactly the symptom the row exists to catch (observed on
413
+ 2026-10-11: `json 13:38 / html 13:40:14` failed, and the same conversation passed a minute later). Widening the
414
+ tolerance would hide the real thing, since shards rotate within minutes in a busy chat. Instead, when the two
415
+ disagree the check now **re-reads `json` and arbitrates**: if that read can see the newer message the difference
416
+ was liveness (passes, and says so), and if it still cannot, the row still fails - with the re-read result quoted.
417
+ The extra export only happens in the disagreeing case, so the common path costs nothing more.
418
+ - **The "session freshness" row stops blaming the read path on its own.** It goes red when the newest message in
419
+ the session list is older than a threshold, and it used to explain that as "usually one shard is being read" -
420
+ but a week without chatting produces exactly the same reading. The hint now gives both explanations, names the
421
+ one that is more likely, and points at the row that discriminates between them (if the two read paths agree,
422
+ nothing is broken).
423
+ - **A health check that cannot pick its sample conversation now says so instead of quietly skipping two rows.**
424
+ `读取路径一致` and the export-format checks run on a sample conversation; when the session list was non-empty but
425
+ none of its entries carried a `username` (a changed field, a truncated list), that selection returned nothing and
426
+ the two checks were skipped - while the report still said "all passed", just with a smaller count. It now fails
427
+ with a line explaining that those two checks did not run, which is not the same as passing.
428
+ - **`health_check` no longer reports the scheduled tasks as "all present and last run succeeded" when it could
429
+ not actually read the result.** That row parses the text `schtasks` prints and looks for the `Last Result` line;
430
+ if a system language or an output-format change takes that line away, both its "failed" and its "missing" lists
431
+ stay empty and it fell through to the success message - a green row for something it never verified. It now says
432
+ **not verified** (and fails) instead, with the command to check by hand. Same rule as the client-version row:
433
+ not being able to read something is not the same as it being fine.
434
+ - **`check` and `health_check` now report which WeChat build is actually loaded.** They reported the database
435
+ format ("4.x") but never the client version, which is the first thing to ask when data refuses to load. The
436
+ version comes from the **live process's `--user-lib-dir`** (the only place that knows it - the parameter does
437
+ not appear in `Weixin.exe` at all), *not* from the version literal inside the executable: that literal is a
438
+ fallback used only when the primary load path fails, and on this machine it matches the directory by
439
+ coincidence, so reading it would look right and be wrong. The same check flags **version directories that
440
+ cannot work**: a usable one must contain `Weixin.dll`, and this machine still carries a leftover `4.1.15.11\`
441
+ holding a single file - pointing a load at it fails. When the client is not running the row says **unknown**
442
+ rather than green-and-fine, and a `check --json` that lacks the whole field counts as a **failure** (that
443
+ means the build is stale - which is exactly how this row first failed, before `npm run build`).
444
+ Three more signals come from the same process listing: the **data directory the client itself reports**
445
+ (`--wechat-files-path`, compared against the configured root *by real path*, since this machine's data
446
+ directory is a symlink), the client's **crash records** (count and newest timestamp only - never their
447
+ contents), and a caveat when `update\update.data` exists, because then the version from the command line may
448
+ not be the one actually loaded (that file's format is not understood, so it is stated rather than guessed).
449
+ That self-reported directory is also **used**, not just shown: `init` now asks the running client first when
450
+ discovering the data root, and reports which source won, because the other two routes (well-known locations,
451
+ scanning the disk for the folder name) both guess from the name. A hint still has to pass the same structural
452
+ check (an account folder must exist under it) - being handed a path is not the same as the path working.
453
+ The comparison against the configured root only runs on a 4.x setup: that root is derived from the 4.x database
454
+ path, so on a 3.x-only machine it would produce a warning that cannot mean anything (and the line only prints
455
+ when it is false, which is exactly where a meaningless warning hurts). The same row now also prints the build
456
+ token the client reports (`--client_version`) next to the directory name - a second, independent source for
457
+ "which build is this"; the two are shown side by side and neither is derived from the other, because the mapping
458
+ between the token and the dotted version has not been worked out.
459
+ - **The 2D knowledge graph shows names again when you click a concept.** Focusing a node dims everything else and is
460
+ supposed to make "who is it connected to" visible - but the label pass only ever considered nodes *outside* the
461
+ highest-degree batch, so clicking a hub (in a small graph: clicking anything) drew **no names at all** while the
462
+ HUD showed one. It now labels the focused concept and its neighbours, ordered by degree so the same graph always
463
+ draws the same names. Found while splitting the two knowledge bases; the regression test asserts the focused name
464
+ and a neighbour's name are really drawn, and reverting the fix fails it with `(一个都没有)`.
465
+ - **`search_knowledge` walks the neighbourhood in the line you asked for.** When both knowledge bases hold the same
466
+ concept name, the walk used to resolve every hop against the first directory that had the file - so the
467
+ conversation-line page was unreachable even though the answer you just got said the other line has one too. The
468
+ starting page now honours the line that matched, and each hop prefers its parent's line before falling back to the
469
+ other one (the same rule the graph uses: a page may legitimately link a concept that only exists on the other
470
+ line - 277 such links exist in this vault).
471
+ - **Links that name a concept both knowledge bases have now say which one they mean.** With the two concept
472
+ namespaces split, a bare `[[DeepSeek]]` in Obsidian resolves deterministically but not necessarily to the page you
473
+ meant. Rather than moving the chat tree into a second vault, the fix keeps one vault and writes the line's path into
474
+ the link: `[[Chat/Concepts/DeepSeek|DeepSeek]]`. `weflow-cli wiki lint` reports the remaining ambiguous links in a
475
+ new `ambiguousLinks` section and `weflow-cli wiki compile --qualify-links` (dry-run first) rewrites them - only for
476
+ names both lines have, never inside a `## 来源` section (those links mean cards), and idempotently, so re-running
477
+ changes nothing. On this vault that was 36 shared names, 477 pages, 539 links; the rewrite was checked by reverse
478
+ audit: ambiguous links 462 → 0 while every other lint count and all three graph scopes stayed byte-identical.
479
+ New pages render the qualified form automatically (the page builders and `chat-notes` share one renderer), so a
480
+ *new* collision is the only thing that needs the command re-run - and lint is what tells you.
481
+ - **Search can now be scoped to one of the two knowledge bases, and says which one it searched.** Following the
482
+ namespace split above, `search`, `chat`, `vault search`, `vault rag` and the assistant's `search_semantic` /
483
+ `search_knowledge` all take `--line wiki|chat|all` (or `line: "wiki"` for the assistant tools). **The default is
484
+ the article line** because the two bases were split on purpose; `all` merges them explicitly. Every result says
485
+ which line it came from, and an empty result distinguishes "nothing matched" from "this line has no records in the
486
+ index at all". The functions underneath default to `all` instead, so a caller that forgets the argument searches
487
+ too much rather than silently half. Two notes: with the semantic index the line is a mask applied **before** the
488
+ top-k sort over one shared index (one index, filtered at query time - `search-index` deliberately has no `--line`,
489
+ since a per-line index would make `all` a lie), and `semantic_search.py search`'s JSON changed from a bare array
490
+ to an object (`line` / `results` / `indexCounts` / `note`); the assistant now reports an unrecognised shape
491
+ instead of degrading it to "no results". Scoped to the keyword path on this machine, which is the one that runs
492
+ here: `vault search "微信" --line chat` returns chat cards and chat concept pages only, `--line wiki` article
493
+ titles only.
494
+ - **The article knowledge base and the chat knowledge base are now two concept namespaces, as requested.** A concept
495
+ name may hold a page on both lines - `wiki compile` no longer skips a name because the other line already has one -
496
+ and every place that turns a name into a key now qualifies it with the line (`wiki:DeepSeek`), so the merged graph
497
+ keeps both instead of one silently shadowing the other. `wiki lint` reports the overlap in its own
498
+ `crossLineSameName` section (by design, not an error) and `duplicateTitles` now only covers collisions **inside**
499
+ one line. Two honest notes: the two lines still share one vault root, so a bare `[[DeepSeek]]` in Obsidian is
500
+ genuinely ambiguous until the roots are split - that ambiguity is now *reported* rather than avoided by not
501
+ creating the page (the official rule is "a bare link with duplicates resolves deterministically but not
502
+ necessarily to the one you meant"; `[[Chat/Concepts/DeepSeek]]` is unambiguous) - and 36 such pairs already
503
+ existed in this vault, so the invariant this replaced was never true of the data. Verified on the real vault:
504
+ 36 shared names, and `wechat.get_concept` now names both lines for each of them while 1,491 chat-only names get no
505
+ extra note; the 2D viewer is exercised for real in jsdom, the 3D one only syntax-checked (it needs WebGL).
506
+ - **`messages` now says when an ID is one nobody knows, instead of looking like "no messages".** An input shaped like
507
+ an id (`wxid_…`, `…@chatroom`, `…@openim`) is passed straight through, so a typo produced exactly the same output as
508
+ a real conversation that happens to be empty: `未找到消息` and exit code 0. It still exits 0 - "no messages" is not
509
+ an error and scripts depend on that - but when the id appears in neither the session list nor the contacts, the
510
+ output adds a line naming the id and pointing at `sessions` / `contacts`, and `--json` carries an optional `note`
511
+ field beside the unchanged `success: true`. Verified with the real command:
512
+ `messages wxid_zzz_not_a_real_id --json` → `{"success":true,…,"note":"这个 ID 既不在会话里、也不在联系人里:…"}`.
513
+ - **The OC Bot channel can save inbound media to disk, and it stays off until you say otherwise.** Inbound images,
514
+ voice notes, files and videos used to arrive as an empty `filePath` and nothing else, so the assistant was told
515
+ "[image]" and no more. `weflow-cli config set wechatMediaDownload true` makes the channel download each item from
516
+ the CDN and decrypt it locally with the scheme the local image files already use, writing it under
517
+ `output/wechat-media/` (the directory can be moved with `WEFLOW_WECHAT_MEDIA_DIR`). It is **off by default** because
518
+ turning it on is a network access the user should choose. Three things are deliberately decided locally: the file
519
+ name (a name sent by the server is reduced to a sanitised basename, so `../` cannot escape the directory), a 25 MB
520
+ per-item cap, and the failure behaviour - a download that fails, comes back empty or exceeds the cap writes
521
+ **nothing**, leaving `filePath` empty plus one log line naming the reason, rather than a 0-byte file that the rest of
522
+ the pipeline would treat as a picture. **Observed on a real inbound message** (2026-10-10): after a fresh `login-wechat`, a
523
+ text message was answered, then a real image came through as 27,637 bytes with PNG magic - and PIL opened it at
524
+ 798x513 RGB, so the bytes are a real picture and not just ciphertext that happened to land on disk. **Nothing reads the saved
525
+ path yet either**: the file is on disk and `components[].filePath` points at it, but the assistant still receives
526
+ only the text and the kind, so it cannot act on the picture. Handing images to a model is a separate egress
527
+ decision, not a side effect of this switch. **And the switch downloads from any sender**: it runs while the polled
528
+ message is parsed, before the assistant decides whether to answer, so the allowlist gates replies rather than
529
+ downloads - turn it on only if that is what you want.
530
+
531
+ - **Two scripts crashed when their output was redirected or piped, and the crash was invisible from inside this
532
+ repository.** `scripts/quality_eval.py` prints `⚠️` and `scripts/wechat_emoticon.py` prints `✓`; with stdout at the
533
+ locale encoding - which is what a by-hand `python scripts/x.py > out.txt` gets on a Chinese Windows console - the
534
+ first character GBK cannot encode raises `UnicodeEncodeError` and the run dies with exit code 1. Nothing here saw it
535
+ because this environment and the bridge both export `PYTHONIOENCODING=utf-8`; the reproducer only appears under
536
+ `python -E` (measured: `sys.stdout.encoding` becomes `gbk`, locale cp936). Both scripts now call
537
+ `sys.stdout.reconfigure(encoding='utf-8', errors='replace')`, like the 39 others already did, so their output no
538
+ longer depends on the caller's environment. `test/script_stdout_encoding_test.py` guards the rule statically (AST,
539
+ not a subprocess run - a spawn-based test is a false negative on this machine) and carries its own controls
540
+ (`DECISIONS.md` D-090).
541
+
542
+
543
+
544
+ - **`contact-schema` can now read contacts as well as rooms - `--contacts` - and the flags it declares are now guarded.**
545
+ The command has always decoded only *verified* fields; three more were verified in the schema work, so they now have an
546
+ outlet instead of living only in the notes: `region` (country/region code, ISO 3166-1 alpha-2), `bizType` (equal to the
547
+ row's `biz_info.type`) and `updatedAt` (the row's last-updated epoch seconds, with `0` treated as "unset" rather than
548
+ 1970). Two things are deliberately **not** printed: `#4`/`#9` are a digest of the account's own profile text - content,
549
+ not a field name - so they appear only as a length inside `unrecognized` (a test asserts the text itself never reaches
550
+ the output), and the 44-row OpenIM shape is reported as `kind: 'openim'` without decoding its fields (the
551
+ discriminator is "every field number is <= 9", not "it has `#1`" - one row of that family has no `#1`, and a
552
+ full-population check caught it being classified as a contact). Separately, an
553
+ audit found a real hole next to it: the TS layer declares flags and forwards them **by hand**, and nothing tested that
554
+ step - a dropped `--contacts` would be accepted by the CLI and silently read *rooms* instead. `test/contact-schema-cli.test.ts`
555
+ now asserts that every declared option is forwarded inside the action (verified by mutation: replacing the forwarding
556
+ with `[]` turns it red) and that `--contacts` is a flag the CLI actually accepts. `npm test`: 858 -> 860
557
+ (`DECISIONS.md` D-088).
558
+
559
+
560
+
561
+ - **A pairwise sweep inside the blob: three real results and three trivial ways to fake one.** Asking "does field A's value
562
+ determine field B's" across the 940-row main proto gave 398 "determinations" before filtering and 16 after - the
563
+ difference being three degenerate shapes that all make the relation vacuous: A constant, **B constant** (the first
564
+ pass was full of `has15`/`v16`/`v23`, which are always the same), or **A nearly unique** (`#41` has 565 distinct
565
+ values, so it "determines" everything). The surviving results: on biz rows (`#13` present, 687 of them) `#2`, `#12`,
566
+ `#17`, `#22`, `#24` are always 0 and `#25` is always absent - these small flags only ever appear on non-biz rows;
567
+ `#10 = 0 iff #11 = 0` is confirmed a second time by an independent method; and `#2` correlates strongly with that
568
+ URL (123 of the 144 rows with `#2` in {1,2} carry it, versus 18 of 109 rows with `#2 = 0`) without being equivalent.
569
+ The non-biz 253 rows also divide cleanly: **78 are `@chatroom` group rows** (`#2 = 0`), the other 175 are individuals
570
+ (`DECISIONS.md` D-087, `docs/CONTACT_DB_SCHEMA.md` §7).
571
+
572
+
573
+
574
+ - **`#27`'s second field is a URL, and the notes stop there on purpose.** It is present in 141 of 940 rows, always
575
+ `http://`, 107-110 bytes long, and drawn from only **3 hosts** - none of which appears in any column of
576
+ `contact`, `biz_info` or `chat_room_info_detail` (against 3,914 non-empty `big_head_url` and 691 `brand_icon_url`
577
+ rows, zero overlap), so it is not any CDN this machine knows. The rows are all outside `biz_info`. The meaning stays
578
+ UNKNOWN and digging further is deliberately declined: it would mean reading URL path content to unlock nothing else.
579
+ A reading error of mine is corrected alongside - "14 variants" was the number of *lengths*, not of values; the
580
+ min/max/count column was always a length column (`DECISIONS.md` D-086, `docs/CONTACT_DB_SCHEMA.md` §7).
581
+
582
+
583
+
584
+ - **"It parses" is not "it is a message" - and two earlier notes are corrected by that.** Walking every
585
+ length-delimited field one level deeper shows that *text* fields parse too (`#4`, `#5`, `#9` "walk" in 294, 252 and
586
+ 393 of 940 rows because random bytes form legal tags), so the test is not parseability but **whether the shape
587
+ repeats across the whole set**: `#14` is identical in **830/830** rows (`{#1: 0/1, #2: 13 bytes}`) and `#27` in
588
+ **937/937** (`{#1, #2: an 84-141 byte text, #3, #4, #5: a mask}`), while `#6` (555/940) and `#7` (473/940) fall
589
+ short - so the standing line "`#6`/`#7` walk as nested messages" does not hold. Also mapped: `#33`/`#36`/`#15`/`#20`/
590
+ `#21`/`#28` (and most `#26`) are **empty shells** whose presence is the information, which is consistent with the
591
+ group of fields that always co-occur (`DECISIONS.md` D-085, `docs/CONTACT_DB_SCHEMA.md` §7).
592
+
593
+
594
+
595
+ - **A third field is pinned down: `#41` is the row's last-updated time; `#43` turns out not to be an id.** `#41` holds
596
+ epoch seconds in 691 rows (127 of them an explicit 0), correlates with `contact.id` at **Spearman 0.945** with no
597
+ duplicate values among the 564 non-zero rows, and - decisively - **40 rows carry ids below 1500 together with 2025/2026
598
+ timestamps**, which no "creation time" could produce. So it reads as "this row was updated", with most rows never
599
+ updated (which is why the correlation is high but not 1). That is a local inference, not a vendor name: none of the
600
+ 22 columns can hold a time. `#43`'s old description ("a 22-byte id-like value") is wrong - decoding its interior
601
+ gives a small structure with only **10 variants**, the largest covering 448 of 628 rows, so it looks like a set of
602
+ capability/scope codes rather than an opaque id. Two negatives ship with them: none of the twelve small varint
603
+ fields is determined by the row-level attributes tried (account class, `local_type`, `flag`, `verify_flag`,
604
+ `is_in_chat_room`, `chat_room_type`, `chat_room_notify`, presence of `encrypt_username`), and my own first pass at
605
+ that scan was flawed because it included a *constant* attribute as a candidate discriminator - which "determines"
606
+ every field and says nothing (`DECISIONS.md` D-084, `docs/CONTACT_DB_SCHEMA.md` §7).
607
+
608
+
609
+
610
+ - **Two more entries in the value-domain table, one positive and one negative.** Verified: `#10 = 0` and `#11 = 0` are
611
+ *the same 129 rows* (811 / 129 with no off-diagonal), so they share one "unset" state and must not be read as two
612
+ independent flags. Refuted: `#19`'s 0-10 count-like values are not "the number of repeats of some sub-field" - the
613
+ best of the ten candidate sub-fields matches only 104 of 940 rows, which rules out a whole class of explanations
614
+ without yet naming the field (`DECISIONS.md` D-083, `docs/CONTACT_DB_SCHEMA.md` §7).
615
+
616
+
617
+
618
+ - **A second field is named, and every varint field's value domain is now measured.** `#13` equals that row's
619
+ `biz_info.type` - **687 of 687 rows, no counterexample**, across all five values - which also explains the standing
620
+ fact that `#13` appears exactly when `verify_flag != 0` (i.e. when the username is in `biz_info`): the blob simply
621
+ carries a copy of the account's service type, and non-biz rows have no value to write. The same pass filled a real
622
+ gap: the notes had "value range unmeasured" next to most varint fields, and measuring them turned up `#10`/`#38` as
623
+ `0` / `-1` sentinels (`4294967295` is -1 unsigned), `#8` with 23 values shaped like a bitmask, `#12`/`#17`/`#18`/
624
+ `#22`/`#24` as flags and tiny enums, `#16`/`#23` always 0, `#37` using only bits 8 and 11, and **`#19` as an 0-10
625
+ value that looks like a count** (menu-button count already excluded) - a new, clearly-shaped open item. The lesson is
626
+ recorded with it: "does the field have a value" and "what values does it take" are different questions, and only the
627
+ second one names fields (`DECISIONS.md` D-082, `docs/CONTACT_DB_SCHEMA.md` §7).
628
+
629
+
630
+
631
+ - **The first field of the 37 gets a name: `#5` is a country/region code.** It holds exactly two uppercase ASCII
632
+ letters in 717 rows (223 more carry an explicit empty string - a pattern this proto uses a lot) with 25 distinct
633
+ values, and all **25 fall inside the 249 ISO 3166-1 alpha-2 codes** - if the letters were arbitrary the chance of
634
+ all 25 landing there is about **1.4e-11**. Two independent confirmations came with it: the **mode is `CN`, covering
635
+ 94.0%** of rows, which follows from "most accounts on this machine are Chinese" without using the code table at all;
636
+ and only **15 of 25** match ISO 639-1 language codes (which are lowercase) - so it is a *region*, not a *language*.
637
+ That second check is the point of the method: the code table is hardcoded by me, so a "25/25 hits" result would
638
+ otherwise confuse an error in my own oracle with a fact (`DECISIONS.md` D-081). The field number is also a trap worth
639
+ naming explicitly: `#5` in `chat_room`'s blob is the member-status-bit-11 projection (D-070), while `#5` in
640
+ `contact`'s blob is this region code - say which table you mean. Nothing in the code needs to change: no code reads
641
+ `contact.extra_buffer`'s fields today.
642
+
643
+
644
+
645
+ - **Two of the unnamed fields finally have content: they are the account's own profile text, aggregated.** Flattening
646
+ `biz_info`'s `external_info` and `brand_info` into 16,294 distinct leaves and asking whether a blob field *contains
647
+ the leaf from its own row* gives **336 same-row hits against 7 next-row hits** - so the containment is real, not
648
+ coincidence. The split is clean: `#9` carries the subject name and certification description
649
+ (`RegisterSource.RegisterBody` 147, `VerifySource.Description` 67) while `#4` carries **menu button names**
650
+ (`MMBizMenu.button_list[].name` 31 plus 21 sub-button names), and both also carry trademark and verifier names. So
651
+ `#4`/`#9` read as "a digest of this account's own profile strings" - not a name, and containment is not equality.
652
+ The control is the point: the same query run against the *next row's* leaves is what separates signal from noise, and
653
+ it is what killed `#5`'s 22 apparent matches (a two-byte value is "contained" in almost anything; only leaves of 8+
654
+ bytes survive). Hit rates here now come with the counter-rate that could have produced them
655
+ (`DECISIONS.md` D-080, `docs/CONTACT_DB_SCHEMA.md` §7).
656
+
657
+
658
+
659
+ - **The per-room counter has no second copy anywhere locally, which closes its file from three directions.** A sweep of
660
+ all 24 databases (207,156 rows) for the counter's value or its offset found **0 meaningful hits** - but only after the
661
+ criterion was tightened to "same object": a column counts only if the row it sits in *names that room* (in a
662
+ `username`/`room_id`/`chat_name` key column). The loose version of the same search returned **279 hits, every one a
663
+ small-integer collision** (0, 1, 2 and 10 are everywhere, and the rooms sitting at their base keep contributing 0 as a
664
+ candidate) - so the mirror of D-076's rule applies: before reporting a *hit*, ask how likely a collision is. The
665
+ negative ships with the three things this project now requires of any "0 hits" claim: a positive control (rooms at
666
+ base did match zero columns, and `session.last_msg_type = 10000` matched an integer column, so the matcher works),
667
+ what was skipped (735 `Msg_*` tables, plus tables keyed by a hash rather than a username), and the scope (24
668
+ databases, 207k rows). With the earlier two results - it is not a server sequence, and it is not a member-change
669
+ event count - the counter is now refuted from three independent directions, leaving only an off-machine reference
670
+ (another machine, another version, or an older copy of the database) as a route to what one step means
671
+ (`DECISIONS.md` D-079, `docs/CONTACT_DB_SCHEMA.md` §6.1 ⑩).
672
+
673
+
674
+
675
+ - **The per-room counter's "one step" is not a member-change event - and the note saying it "needs a second
676
+ timepoint" was framed too narrowly.** The room message table carries its own timestamped event log (member-change
677
+ notifications, base `local_type == 10000`, 1,764 of them locally), so the question could be tested without a second
678
+ snapshot. It fails on all three candidate readings (n = 71): the counter's offset above its base correlates with
679
+ member-change event count 0.168, joins 0.143 and net change 0.144, while the existing member-count correlation is
680
+ 0.911. The refutation is not an artifact of the retained window: five rooms created after 2026-08 have their whole
681
+ history in-window and still exceed every possible join count - for `chat_room.id = 3906` (36 members, whole life
682
+ 2026-08-31 to 2026-09-28) the offset is **104** against **48** messages of that type in total (9 classified as
683
+ member changes). Two smaller facts came with it: proactive-leave notifications are **0 of 1,622** (WeChat does not
684
+ send them to ordinary members), so the "net change" reading was never measurable from the data side - check that
685
+ both halves of a two-sided test exist before weighting them equally - and the rooms sitting exactly at their base
686
+ are the same **7** under two independent measurements, one of which contains a disband event yet still reads 0
687
+ (`DECISIONS.md` D-078, `docs/CONTACT_DB_SCHEMA.md` §6.1 ⑨).
688
+
689
+
690
+
691
+ - **The "read the client binary for field names" route is closed, and the reason it looked closed once before was a
692
+ false negative.** The `ContactExtData` field names are not recoverable from this machine's binaries, and now the
693
+ negative is structural rather than "not found": the four `micromsg.*` type-name strings have **zero** 4-byte
694
+ references anywhere in the file (only `ChatroomDetailInfoExtData` has two), the pointers next to a type name lead to
695
+ either a 96-byte cipher block or to code, and a whole-file sweep of 43,411 name slots found **no four-member group
696
+ and no ~37-name group**. One hard detail: names that sit next to each other are not one message's field table -
697
+ `AnnouncementEditor` and `ChatRoomStatus` are adjacent to three referenced names yet are referenced **zero** times.
698
+ The addressing lesson is the reusable part: this `Weixin.dll` is **64-bit** (imagebase `0x180000000`) with
699
+ `VA != PRAW` for every section, and the descriptor tables store **file offsets** - searching for a true RVA or VA
700
+ returns **0 for every name**, which is exactly how the earlier "these names are never referenced" note was born.
701
+ So: before writing down any "0 hits / never referenced" result, check which address form you searched, and run a
702
+ positive control (here, `InfoVersion`'s 4 hits). Two smaller results: the client's snake_case name arrays *do*
703
+ preserve SQL column order (`chat_room_info_detail` 8/8, `contact` 18/18) but `biz_info`'s does not, so array
704
+ position must not be used to infer proto field numbers. Three speculative field names ship as **speculation only**,
705
+ each with its own falsification test (`DECISIONS.md` D-077, `docs/CONTACT_DB_SCHEMA.md` §3.3/§7).
706
+
707
+
708
+
709
+ - **`contact.verify_flag` is decoded - and it never was about friend requests.** It is a bitmask with 9 observed
710
+ values (three single-row values were missing from the earlier list). Bit by bit: **`bit3` (value 8) is true iff the
711
+ row's `username` is in `biz_info`** - 687/687, both directions, no off-diagonal - and every non-zero value carries
712
+ it, so "8" *is* "non-zero". `bit4` (16) holds iff `VerifySource` is present (265/265, 2 exceptions the other way),
713
+ `bit8` (256) iff `PersonVerifyInfo.VerifyDesc` is present (12/12), `bit9` (512) implies `PersonVerifyInfo` (65/65),
714
+ and `bit4 ⊥ bit9` - institutional and personal verification are mutually exclusive. Three conditions turn out to be
715
+ three faces of the same 687 rows: `extra_buffer` carries `#13` iff `verify_flag != 0` iff the username is in
716
+ `biz_info` (and none of those rows is outside the large proto). That is also *why* the older note "`verify_flag != 0`
717
+ implies not in any group" held - they are all biz/system accounts. Read as friendship state, the flag is refuted by
718
+ counting: `!= 0` with no `biz_info` row = 0, with a remark = 0, as a group member = 0, and only 5 of 2946 `wxid_`
719
+ contacts are non-zero.
720
+
721
+ - **Two notes corrected: `chat_room_info_detail`'s `#1` is a real nested message, and the three `openim_*` blobs are
722
+ not one shape.** The `#1` body (empty in 72 of 77 rows, non-empty in 5) walks cleanly as `DetailList -> Entry`
723
+ (`Entry` has field numbers 1-7 in all 8 entries, with `#2` a member `username` - 8/8 present in `contact`, 7 of them
724
+ exactly the room's `owner` - and `#5` valid UTF-8 free text), so "a chunk of binary" was wrong. Of the three
725
+ `openim_*` tables, the first two carry "`#1` plus {key, value} pairs", `openim_acct_type`'s blob `#1` is
726
+ byte-identical to that row's `acc_type_id`, and **`openim_wording.ext_buffer` is zero-length in all 21 rows** - the
727
+ old line had merged all three into "3-4 fields with nesting". A related field got a *partial* name and is recorded
728
+ as partial: `#9` is byte-identical to the row's registered entity name (`RegisterSource.RegisterBody`) in 213 of 567
729
+ rows and merely *contains* it in another 21 (41% together) - but 333 rows have nothing to do with it, so it is not
730
+ "the subject-name column" and must not be used as one (`DECISIONS.md` D-076, `docs/CONTACT_DB_SCHEMA.md` §3/§6/§7).
731
+ The rule that came out of that: when a hit rate is reported, ask what the *misses* are - only an asymmetry against
732
+ a field that never matches can support a name.
733
+
734
+
735
+
736
+ - **`contact.extra_buffer` turns out to hold two message types in one column, and the OpenIM half is pinned by
737
+ cross-table foreign keys.** The 44 rows that were described as "a different proto" are **OpenIM-only**: field
738
+ `#1` byte-exactly equals `openim_appid.app_id` and `#2` equals `openim_wording.wording_id` (43/44 each), and the
739
+ nested `#4.#2` is JSON whose key is always `custom_info` (43/43). The carriers are 42/44 `@openim` usernames with
740
+ `local_type` in {5,6} - and every `local_type` 5/6 row in the database is in that family (both directions), so the
741
+ column's type follows `local_type`. Two counts are corrected with it: the "46 small rows" is **44** by field number
742
+ (the other two carry field numbers `#10`/`#40` and belong to the large proto), and `stranger.extra_buffer` was
743
+ labelled `OpenIMContactExtData` in the type table - that is almost certainly wrong (its skeleton matches
744
+ `contact`'s main body, i.e. `ContactExtData`); the real OpenIM blob is the 44 rows. Two more exact relations came
745
+ out of the same pass: **`#13` is present iff `verify_flag != 0`** (687 / 253, no off-diagonal), and `#33` non-empty,
746
+ `#36` present and `#27`'s subfield 3 are the same 824 rows. Also worth keeping methodologically: those `#1`/`#2`
747
+ names came from **cross-table byte-exact hits**, not from reading the client binary - a route the "field names are
748
+ unobtainable" note had not counted (`docs/CONTACT_DB_SCHEMA.md` §3/§3.1/§7, `DECISIONS.md` D-075).
749
+
750
+ - **Member `status` bits got an external signal (does this member ever speak in this room?) and almost nothing
751
+ survived.** The one solid member-level footprint is **bit13**: carriers are systematically quieter *within the same
752
+ room* (P(sent) 0.087 vs 0.208, within-room MH OR 0.40, 19/20 rooms same direction) - so the earlier "no clean
753
+ member-level discriminator" line is withdrawn for that bit. The rest went the other way: **bit4 ↔ has a group
754
+ nickname** shrinks from raw OR 6.19 to **MH 2.05** once the room is controlled (~2/3 of it was "rooms with a
755
+ nickname culture"), and **bit3 is just as strong within-room**, so it is not bit4-specific; "bit4 carriers are
756
+ older members" does not hold within-room (0.353 vs a 0.333 baseline, p=0.42); "bit3 carriers talk more" is a room
757
+ effect (within-room signs 15/13/11, mean difference -0.002); and G's apparent activity comes from **one room**
758
+ (8 of the 10 talking G carriers are in the same room, while the other six G rooms have zero). Two traps are
759
+ recorded too: the two "join-order" proxies (`chatroom_member.rowid` percentile and blob position percentile) are
760
+ the **same ordering in 76/77 rooms** (not two independent signals), and small samples lie about value ranges -
761
+ `#41`, seen as "2021-2024" from a partial sample, is really **2019-2026 with 70% in 2026** (plus 127 rows
762
+ explicitly written as 0), i.e. more like a "last updated" stamp (`DECISIONS.md` D-075,
763
+ `docs/CONTACT_DB_SCHEMA.md` §6/§7).
764
+
765
+
766
+
767
+ - **The member `status` field is a set of small fields rather than a pile of flags, and `contact.extra_buffer`'s main
768
+ body is a 37-field proto - two earlier notes corrected.** (a) The 27 observed member `status` values decompose into
769
+ non-overlapping bit ranges (`bit0 | A<<3 | bit11 | bit13 | G<<20`). `A` (bits 3-4) is a **2-bit field**, not two
770
+ flags (bit3&bit4 co-occur 46 times against an expected 19.7), and its strongest - not definitional - correlate is
771
+ having a group nickname (monotone 0.26→0.78 across A=0→3, but 31% of carriers have none). `G` (bits 20-22) is a
772
+ **3-bit group with a forced bit**: only the values 2/3/6/7 occur, so `G != 0` always sets bit 21 (bits 20&22
773
+ co-occur 18 times against an expected 0.12). Two hard exclusions were re-verified bit by bit (0/4151 each):
774
+ `bit4 ⊥ bit13` (these two point in *opposite* directions, so they are not one enum) and
775
+ `bit11 ⊥ {13,20,21,22}`. The notes also carried a wrong value set for bits 20-22 (`1/3/5/7` - those two values
776
+ cannot occur here). (b) The notes described `contact.extra_buffer` as a "`#3` varint, about 2 bytes"; that is the
777
+ shape of **46 rows only**. The **main body - 940 rows - is a 37-field proto (`#2`...`#38`), median ~115 bytes**.
778
+ The two older counts now reconcile exactly: **984 rows carry a top-level `#3`**, of which 942 are varints (940 large
779
+ + 2 small), 42 are length-delimited and 2 are absent - so the old "942 / 44" *was* the "is `#3` a varint"
780
+ criterion, two rows off the shape split (940 / 46). (c) The real gain: that 940-row body matches
781
+ `stranger.extra_buffer` (1 row locally) **field number by field number and wire type by wire type** - the same
782
+ message type - so the shape catalogue in §7 can now draw value ranges from 940 rows instead of one. `#41` turns out
783
+ to be an **epoch-seconds** value (691 rows, 2021-2024) with no column among the 22 that could hold it. (d) A
784
+ value-alignment pass over all 986 rows (raw / utf8 / md5 / sha1 / b64 / utf-16le against every column) returned
785
+ **2 sparse hits** (`nick_name` == `#4` 8/716, == `#9` 12/953) and **no** hash / URL / wxid-level hit, so the blob's
786
+ field names still ship as raw numbers (`DECISIONS.md` D-074, `docs/CONTACT_DB_SCHEMA.md` §3/§6/§7). One earlier
787
+ figure also had its origin pinned: the "33 accounts with message tables but no `biz_info` row" is a real 33, but
788
+ it comes from the **official-account database's own `Name2Id`** and **none of the 33 has a message table at all** -
789
+ "message table" was the wrong condition, not the number.
790
+
791
+
792
+
793
+ - **`biz_info` - the official-account table in `contact.db` - has been mapped, and it holds fields the daily
794
+ pipeline never had.** The repo has never read this table (a grep finds only the schema notes mentioning it), yet
795
+ its `external_info` JSON carries `RegisterSource.RegisterBody` (**the account's legal/subject name, 93.1% of
796
+ rows**), `VerifySource.Description` / `VerifyBizType` (**verification type**), `ServiceType`, `brand_icon_url`
797
+ (an **avatar direct link**, 691 rows) and `PersonVerifyInfo.VerifyDescribe`. Today's daily report takes account
798
+ names from `contact.remark` / `nick_name`, never uses avatars, and `contact.description` is empty for all 673
799
+ `gh_` rows - so there is currently **no** source for a subject name or a description. Two boundaries are
800
+ recorded with it: the `sync_version` column is declared TEXT but is really a binary blob (`SELECT *` dies on
801
+ UTF-8 decoding - use `text_factory=bytes`), and `PersonVerifyInfo` carries a **verified person's real name**
802
+ plus `ServicePhone` and lat/long, so read the fields you need rather than the whole JSON
803
+ (`docs/CONTACT_DB_SCHEMA.md` §1.1). The same pass decoded two more flag meanings and corrected one earlier
804
+ conclusion: `chat_room_status_` bit 19 means "initialised and not bridged to WeCom/openim" (77/77), bits 2 and
805
+ 31 always appear together, and `contact.extra_buffer` does yield two bit-level rules once you test **bit by
806
+ bit** instead of by whole value (`bit1` implies `local_type = 1` and not a group member, 423/423) - which is
807
+ what retired the earlier "the value cannot be judged" line, itself a by-whole-value artefact
808
+ (`DECISIONS.md` D-073).
809
+
810
+ - **The `#3`/`#4` pair inside `chat_room.ext_buffer` is pinned down to "a local per-room counter", and two name
811
+ candidates are withdrawn.** Three agents worked it from different sides; the results overlap, which is what makes
812
+ them usable. (a) It is **not** a server sequence: 42/42 rooms of the `10000+x` family hold a value below their own
813
+ room's smallest non-zero `server_seq` (median gap 872 million), the entire 84,279-row message set has **zero** rows
814
+ with `server_seq` in `[1e4, 8e8)`, and rooms created in 2024-2026 still sit at `10000+x` while this account's
815
+ server sequence had already reached 841 million back in 2021-11. (b) It is **not** a unique id: seven rooms share
816
+ the value `10000` exactly, which a server-allocated identifier would not do. What survives is "a counter the
817
+ client seeds itself - `10000`, or a ~7e8 block for the older batch, or ~1-2k for the WeCom-bridged rooms - and
818
+ bumps on local events", with member-list changes dominating it (the detail blob's own pair correlates 0.889 with
819
+ member churn and collapses to ~0 against announcements once member count is controlled). Two candidate *names*
820
+ are dropped: `chatroom_seq` (an account-level cursor in the InitContact log, with no room id anywhere near it) and
821
+ `InfoVersion` (the plaintext field-name group that contains it carries **no field numbers**, so its order cannot
822
+ be mapped onto field numbers - reading it as 1..5 would put `AnnouncementPublishTime` at `#4` while the database
823
+ stores it at `#6`). The blobs' `#3`/`#4` therefore still ship as raw field numbers
824
+ (`docs/CONTACT_DB_SCHEMA.md` §3.3/§6.1, `DECISIONS.md` D-071). Two more bites followed the same day, both
825
+ narrowing what the number can be: it is **not** "how many distinct people were ever in this room" (the local
826
+ `name2id` mapping is gap-free - rowids 1..4025 with a count of 4025 - and `chatroom_member.member_id` is that
827
+ same rowid, so only 4025 distinct people exist on this machine, while one room's offset is 15304), and rooms
828
+ whose counter still sits exactly at its base turn out to be **rooms whose member table was written once and
829
+ never touched** (a single contiguous `chatroom_member` rowid run whose length equals the member count, no
830
+ member-change system message, no announcement). So the pair reads as "a per-room counter of member-table
831
+ history" - and the remaining question (does one step mean one member added/removed, or one member-table
832
+ update?) needs a second snapshot of the same room, which a machine-wide sweep showed does not exist locally
833
+ (`DECISIONS.md` D-072 records the full searched range, so nobody re-searches it).
834
+
835
+ - **`contact-schema`'s `#5` output carried a wrong label; the label is now the measured fact.** The blobs'
836
+ top-level `#5` used to be described as "a run of extra participant ids that no table carries". Probing all 77
837
+ rooms shows the id set equals the members whose `status` has bit 11 (2048) set - exactly, room by room
838
+ (77/77), and the 28 entries spread over 15 rooms match 28 members carrying that bit, no more and no fewer. So
839
+ `#5` holds **no information the member list does not already have**, and `extraIds` was simply the wrong name
840
+ for it: the JSON key is now `statusBit11Ids` and the human line reads `#5 (status bit 11 members)`. **That
841
+ key rename is user-visible** - if you were keying off `extraIds`, read `#5` raw instead. Two more bit meanings
842
+ are written down while we were there (both 77/77): `chat_room_info_detail.chat_room_status_` bit 17 marks a
843
+ room bridged to WeCom/openim, and `524288` (bit 19) is only the **mode**, not a constant - the notes used to
844
+ say all 77 rows carried it. The paired-field story narrowed as well: `#3 != #4` is a property of the *detail*
845
+ blob (5 rooms - exactly the rooms whose member list runs one entry ahead of `chatroom_member`), while
846
+ `chat_room`'s own pair stays equal even in those rooms, so "the difference is a pending-change flag" is not
847
+ something the data supports for the room blob itself. `#3`/`#4` still get no name; the one candidate
848
+ (`chatroom_seq` in the client's obfuscated string pool) was chased down and is an account-level cursor in the
849
+ InitContact log with no room id anywhere near it (`docs/CONTACT_DB_SCHEMA.md` §3.2/§6, `DECISIONS.md` D-070).
850
+
851
+ - **Running the pointer across the floating ball makes it squirm as if tickled.** Four generated poses (eyes
852
+ squeezed shut, paws up, open laugh) cycle every 110 ms while the pointer moves over it, and settle back after
853
+ 260 ms without movement. It **no longer tilts** - see the next entry; the 4-degree rotation computed from the
854
+ pointer's speed and direction was removed on 2026-10-02. Why it was computed rather than drawn is still worth
855
+ recording: a fixed frame set cannot express "the harder you tickle, the more it wriggles". That rotation was
856
+ about the ball's
857
+ **centre**, which is what keeps it safe under the circular clip: rotating about the centre preserves every
858
+ pixel's distance from it, so the ears cannot be turned out of the circle - translating them would. The four
859
+ poses are normalized at **scale 1.0** (a tickle must not change the ball's size, unlike the lift sequence),
860
+ which is also why they live in their own table (`TICKLE` in `scripts/panel_frames.py`) instead of joining the
861
+ ordered lift sequence. Skipped under `prefers-reduced-motion`, skipped while the ball is peek-hidden (that
862
+ artwork's straight cut edge would open into a seam when rotated), and a press settles it so the lift owns the
863
+ ball.
864
+
865
+ - **The tickle no longer tilts the ball - rotation belongs to the lift alone.** It used to add up to 4 degrees of
866
+ rotation on top of the four poses, and both features wanted the same channel (a `rotate` on `#ball`), so running
867
+ the pointer across the ball looked like a small version of the take-off. The user asked exactly that - "why does
868
+ it sway when I move the mouse across it, instead of only swaying in the lift state" - and it is also why the code
869
+ had to keep the two mutually exclusive in JS (`canTickle()` excludes `ball-lift`, and a press settles the tickle
870
+ first). `--tickle-deg` is no longer written and its CSS rule is gone, so the tickle is now only the pose frames.
871
+ Two guards keep it from creeping back: `test/panel-tickle.test.ts` asserts the tickle writes **no** angle at all,
872
+ and `test/panel-lift-frames.test.ts` asserts that no `body.ball-tickle #ball` rule carries a `transform`.
873
+
874
+ - **`weflow-cli wiki graph` exports the concept graph as a self-contained 3D page.** `output/knowledge-graph-3d.html`
875
+ (9.05 MB for this machine's 49,956 concepts / 145,794 links) opens by double-click with no server: the libraries,
876
+ the graph and the layout coordinates are all inlined, because a `file://` page cannot `fetch` its data and a CDN
877
+ reference would make a local-first feature depend on the network. The generator used to live in a gitignored
878
+ `output/_3d/`, so on any other machine it simply did not exist - and its Node layout helper carried **this
879
+ machine's absolute path** in the source. Both now live in the repository (`scripts/graph_3d.py`, and the helper
880
+ beside the libraries it loads under `resources/js/graph3d/`, with versions and licences in `NOTICE.txt`). The
881
+ layout is the slow part, so it is computed once and cached under `output/.graph3d-cache/`; `--dry-run` reports the
882
+ counts and writes nothing. `test/graph-3d-cli.test.ts` pins the parts that break silently: the page must carry
883
+ **no** external reference, the two library lists must agree, `--dry-run` must not write, and a hand-run without the
884
+ CLI's `PYTHONIOENCODING` must still print readable UTF-8 (this machine's console is GBK, and that trap has bitten
885
+ the repository before).
886
+
887
+ - **`chat-notes --transcribe-voice` was documented but unreachable.** The Python script has supported it since the
888
+ local-whisper path landed, and `docs/PROJECT_STATE.md` told readers to add the flag to fill missing transcripts -
889
+ but the CLI command forwarded only `--days`, `--limit`, `--yes`, `--json` and `--dry-run`, so the flag came back as
890
+ an unknown option. It is now declared and forwarded; combined with `--dry-run` it fills the local cache and prints
891
+ the counts **without calling any model** (zero cost, zero egress). Run on this machine it took the 30-day window to
892
+ **130 voice messages with transcripts, 0 without**. `test/chat-notes-cli.test.ts` pins both halves (declared *and*
893
+ forwarded) - either one alone leaves the switch dead, which is exactly the state it was in.
894
+
895
+ - **The 3D graph page can be filtered down to a size you can actually read.** On this machine the concept graph is
896
+ 49,956 nodes / 145,794 links / 9.05 MB, and at that size a force layout in a browser is a smear - the measured
897
+ answer is that only **3,825** of those concepts have ten or more links, so `--min-degree 10` produces a 1.09 MB
898
+ page that shows the structure. Degree is taken from the **whole** graph rather than recomputed inside the subset,
899
+ otherwise the same threshold would mean something different on every page, and links pointing at removed nodes are
900
+ dropped with them: keeping them makes the layout fail with "node not found" at build time, which is where the test
901
+ catches it. `--line wiki|chat` draws one corpus at a time (48,430 article concepts against 1,527 chat ones - the
902
+ vocabularies differ enough that merging them hides the structure of both). A filtered graph is a *different* graph,
903
+ so the layout cache is now keyed by a hash of the data (`graph-<hash>.json`) instead of one fixed name: before this,
904
+ building the small page evicted the 50k-node coordinates and switching back cost a 55-second relayout. Three
905
+ mutations confirm the guards bite - keeping dangling links, skipping the node filter, and ignoring `--line` each
906
+ turn the suite red.
907
+
908
+ - **`wiki graph --flat` draws the same graph as a 2D canvas page.** The 2D layout is a *separate* build-time solve,
909
+ not a flattening of the 3D coordinates: the z axis carries structure, so dropping it stacks clusters that have
910
+ nothing to do with each other on top of one another. 2D is also far cheaper - 3,825 nodes took **2.4 s** for 250
911
+ ticks where the 3D solve at 25k took 55 s - so the page is **0.71 MB** against the 3D page's 1.34 MB, and it needs
912
+ **no libraries at all**: the layout is precomputed, the renderer is plain canvas, and unlike the 3D page there is
913
+ not even three.js to inline (a test asserts the page contains no `Three.js Authors` and no external reference).
914
+ Interaction is the Obsidian-like set - drag to pan, wheel to zoom, hover for a name, click a concept to dim
915
+ everything except it and its neighbours, and a search box to jump - with labels only for the highest-degree nodes,
916
+ because 5,000 names on screen is a smear rather than a graph. `--min-degree` / `--line` apply unchanged, and 2D and
917
+ 3D coordinates are cached separately (the digest includes the dimension), so switching between the four
918
+ combinations never recomputes anything. `test/graph-2d-cli.test.ts` includes a **render smoke test**: it feeds the
919
+ built data to the viewer over a fake canvas context and counts the `arc` / `lineTo` / `fillText` calls, because a
920
+ JS error in a viewer yields a blank page with **no failing assertion anywhere** - and the page cannot be eyeballed
921
+ from here. **The page was then reported as too slow, and the numbers say where it went** - measured in a real
922
+ headless Edge over CDP (Node's built-in WebSocket speaks the protocol, so no puppeteer was needed): one `draw()`
923
+ cost **12.78 ms** at 49,956 nodes, and a micro-benchmark split it up - 50,000 `arc` calls 9.0 ms, 50,000 `rect`
924
+ calls 3.4 ms, 145,000 line segments 4.1 ms, 80 labels 1.3 ms. Two fixes followed. **Sub-2px dots are now drawn as
925
+ squares** (identical at that size, 2.6x cheaper), and **every interaction redraw is coalesced into one per frame**
926
+ - the handlers used to call `draw()` on every `pointermove`, so a drag issued hundreds of full redraws and the
927
+ canvas never caught up; that was the actual "lag". After: **7.6 ms** per frame (132 fps) and a 200-event drag
928
+ storm went from ~2.6 s (computed from the measured 12.78 ms per draw) to **6.9 ms**. The same measurement pass also
929
+ showed why the labels were unreadable - the top-degree concepts are all hubs sitting in the middle, so all eighty
930
+ names landed on top of each other; labels are now capped and greedily de-overlapped. **The 2D layout needed its own
931
+ force parameters**: the same numbers in one dimension fewer pack 50,000 nodes into a featureless disc (the first
932
+ screenshot was exactly that), so 2D now uses stronger repulsion and longer links. That change exposed a second bug:
933
+ the layout cache key hashed only the *data*, so changing the forces silently kept serving the old coordinates -
934
+ the key now covers data, dimensions, tick count **and** the force parameters (a Python test pins each of those four,
935
+ since the symptom is a silently stale result). And that smoke test did not catch the first version's real defect: the graph drew into the top-left
936
+ 300x150 corner. `canvas` is a *replaced element*, so `position:fixed; inset:0` does not stretch it - right/bottom
937
+ are ignored and it keeps its intrinsic size, `clientWidth` came back as 300, and the whole view was therefore
938
+ computed for a 300x150 viewport. The page CSS now sets width/height explicitly and `resize()` keeps the CSS box and
939
+ the backing store in step; both halves are asserted, and reverting either one turns the suite red (verified). The
940
+ 3D page never had this problem because it draws into a `div` (which does stretch) and lets three.js size its own
941
+ canvas - but nobody has watched *that* one either.
942
+
943
+ - **`weflow-cli contact-schema` decodes the verified half of `chat_room.ext_buffer`.** Those blobs had no
944
+ documentation anywhere - not in this repository, not in the native DLL that reads them (`wcdb_api.dll` just
945
+ hands the string across and never parses it). A read-only probe established the structure and wrote it up in
946
+ `docs/CONTACT_DB_SCHEMA.md`: the top-level `#1` is a repeated member entry (`1=userName`, `2=displayName`,
947
+ `3=status`, `4=inviter`), whose count matches `chatroom_member` row-for-row on 29 of 30 groups, and `#5` is a
948
+ run of extra participant ids (`wxid_...`, `...@openim`) that no table carries. The command outputs exactly
949
+ those two things and **nothing it cannot name**: the top-level `#3`/`#4` pair - which the published
950
+ `roomdata.proto` does not describe at all - goes into `unrecognized` keyed by field number, because three
951
+ separate falsifications (not a member id, not the room id, not any id in the message shards) left its meaning
952
+ open. Two of that proto's claims are wrong for V4 and the notes say so: `roomCap = 5` is really a
953
+ length-delimited string list, and `status` is not the documented 0-9 but a bitfield (0/1/9/17/25/2057/2073/
954
+ 8193/2097153/3145729/6291457/7340049 observed). A malformed blob raises and lands in `failures` rather than
955
+ returning an empty member list, since "cannot parse" and "nobody is in this group" are opposite facts -
956
+ and the test for that only became real after mutation testing showed the first version never reached the
957
+ bounds check it was supposed to guard.
958
+
959
+ ### Added
960
+
961
+ - **The ball now reacts twice more on its own: a hop when a reply lands, and a blink every 7-13 seconds.** The hop
962
+ answers the half `busy` never did - a collapsed ball told you it was *thinking* but never that it was *done*. It
963
+ has **no generated frames, deliberately**: asked for a crouch, the model reliably squashes the body (measured
964
+ +9.2% / +8.0% on the outline metric, and milder wording did not help), and over three frames that meant the ball
965
+ visibly fattening and thinning four times in 270 ms. A hop is a *motion* - the character does not change shape
966
+ when it jumps - so it is a transform, with the rise **budgeted** rather than guessed: the art already uses 125.25
967
+ of the circle's 128 radius, so the airborne step shrinks to 0.96 (content to 120.2) and only then rises 7 px
968
+ (127.2 < 128). It plays only when the turn succeeded (a failure must not make the ball jump) and after the busy
969
+ face is cleared. The blink adds two frames - and **those are composited, not used as generated**: the model
970
+ repaints the whole face, and even a one-pixel difference is the entire ball flickering at 96 px, so the pipeline
971
+ pastes back only the eye band (the iris layer's bounds plus a margin, which stops just above the mouth).
972
+ Measured after compositing: **0 pixels differ outside that band**, asserted in `test/panel-lift-frames.test.ts`
973
+ together with a reverse check that the band itself did change. Both are skipped under `prefers-reduced-motion`;
974
+ the blink also waits until no other face is showing (the state faces have their eyes painted on), the ball is not
975
+ being carried or tickled, and the page is visible.
976
+
977
+ - **The ball now swings while you carry it - and it swings from the moment you pick it up.** Holding it already
978
+ played the "picked up" sequence, but the carry itself was stiff. The first version derived a static tilt from the
979
+ drag speed and leaned **against** the direction of travel, the way something carried lags behind; the user asked
980
+ for something more specific - it should start swinging **when picked up**, and the side it leans towards should be
981
+ the side you are moving towards. So it is now a single sine (a 900 ms period) whose **amplitude and lean both
982
+ come from one "energy" value** read off the drag speed: the swing runs from 6 degrees when it is hanging still
983
+ (picking it up is enough to start it) up to 16, and the lean towards the direction of travel runs from 0 to 6 on
984
+ top of that - a full-speed fling reaches about 22 degrees on the side you are moving towards and dips only to
985
+ about -10 on the other. A
986
+ click sways too, since a click *is* a pick-up - the 4-pixel threshold only decides whether the gesture counts as a
987
+ drag, which is why the old "a click must not tilt" assertion is **removed rather than inverted**. Rotation about
988
+ the ball's centre preserves every pixel's distance from it, so the amplitude is **a matter of taste, not of
989
+ budget** (a translation would have had to fit inside the circle's remaining 2%). The energy decays after 1.2 s of
990
+ stillness, **deliberately longer than the 900 ms period**: the 240 ms tried first expired before the second peak
991
+ arrived, so a leftward drag merely damped the rightward swing (measured -12.6 degrees, indistinguishable from not
992
+ dragging at all) instead of amplifying the leftward one - half of the request. The CSS transition was shortened
993
+ from 90 ms to 45 ms for the same reason: at a 16 ms frame interval a 90 ms first-order lag eats about 15% of the
994
+ amplitude and delays what you see by ~90 ms, and **no test can see that**, because the tests read the
995
+ `--dangle-deg` property rather than the rendered transform. `test/panel-dangle.test.ts` now samples a **whole
996
+ swing period** and asserts both extremes; an earlier version sampled only the first 300 ms, which mutation
997
+ testing showed could not fail on the decay bug above, the negative peak it depends on never falling in that
998
+ window.
999
+
1000
+ - **...and the whole swing grows with the speed - scaling only the lean was not enough.** The speed term was inert
1001
+ from the start there: it saturated at **450 px/s, slower than any real drag**. Raising that to 1800 px/s fixed
1002
+ the lean, and the ball still looked the same at any speed, because the swing itself was a constant +/-12 degrees
1003
+ - the part you actually see was fixed, and the speed only moved a 10-degree lean on top of it. The user said so
1004
+ twice ("move it left quickly and it should sway further left... currently fixed"), and the second time named the
1005
+ gesture explicitly, which is what separated it from the tickle: the swing is the one that renders only while the
1006
+ ball is held (`body.ball-lift`), the tickle is the four poses you get by running the pointer across it (its 4-degree tilt was removed on
1007
+ 2026-10-02, precisely because that distinction was too easy to miss), and
1008
+ the two cannot co-occur (`canTickle()` excludes `ball-lift`). Both quantities now come from the single energy
1009
+ value described above, and the curve is pinned on a **virtual clock** (see below): hanging still is 6.0 degrees,
1010
+ 100 px/s 6.7, 400 px/s 8.9, 900 px/s 12.6, and 1800 px/s 19.3 for a single move - a continuous drag reaches
1011
+ about 22 - so a slow nudge and a fast flick differ by about 10 px of travel at the rim of the 96 px ball, and
1012
+ everything in between is monotone. `DANGLE_FULL_SPEED` is a taste value, not a measurement: lower it for a more
1013
+ sensitive ball, raise it for a stiffer one.
1014
+ - **`test/panel-dangle.test.ts` runs on a virtual clock, because a real one could not control the thing it was
1015
+ testing.** The speed cases set the speed as *pixels moved / milliseconds waited*, and that only means something
1016
+ if the wait is the wait you asked for: measured here, `await wait(20)` actually took 25-31 ms (Windows timer
1017
+ granularity), so "slow" was never the speed the test believed. Mutation testing found the consequence - restoring
1018
+ the 450 px/s saturation above, **the very bug the user reported**, left the suite green. The harness now replaces
1019
+ `setTimeout` and `performance.now` and advances time itself, which makes the speed exact, removes the jitter, and
1020
+ cuts the file from 5.6 s to under a second. `setInterval` was already stubbed, so the status poll still never
1021
+ runs.
1022
+ - **The panel can show you what the assistant remembers about you.** Those long-term facts were previously visible
1023
+ only by opening `~/.weflow-cli/assistant_memory.json` by hand, so the assistant could act on a belief the user had
1024
+ no window into. A `记忆` button in the bubble header fetches `GET /api/memory` - token-gated like every other
1025
+ route - and lists each fact with the time it was written and the quote it came from, which is what makes a wrong
1026
+ memory traceable to the sentence that produced it. It is **read-only**, the bucket is chosen **server-side** and
1027
+ cannot be steered by a query parameter, and the payload **never goes to a model**: it is deliberately separate from
1028
+ the fact injection in `buildSystemPrompt`, which has its own budget and redaction. Facts are capped at 30
1029
+ (`FACTS_MAX`) so the view is bounded by construction, and the page renders with `textContent` only - a test feeds
1030
+ in a memory shaped like an `<img onerror=…>` tag and asserts it displays as text.
1031
+
1032
+ - **Pressing the floating ball now plays a twelve-frame "picked up" animation.** The hard half already existed - a press
1033
+ swapped in a squint-eyed face and a release reverted it instantly - but the only way the ball could say "you have my
1034
+ attention" was one still frame. A press now plays six frames up (hard cuts about 60 ms apart, the first
1035
+ synchronous so the press never shows an empty ball) and a release plays six frames back down to the resting frame. The
1036
+ frames are held assets, so nothing is fetched at press time - a face fetched on demand costs about 15 ms, one 60 Hz
1037
+ frame, which is what used to make the ball flash empty. The twelve frames are **generated by the project's own image
1038
+ model** and then normalized to a written-down geometry contract, which is the part worth keeping: 256x256 RGBA,
1039
+ **everything inside the ball's inscribed circle** (0 solid pixels past r=128, farthest solid distance under
1040
+ 0.98 x 128 measured from the canvas centre with solid = alpha >= 128), the **apex anchored to the resting frame**
1041
+ (the scruff is what is held, so the body hangs from a fixed point) and the **scale taken from the cat's own bounding
1042
+ box** (170 x s) rather than from the canvas, which also carries the speech bubble and the shock marks. An earlier
1043
+ generation drew the hood as a pointed cone with no ears and **every geometric assertion passed** - it was inside the
1044
+ circle, the right size, and whitelisted - so the suite now pins the largest connected component's width and aspect
1045
+ ratio and makes "it is no longer the same cat" fail a test instead of shipping. `prefers-reduced-motion` plays no
1046
+ animation and keeps the squint face, which is now what that face is for. The pipeline is `scripts/panel_frames.py`
1047
+ (`--gen` / `--normalize` / `--check` / `--check-raw`); `--check` uses only the standard library, because the test
1048
+ suite runs it.
1049
+
1050
+ ### Changed
1051
+
1052
+ - **The digest pipeline no longer writes your Obsidian Vault unless you ask.** `pipeline` used to copy the day's
1053
+ content into `Sources/WeChat/<date>/` and compile concept pages as part of every run. **Obsidian's own graph view
1054
+ is live** - it watches the vault's files and re-renders the moment one changes - so "the content updates and the
1055
+ graph rebuilds itself" was the vault being rewritten, not the graph being rebuilt. Both steps are now opt-in
1056
+ (`--with-vault` / `--with-wiki`), the run prints which ones it skipped and how to ask for them, and
1057
+ `test/pipeline-vault-default.test.ts` pins the default: writing the vault succeeds silently, so a flip back to
1058
+ "do it every time" would otherwise have gone unnoticed.
1059
+
1060
+ ### Fixed
1061
+
1062
+ - **A transient failure used to leave the floating ball stuck on the aggrieved "offline" face forever.** That face
1063
+ was set when a status poll failed and the **success path never cleared it** - only a turn's `finally` cleared
1064
+ `busy`. So the few seconds an `assistant stop` / `assistant start` takes were enough to latch it: the daemon came
1065
+ back, the status line read "微信 + 本机", and the ball stayed aggrieved until the window was reopened. The user
1066
+ reported it as "the default face changed", and the artwork was innocent - `mascot-base.png` and `mascot-iris.png`
1067
+ are byte-identical to the repo. The success path now clears it, without touching `busy` (a turn owns that) and
1068
+ while keeping the quota colour. `test/panel-ball-state.test.ts` pins the **recovery**, because the failure half was
1069
+ always correct; a mutation check confirms it goes red when the clear is removed.
1070
+
20
1071
  ## 1.9.0
21
1072
 
22
1073
  ### Added