weflow-cli 1.9.0 → 1.10.0

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