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