weflow-cli 1.8.2 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +62 -26
- package/CHANGELOG.md +1290 -0
- package/OPERATIONS.md +144 -11
- package/README.en.md +20 -4
- package/README.md +30 -5
- package/bin/weflow-cli-electron.cjs +2 -0
- package/bin/weflow-cli.ts +996 -118
- package/dist/bin/weflow-cli.js +1038 -146
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/http.d.ts +14 -0
- package/dist/mcp-server/http.d.ts.map +1 -0
- package/dist/mcp-server/http.js +84 -0
- package/dist/mcp-server/http.js.map +1 -0
- package/dist/mcp-server/httpConfig.d.ts +50 -0
- package/dist/mcp-server/httpConfig.d.ts.map +1 -0
- package/dist/mcp-server/httpConfig.js +104 -0
- package/dist/mcp-server/httpConfig.js.map +1 -0
- package/dist/mcp-server/index.d.ts +13 -1
- package/dist/mcp-server/index.d.ts.map +1 -1
- package/dist/mcp-server/index.js +58 -374
- 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 +61 -1
- package/dist/src/panel/server.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts +69 -0
- package/dist/src/services/assistantEval.d.ts.map +1 -1
- package/dist/src/services/assistantEval.js +270 -8
- package/dist/src/services/assistantEval.js.map +1 -1
- package/dist/src/services/assistantScenes.d.ts +81 -0
- package/dist/src/services/assistantScenes.d.ts.map +1 -0
- package/dist/src/services/assistantScenes.js +322 -0
- package/dist/src/services/assistantScenes.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +33 -4
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +102 -6
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantSkills.d.ts +94 -0
- package/dist/src/services/assistantSkills.d.ts.map +1 -0
- package/dist/src/services/assistantSkills.js +330 -0
- package/dist/src/services/assistantSkills.js.map +1 -0
- package/dist/src/services/assistantTools.d.ts +34 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +931 -88
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/assistantTrace.d.ts +4 -0
- package/dist/src/services/assistantTrace.d.ts.map +1 -1
- package/dist/src/services/assistantTrace.js +9 -0
- package/dist/src/services/assistantTrace.js.map +1 -1
- package/dist/src/services/configService.d.ts +9 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +5 -2
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/messageQuery.d.ts +18 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -1
- package/dist/src/services/messageQuery.js +29 -0
- package/dist/src/services/messageQuery.js.map +1 -1
- package/dist/src/services/outboundSend.d.ts +53 -0
- package/dist/src/services/outboundSend.d.ts.map +1 -0
- package/dist/src/services/outboundSend.js +90 -0
- package/dist/src/services/outboundSend.js.map +1 -0
- package/dist/src/services/pythonBridge.d.ts.map +1 -1
- package/dist/src/services/pythonBridge.js +1 -0
- package/dist/src/services/pythonBridge.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/dist/src/utils/python.js +2 -2
- package/dist/src/utils/python.js.map +1 -1
- package/docs/BRANCHES.md +37 -0
- package/docs/CONTACT_DB_SCHEMA.md +1085 -0
- package/docs/DECISIONS.md +1624 -0
- package/docs/EXTENDING.md +98 -10
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +6 -0
- package/docs/HEALTH-CHECK.md +12 -7
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
- package/docs/MCP.md +58 -4
- package/docs/PROJECT_STATE.md +180 -11
- package/docs/RELEASING.md +115 -1
- package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
- package/logo.png +0 -0
- package/mcp-server/http.ts +108 -0
- package/mcp-server/httpConfig.ts +126 -0
- package/mcp-server/index.ts +359 -681
- 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/ball-position.cjs +48 -2
- package/resources/panel/index.html +18 -4
- package/resources/panel/main.cjs +137 -5
- package/resources/panel/mascot-base.png +0 -0
- package/resources/panel/mascot-blink-1.png +0 -0
- package/resources/panel/mascot-blink-2.png +0 -0
- package/resources/panel/mascot-iris.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-peek.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 +205 -59
- package/resources/panel/preload.cjs +26 -0
- package/resources/panel/renderer.js +646 -17
- package/scripts/_utils.py +1203 -931
- package/scripts/backfill_articles.py +202 -21
- package/scripts/biz_daily.py +26 -34
- package/scripts/bonds.py +331 -0
- package/scripts/chat_notes.py +561 -470
- package/scripts/chat_stats.py +8 -7
- package/scripts/check_readme_commands.py +426 -0
- package/scripts/compile_wiki.py +187 -59
- package/scripts/contact_schema.py +339 -0
- package/scripts/data/wechat_emoji.json +225 -0
- package/scripts/export_chat_html.py +3 -1
- 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/mcp_bridge.py +6 -7
- package/scripts/nt_decrypt.py +3 -3
- package/scripts/panel_frames.py +683 -0
- package/scripts/pipeline.py +21 -10
- package/scripts/quality_eval.py +5 -0
- package/scripts/rag_chat.py +33 -16
- package/scripts/reply_debt.py +26 -6
- package/scripts/semantic_search.py +130 -22
- package/scripts/vault_rag.py +42 -16
- package/scripts/vault_search.py +44 -8
- package/scripts/wechat_emoticon.py +6 -0
- package/scripts/wechat_voice.py +9 -0
- package/scripts/wiki_lint.py +124 -24
- package/src/core/dbPathService.ts +22 -4
- package/src/panel/server.ts +58 -1
- package/src/services/assistantEval.ts +323 -12
- package/src/services/assistantScenes.ts +342 -0
- package/src/services/assistantService.ts +107 -6
- package/src/services/assistantSkills.ts +364 -0
- package/src/services/assistantTools.ts +2452 -1640
- package/src/services/assistantTrace.ts +9 -0
- package/src/services/configService.ts +415 -403
- package/src/services/messageQuery.ts +29 -0
- package/src/services/outboundSend.ts +126 -0
- package/src/services/pythonBridge.ts +1 -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/src/utils/python.ts +2 -2
- package/dist/src/utils/wechatEmoji.d.ts +0 -5
- package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
- package/dist/src/utils/wechatEmoji.js +0 -35
- package/dist/src/utils/wechatEmoji.js.map +0 -1
package/docs/DECISIONS.md
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
> 编号说明:`D-009` 从未使用(D-008 直接跳到 D-010),保留为空号;
|
|
6
6
|
> 历史上曾有两条决策共用 `D-016`,其中「architecture visuals」一条改号为 `D-028`。
|
|
7
|
+
> `D-056`~`D-058` 原本写在**另一条分支** `feat/assistant-skills-scenes-tool-surface` 上(技能包/场景/工具面那三条),
|
|
8
|
+
> 而 `D-059`(悬浮球的眼睛跟随)写在 `feat/panel-eye-follow` 上。本分支当时为避免合并时撞号,直接从 `D-055` 跳到 `D-059`。
|
|
9
|
+
> 两条分支已在同一次合并里并入 `master`(2026-09-30),所以现在 `D-055`~`D-059` 五个号都在,没有空号。
|
|
10
|
+
> **证据分级(2026-10-11 起)**:新条目的每条结论请标 **【实测】/【推断】/【未知】**,被推翻的结论就地标注
|
|
11
|
+
> 而不是删掉 —— 这是借自一份实测的启动器逆向报告的做法(它靠这一栏把"两处自我更正"留在了原地,
|
|
12
|
+
> 那两处都是**先写错、后被自己的证据推翻**的)。旧条目没有回溯补标。
|
|
7
13
|
|
|
8
14
|
## D-001: Local-first data handling
|
|
9
15
|
|
|
@@ -1400,6 +1406,1618 @@ which is what the gate is for.
|
|
|
1400
1406
|
semantics are not reliable here), as is any automatic labelling: there is no gold standard for "is this
|
|
1401
1407
|
draft right", so the feature records what it judged and says so rather than claiming calibration.
|
|
1402
1408
|
|
|
1409
|
+
## D-107: 报"客户端运行时版本",判据是活进程,不是 exe 里的字面量
|
|
1410
|
+
|
|
1411
|
+
**Status:** Active(做法来自一份**实测的启动器逆向报告**——报告在本仓库外,含真实的开发者用户名与
|
|
1412
|
+
厂商内部构建路径,按仓库规矩**不进 `docs/`**;这里只落它的结论与判据)
|
|
1413
|
+
|
|
1414
|
+
**决定。** `check` / `health_check` 新增一项**微信客户端版本**【实测】:版本取自**活进程命令行的
|
|
1415
|
+
`--user-lib-dir`**(`check --json` 多一个 `wechatClient` 块:`running` / `version` / `loadedDirs` /
|
|
1416
|
+
`note` / `unusableVersionDirs`)。三条判据写死:
|
|
1417
|
+
|
|
1418
|
+
1. **不读 `Weixin.exe` 里的版本字面量。**【实测·报告】它确实硬编码着、而且在本机**恰好与版本目录
|
|
1419
|
+
同名** —— 但它只是**兜底**(控制流显示只有主路径加载失败才会用到它;主路径读
|
|
1420
|
+
`%APPDATA%\Tencent\xwechat\update\update.data`,本机没有该文件)。报告里那条"2/2 实测"就是
|
|
1421
|
+
先据此下了结论、后被控制流推翻的:**"字面量存在且与目录同名"与"它决定加载哪条路径"是两个命题。**
|
|
1422
|
+
2. **"磁盘上有版本目录" ≠ "它能用"。** 判据是**有没有 `Weixin.dll`**。本机 `D:\WeiXin\4.1.15.11\`
|
|
1423
|
+
实测【实测】只剩 **1 个文件**(`crashpad_handler.exe`),指向它的加载会失败;`4.1.15.13\` 是
|
|
1424
|
+
29 个文件 / 867.8 MB。
|
|
1425
|
+
3. **取不到就说未知,不算通过。** 客户端没在跑、或进程都没带这个参数 ⇒ `version: null` + 一句
|
|
1426
|
+
`note`;`health_check` 那一项仍算 OK 但**明细里写"未知"**(客户端没开不是故障)。而
|
|
1427
|
+
**`wechatClient` 字段整个不在 ⇒ 判不通过**【实测】:那意味着 `cli.cjs`/`dist` 是**没重建**的旧版本 ——
|
|
1428
|
+
这条规则在它第一次真跑时**就抓到了自己**(没 `npm run build` 之前,体检正是这么红的)。
|
|
1429
|
+
|
|
1430
|
+
**为什么值得一条决策**:这是"一个看着会对的错判据"的标本 —— 字面量在本机 2/2 对得上,谁都不会怀疑;
|
|
1431
|
+
而它错的方式是**静默**的(报一个不是实际加载的那个版本)。同一个坑还有第二个面:目录**存在**不等于
|
|
1432
|
+
**能用**,而"存在"是最容易被当成"没问题"的那种证据。三条判据合起来是一句话:**要问的就一个 ——
|
|
1433
|
+
它现在到底加载了哪个**,别的来源都是替身。
|
|
1434
|
+
|
|
1435
|
+
**同一个检查顺带答另外两个排查问题**(都在同一份进程表上,不额外起进程):
|
|
1436
|
+
|
|
1437
|
+
- **客户端自报的数据目录**(`--wechat-files-path`)与配置里那个数据库推出来的根是不是同一份。
|
|
1438
|
+
只比结论、**不把路径放进机器可读输出**(`weflow-check/v1` 一直不含路径),且**按 realpath 比**——
|
|
1439
|
+
本机 `C:\Users\Administrator\xwechat_files` 实测是 `D:\微信\xwechat_files` 的软链(同一份库两条路径),
|
|
1440
|
+
不比真身会永远报"不是同一份"。
|
|
1441
|
+
- 而且**这条线索现在被用起来了**:`init` 的目录发现(`DbPathService.autoDetect`)会把它作为
|
|
1442
|
+
`hintedRoots` **最先**试,并回报 `source` 让用户知道"这是客户端自己说的 / 还是扫出来的"。
|
|
1443
|
+
理由:发现数据目录的另外两条路(常见位置、全盘扫目录名)**都是按名字猜**,而这一条来自客户端本身;
|
|
1444
|
+
线索**照样要过 `resolveDataRoot` 的结构验证**(找到账户目录才算数)——拿到一个路径 ≠ 它能用。
|
|
1445
|
+
【实测】本机:自报 `D:\微信\xwechat_files` → `{success: true, source: "client"}`,没走扫描。
|
|
1446
|
+
- **客户端崩溃记录**(`%APPDATA%\Tencent\xwechat\crashinfo\`):**只报条数与最近时间,不读内容**【实测】。
|
|
1447
|
+
"数据读不出来 / 客户端起不来"时这是一条独立线索。
|
|
1448
|
+
- **`update.data` 在场时给一句 caveat**【推断】:报告把它留成未解("若该文件存在,exe 侧第一次
|
|
1449
|
+
`LoadLibraryExW` 就不会看命令行 —— 两条路谁优先【未知】"),所以**上面那个版本可能不是加载的那个**。
|
|
1450
|
+
这句话必须在那种机器上跟着版本一起出现,否则新报的版本会**悄悄变成错的**。
|
|
1451
|
+
|
|
1452
|
+
**验证到什么程度**【实测】:真机 `check` / `health_check` 跑出来
|
|
1453
|
+
`4.1.15.13(D:\WeiXin\4.1.15.13)` + `4.1.15.11(1 个文件)` 被独立判成残留(与报告 §2.3 各自独立量到同一件事)
|
|
1454
|
+
+ 自报数据目录与配置**按真身比是同一条**(不报假警告)+ 崩溃记录 1 份 / 2026-09-05;
|
|
1455
|
+
`test/wechat-client.test.ts` 13 条、`test/health_check_test.py` 7 条;变异检查:把"字段缺失 ⇒ 不通过"去掉 → 红。
|
|
1456
|
+
|
|
1457
|
+
**这一轮我踩的两个"看着合理的错"(都当场被自己抓住,记在这里)**:
|
|
1458
|
+
① `--wechat-files-path` **不在 `Weixin.exe` 上**,它只在 `WeChatAppEx.exe`(小程序运行时那组)的命令行上
|
|
1459
|
+
—— 我第一版只筛 `Weixin.exe`,于是这一项**永远是空的**且看不出来;② 从 `message_0.db` 推到数据根要**四**层
|
|
1460
|
+
`dirname`(→ message → db_storage → wxid → xwechat_files),只去三层就退到 `<wxid>` 目录,于是
|
|
1461
|
+
"不是同一份"这条警告**每次都报**(假警报)。两条都不是数据的问题,是判据的问题 —— 与报告 §10.4
|
|
1462
|
+
"判据错了,不是数据的问题"是同一回事。
|
|
1463
|
+
|
|
1464
|
+
**没做的(明写)**:**没有**去解析 `update.data`(报告说它的**内部格式完全没解** ⇒ 不猜);
|
|
1465
|
+
非 Windows 直接报"只做了 Windows"(不是"没问题");**没有**读 exe 的字面量(故意的,见判据 1)。
|
|
1466
|
+
|
|
1467
|
+
**怎么自己复核这几条**(照那份报告的附录 A 的规矩:结论要能直接复现,别只信写的人):
|
|
1468
|
+
|
|
1469
|
+
```powershell
|
|
1470
|
+
# ① 运行时加载的是哪一份(判据是进程命令行,不是 exe 里的字面量)
|
|
1471
|
+
weflow-cli check --json # 看 wechatClient.version / loadedDirs / unusableVersionDirs
|
|
1472
|
+
Get-CimInstance Win32_Process -Filter "Name='Weixin.exe'" | Select-Object ProcessId, CommandLine
|
|
1473
|
+
|
|
1474
|
+
# ② 客户端自报的数据目录(在小程序那组进程上;`Weixin.exe` 上没有)
|
|
1475
|
+
Get-CimInstance Win32_Process -Filter "Name='WeChatAppEx.exe'" | Select-Object -ExpandProperty CommandLine |
|
|
1476
|
+
Select-String 'wechat-files-path'
|
|
1477
|
+
|
|
1478
|
+
# ③ 残留版本目录:**有版本目录 ≠ 能用**,判据是有没有 Weixin.dll
|
|
1479
|
+
Get-ChildItem D:\WeiXin -Directory | ForEach-Object {
|
|
1480
|
+
"{0,-14} 文件 {1,4} Weixin.dll:{2}" -f $_.Name,
|
|
1481
|
+
(Get-ChildItem $_.FullName -File | Measure-Object).Count,
|
|
1482
|
+
(Test-Path (Join-Path $_.FullName 'Weixin.dll'))
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
# ④ 崩溃记录(**只数那三个子目录** —— 与代码同一口径;只数条数,不读内容)
|
|
1486
|
+
Get-ChildItem "$env:APPDATA\Tencent\xwechat\crashinfo\reports",
|
|
1487
|
+
"$env:APPDATA\Tencent\xwechat\crashinfo\records",
|
|
1488
|
+
"$env:APPDATA\Tencent\xwechat\crashinfo\metadata" -File -ErrorAction SilentlyContinue |
|
|
1489
|
+
Measure-Object
|
|
1490
|
+
```
|
|
1491
|
+
|
|
1492
|
+
`4.1.15.13` + `4.1.15.11`(1 个文件)这两条就是上面 ①③ 跑出来的;数据目录那条的"按真身比"是因为
|
|
1493
|
+
`C:\Users\<你>\xwechat_files` 实测是 `D:\微信\xwechat_files` 的**软链**(`③` 之外再 `realpath` 一下即可)。
|
|
1494
|
+
②里那条命令行上还带着 `--client_version=4065599245`(= `0xF2541F0D`)—— 客户端**自报的构建 token**,
|
|
1495
|
+
`check` 会把它与目录名**并列**报出来(它是"哪个构建"的第二个独立来源)。
|
|
1496
|
+
|
|
1497
|
+
**编码规则:低 16 位已经读完,高 16 位还没有**(2026-10-11 复核两对已知值)。按大端展开:
|
|
1498
|
+
`4.1.15.13 → F2 54 1F 0D`、`4.1.13.12 → F2 54 1D 0C`,于是
|
|
1499
|
+
|
|
1500
|
+
- **byte 3 = 第四段**(`0D`=13、`0C`=12,逐字对上);
|
|
1501
|
+
- **byte 2 = 第三段 + 0x10**(13+16=29=`1D`、15+16=31=`1F`,等价于 `0x10 | 第三段`)。
|
|
1502
|
+
|
|
1503
|
+
**高 16 位两版都是 `F254`**,而这两版都是 `4.1.x` ⇒ 拿这两对**分离不出** major/minor(它可能在
|
|
1504
|
+
高 16 位里,也可能根本不在 token 里)。**n=2,所以这只能说是"两对都吻合",不是解出来了**;可判的
|
|
1505
|
+
预测是 `4.1.15.11 → 0xF2541F0B`(本机那个残留版本目录上取不到 token,得等另一台机器或一个新版本)。
|
|
1506
|
+
|
|
1507
|
+
⇒ 结论不变:两者**谁也不推谁**,`check` 仍然只并列报出。本机 `update\download` 与 `update\patch`
|
|
1508
|
+
两个目录都是空的(连隐藏项也没有),所以从这里拿不到更多配对。
|
|
1509
|
+
|
|
1510
|
+
**同一天还试过一条"顺手拿配对"的路子:客户端的日志 —— 不通,而且这是个容易骗过自己的地方。**
|
|
1511
|
+
`%APPDATA%\Tencent\xwechat\log\` 有 53 个 `.xlog`(合计 **373 MB**),拿 `client_version=\d+` /
|
|
1512
|
+
`F254[0-9A-Fa-f]{4}` / `\b4\.1\.\d{1,2}\.\d{1,2}\b` 去 grep:**0 命中**。但这**不是证据** ——
|
|
1513
|
+
那些文件不是文本(头 16 字节 `07 0A 02 17 00 F7 D5 00 00 …`,Tencent 的 xlog 容器),grep 看不见
|
|
1514
|
+
里面任何东西,这是**假阴性**。再往下探了一层:载荷也不是"单字节异或 + zlib/zstd" ——
|
|
1515
|
+
256 个单字节密钥 × 4096 个偏移里"找到"52 个像 zlib 签名的位置,而**随机期望约 48 个**,
|
|
1516
|
+
即那 52 个是噪声、不是流;Mars 默认 pubkey(`0000000000000000`)异或后同样没有签名。
|
|
1517
|
+
⇒ **解 xlog 是一件独立的事**,不是拿更多 (构建, token) 配对的捷径。若将来要拿,别再从 grep 开始。
|
|
1518
|
+
|
|
1519
|
+
## D-106: 同库内消歧 —— 跨线同名的链接按名字带路径,而不是把两条线拆成两个库
|
|
1520
|
+
|
|
1521
|
+
**Status:** Active(补上 D-104 明确留下的那个口子:那条说"二义**报出来**而不是回避",这条说**怎么修**)
|
|
1522
|
+
|
|
1523
|
+
**决定。** 命名空间分开之后,两条线各有一张同名页 ⇒ 裸 `[[DeepSeek]]` 在 Obsidian 里是二义的
|
|
1524
|
+
(官方判据:会 deterministic 解析,但**不保证**命中你想要的那张)。两条修法摆在用户面前
|
|
1525
|
+
(2026-10-10):① 两条线各自一个根 = 各自一个 Obsidian 库;② 留在同一个库里,把指向同名的链接
|
|
1526
|
+
**按名字带上路径**。用户选了 ②:**库结构不动**,链接写成 `[[Chat/Concepts/DeepSeek|DeepSeek]]`
|
|
1527
|
+
(`|` 后面保住显示文本)。
|
|
1528
|
+
|
|
1529
|
+
**只改"两条线都有"的名字。** 只属于一条线的名字,裸写法本来就确定了;全量重写 4.9 万张页换不来
|
|
1530
|
+
任何解析上的差别,而那种 diff 没人看得完。实测要改的:**36 个同名概念、477 张页、539 条链接**
|
|
1531
|
+
(概念页 462 + 索引页 36 + 聊天卡 41;`001_Daily` / `002_Literature` / `Sources/WeChat` **一条都没有**,
|
|
1532
|
+
所以不必扫其余层)。
|
|
1533
|
+
|
|
1534
|
+
**`## 来源` 那一段不许动。** 它的链接指的是**卡片**(`- [[会话-甲]] — 标题`),把它们改成概念页
|
|
1535
|
+
是另一回事。实测这两段的二义链接分布是"相关概念 462 / 来源 0" —— 所以这条边界**没有割掉任何
|
|
1536
|
+
该改的东西**,但它挡住了"改错对象"这一类。
|
|
1537
|
+
|
|
1538
|
+
**渲染端也要跟着改,否则下次编译就漂回去。** 三处渲染(模型建页、零模型建页、索引表)加
|
|
1539
|
+
`chat_notes` 的卡片,统一用 `_utils.LinkNamer`(认线用**路径尾两段** `Chat/Concepts`,所以
|
|
1540
|
+
测试的临时库、换过库根的场景都认得出;认不出就按老行为渲染裸名字)。
|
|
1541
|
+
|
|
1542
|
+
**解析端只做两件事,而且这两件都是量出来的教训。** `_utils.link_target_name` 切掉 `|显示文本`、
|
|
1543
|
+
并对带路径的写法去掉前缀 —— 别的**一律原样返回**:
|
|
1544
|
+
|
|
1545
|
+
- **不切 `#`**:这个库里的概念名**真的带 `#`**(`#GPT6`、`Erdős#1026`)。顺手切了它们会被当成
|
|
1546
|
+
别的名字,指向它们的边整条消失 —— 实测图谱少了 **4 条边**,而"图里少一条边"不报错。
|
|
1547
|
+
- **不切 `.md`**:`## 来源` 的卡片链接就写成 `[[名字.md]]`,`wiki_lint.resolve` 靠这个后缀把它
|
|
1548
|
+
分流到卡片目录;切了那一支就再也不走(`test/wiki_link_qualify_test.py` 与
|
|
1549
|
+
`test/wiki_lint_test.py` 一起钉住)。
|
|
1550
|
+
|
|
1551
|
+
**验证:批量重写后做的是反向一致性审计,不是正向抽样。** 改之前取体检底数、改之后逐项对回来:
|
|
1552
|
+
`ambiguousLinks` **462 → 0**,而 `pages` / `broken` / `orphans` / `empty` / `duplicateTitles` /
|
|
1553
|
+
`crossLineSameName` / `aspirational` **一项都没动**(49,956 / 9 / 0 / 1 / 0 / 36 / 10,324)。图谱
|
|
1554
|
+
三种取值也与改动前**一字不差**(48,429/144,628、1,527/878、49,956/145,795)—— 这正是"限定形式
|
|
1555
|
+
必须能被解析回同一个名字"的端到端证明。
|
|
1556
|
+
|
|
1557
|
+
**改名的那条规则,对"走图"的读者同样适用。** 路径消歧只解决"链接指向哪一张",而读者自己也要选:
|
|
1558
|
+
`assistantTools.conceptSubgraph`(`search_knowledge` 用它给出"这个概念周围的一小片")原来每一步都用
|
|
1559
|
+
"第一个有这个文件的目录",于是**起点永远落在文章线**——刚说完"另一条线也有这个概念"就把它领到文章线
|
|
1560
|
+
那张去。改成 **起点按调用方指明的那条线、之后沿 BFS 继承(本线优先,本线没有才回退到另一条)**——
|
|
1561
|
+
与图谱的解析规则是同一条。回退**不能去掉**:聊天线的页会链到只存在于文章线的概念(实测 277 条),
|
|
1562
|
+
那是有信息量的边,报成"缺页"等于把它丢了。
|
|
1563
|
+
|
|
1564
|
+
**没解决的:新的同名。** 两条线将来又出现同名概念时,新写的链接还是裸的(除非重跑
|
|
1565
|
+
`wiki compile --qualify-links`)。兜底是**体检**:`ambiguousLinks` 那一节会列出来并给出修法。
|
|
1566
|
+
把判断交给一个会报出来的检查,而不是假装它不会发生 —— 这是这条的边界,写在这里而不是等人发现。
|
|
1567
|
+
|
|
1568
|
+
## D-105: 检索也按线分:一个 `--line`、两层默认值,且默认写在输出里
|
|
1569
|
+
|
|
1570
|
+
**Status:** Active(接着 D-104:那条把**概念**分成两个命名空间,这条把**检索**分成两条线)
|
|
1571
|
+
|
|
1572
|
+
**决定。** 四个读者(`search` 语义检索、`chat` RAG、`vault search`、`vault rag`)加上助手的
|
|
1573
|
+
`search_semantic` / `search_knowledge` 都接受 `line`:`wiki` = 文章线、`chat` = 聊天线、
|
|
1574
|
+
`all` = 两条都看。**用户看到的默认是文章线**(两条线分开是用户的决定),而输出里**每一处都
|
|
1575
|
+
写明这一趟搜了哪条线**,并在空结果时给出"怎么放宽范围"。
|
|
1576
|
+
|
|
1577
|
+
**两层默认值,方向是故意反的。** 函数层(`semantic_search.search`、`vault_search.collect_files`、
|
|
1578
|
+
`vault_rag.collect_context`、`rag_chat.query_rag`)默认 **`all`**;只有 CLI/工具参数那一层默认
|
|
1579
|
+
**`wiki`**。理由:调用方忘了传 `line` 时,"多搜一条线"只是结果多余,"少搜一半"是**静默的错答案**。
|
|
1580
|
+
这一条与 D-051/D-104 记的是同一类失效——读一半而没有任何东西会红。
|
|
1581
|
+
|
|
1582
|
+
**掩码必须在取候选之前。** 语义检索那边按 `type` 过滤(索引是**一份**:两条线在同一次嵌入里
|
|
1583
|
+
算,靠记录上的 `type` 分,所以不需要两份索引),做法是把不属于这条线的相似度压成 `-inf`
|
|
1584
|
+
**再** `argsort`。反过来的"先取 top-k 再过滤"看着更简单,但会错两次:条数会少(`--top-k 10
|
|
1585
|
+
--line chat` 只回 3 条,而没人会说为什么),以及 `pool_size` 截断之后**返回另一条线的结果**
|
|
1586
|
+
(实测变异:`--line chat` 回了两条**文章**,还标着"聊天线")。`test/knowledge_line_scope_test.py`
|
|
1587
|
+
用一个人造索引钉住这个方向。
|
|
1588
|
+
|
|
1589
|
+
**输出形状变了一次,而且是**报错**地变。** `semantic_search.py search` 的 stdout 从**裸数组**
|
|
1590
|
+
变成一个对象(`line` / `lineLabel` / `results` / `indexCounts`,有时 `note`)——范围与
|
|
1591
|
+
"为什么是空的"只有进程边界那一侧知道。助手那边的解析因此从 `Array.isArray(data) ? data : []`
|
|
1592
|
+
(旧形状会被静默降级成"没有结果")改成**形状不对就报出来**:读不到和搜不到在聊天里长得
|
|
1593
|
+
一模一样,这个仓库一直在防这种静默。
|
|
1594
|
+
|
|
1595
|
+
**为什么 `search-index build` 不加 `--line`。** 索引一份,按线的过滤在查询时做。分线建索引
|
|
1596
|
+
只会让 `--line all` 变成**假的 all**(另一条线的记录根本不在索引里,而界面上看不出来)。
|
|
1597
|
+
要省钱请用 `--days` / `--article-days` 收窗口。
|
|
1598
|
+
|
|
1599
|
+
**已知限制(没验过的那一半)。** 语义检索的**向量路径在本机从未用真索引跑过**——索引从来没建过
|
|
1600
|
+
(`output/.semantic_index` 不存在,建它要 `dashscopeApiKey` 并花用户的嵌入费用)。所以掩码、
|
|
1601
|
+
`line_counts`、"索引里没有这条线"这些是拿**人造索引**测的;真索引上的行为**没有实测**。
|
|
1602
|
+
关键词回退那条路(本机实际会走的那条)在真库上验过:`vault search --line chat` 只回聊天卡与
|
|
1603
|
+
聊天概念、`--line wiki` 只回文章。
|
|
1604
|
+
|
|
1605
|
+
## D-104: 两条线各有一个概念命名空间,而"一个名字全库只有一张页"这条规则撤掉了
|
|
1606
|
+
|
|
1607
|
+
**Status:** Active(**取代 D-051 里「A name belongs to exactly one page」那一段**;D-051 的其余部分——同一个库、
|
|
1608
|
+
那份五个读者要对齐的目录清单、目录一致性测试——全部仍然成立)
|
|
1609
|
+
|
|
1610
|
+
**决定。** 概念名变成**带线限定**的:文章线与聊天线可以各有一张同名页;`compile_wiki` 不再因为
|
|
1611
|
+
"另一条线已有同名页"而跳过;凡是把概念名当键的地方都改用 `_utils.concept_key(线, 名字)`
|
|
1612
|
+
(形如 `wiki:DeepSeek`)。图谱节点的 id 就是它。`wiki lint` 把跨线同名放进新的一节
|
|
1613
|
+
`crossLineSameName`(**报出来,但不当问题**),`duplicateTitles` 从此只管**同一条线内**撞名。
|
|
1614
|
+
|
|
1615
|
+
分隔符是 `:`:**概念页的文件名里不可能有它**(Windows 禁止,`compile_wiki` 也会把它换成 `_`),
|
|
1616
|
+
而主键要能被原样切回 `(线, 名字)` —— 换成 `-` 或 `_` 都会把 `AI-Agent` 这类名字切错,症状是
|
|
1617
|
+
"图谱里两个节点凭一个错名字连上了",不报错。
|
|
1618
|
+
|
|
1619
|
+
**为什么必须撤。** 用户 2026-10-10 要求两条线在**概念命名空间、目录、检索/RAG** 三层上都分开,
|
|
1620
|
+
同时保留"合起来看/查"的显式入口。而"一个概念名在库里只能有一张页"正好是"两个命名空间"的反面。
|
|
1621
|
+
|
|
1622
|
+
**那条规则原本挡的是什么,它现在还在不在。** D-051 当年否决"两条线各留一张同名页"的理由是
|
|
1623
|
+
Obsidian 里 `[[DeepSeek]]` 会二义。这个理由**是真的**:官方文档给的判据是"有同名文件时,裸链接会
|
|
1624
|
+
deterministic 地解析,但**未必命中你想要的那一张**",消歧手段就是带路径的链接 `[[文件夹/名字]]`。
|
|
1625
|
+
**而且它早就在库里发生了**:2026-10-10 实测,两棵目录里有 **36 个**同名概念各有一张页
|
|
1626
|
+
(`DeepSeek`、`MiniMax`…,聊天页建于 09-27~28、文章页建于 10-01 那次批量编译),文章线一侧有
|
|
1627
|
+
**539 条**链接指向这些名字。也就是说这条约束**在库里的数据上并不成立**,守卫只挡住了 09-27 之后
|
|
1628
|
+
**新建**的同名页。
|
|
1629
|
+
所以现在把二义**如实报出来**(lint 的 `crossLineSameName` 那一节),而不是靠"不建那张页"回避。
|
|
1630
|
+
彻底解决它有两条路,都留给后面:① 两条线各自一个根(各自一个 Obsidian 库,名字各自唯一);
|
|
1631
|
+
② 给指向同名的链接按名字加路径。**本条目不预先选定**——所以今天在 Obsidian 里点一个同名链接,
|
|
1632
|
+
命中的哪一张仍是不确定的:这是**已知代价**,写在这里而不是等人来发现。
|
|
1633
|
+
|
|
1634
|
+
**顺手修掉的一个真 bug。** 图谱节点 id 原来就是文件 stem ⇒ 那 36 组是**同一个键**。前端
|
|
1635
|
+
`new Map(NODES.map(n => [n.id, n]))` 只留一个,症状很轻:节点总数看着对,其中 36 个点不到、
|
|
1636
|
+
边连着错的那一张。实测(2026-10-10,直接调 `graph_3d.build_graph`):
|
|
1637
|
+
`--line all` 49,956 个节点,**主键唯一数从 49,920 升到 49,956(0 个撞键)**;边 145,794 → **145,795**;
|
|
1638
|
+
289 条跨线边里"本来能在本线解析、却跑到另一条线"的是 **0**。同一改动下文章线单独跑是
|
|
1639
|
+
48,429 点 / 144,628 边,与改动前**一字不差**。
|
|
1640
|
+
|
|
1641
|
+
**验证到什么程度(这条撤掉的是一个安全性质,所以要写清)。**
|
|
1642
|
+
- 2D 视图在 jsdom + 假 canvas 上**真跑**:跨线同名的夹具下两个主键都在、图上写的是名字不是主键、
|
|
1643
|
+
搜这个名字会说"两条线都有"(`test/graph-2d-cli.test.ts`);
|
|
1644
|
+
- 3D 视图要 WebGL,jsdom 跑不了 ⇒ 源码形状那条测试(`test/graph-3d-cli.test.ts`)仍然在。
|
|
1645
|
+
**2026-10-11 补上了"真跑"**:`scripts/graph3d_check.mjs` 用 CDP 在**真 GPU 的 Edge** 里打开
|
|
1646
|
+
生成的页面,读它自己的钩子 `window.__viz` —— 判据是 `renderer.info.render` 的**计数器**
|
|
1647
|
+
(GPU 实际提交了多少 points / lines),**不是像素**(WebGL canvas 帧外永远黑,截图证明不了)、
|
|
1648
|
+
**也不是帧率**(页面按需渲染,空转的 rAF 量出来是 144 fps 那种假数字)。实测
|
|
1649
|
+
(`output/knowledge-graph-3d-core.html`,3825 点 / 12337 边,渲染器 ANGLE + RTX 5060 / D3D11):
|
|
1650
|
+
**3 次 draw call、3938 个点、12337 条线**(`triangles` 恒为 0 —— 球与线是 `Points`/`LineSegments`,
|
|
1651
|
+
拿它当"画出来了没"的判据是错的),布局 68–172 ms,GL 提交中位 0.1 ms。
|
|
1652
|
+
⚠️ **这条不在 CI 里**(要一个装了 GPU 浏览器的机器)⇒ 它是"本机可复现的验证",不是回归守卫。
|
|
1653
|
+
- 三条断言做过**变异检查**(主键退回 stem、`n.name` 退回 `n.id`、改一个标签字)——都真的会红;
|
|
1654
|
+
- MCP 的 `wechat.get_concept` 用**真库**手工调过:36 个同名概念每个都会说出"两条线都有"并指明
|
|
1655
|
+
给的是哪一张;1491 个只在聊天线有的名字**不多嘴**(这条没有变成常驻测试——那些目录是模块级
|
|
1656
|
+
常量、不可注入,所以测试用不了夹具)。
|
|
1657
|
+
|
|
1658
|
+
**没改的东西。** 两条线**仍然共用一个库根** `output/wechat-vault`(物理分开是后面一步);
|
|
1659
|
+
`compile_wiki` 仍然一次只写一条线;`--output` 之外的地方一个都没动。
|
|
1660
|
+
|
|
1661
|
+
## D-103: `messages` 的退出码不动,但把"这个 ID 谁都不认识"说出来
|
|
1662
|
+
|
|
1663
|
+
**Status:** Active(补 `docs/HEALTH-CHECK.md` 里那行"未知 talker 退出码不一致")
|
|
1664
|
+
|
|
1665
|
+
1. **不改退出码。** 拼错的 `wxid_…` 与"这条会话在窗口内确实没有消息"此前给出**一模一样**的输出:`未找到消息`、退出 0、`success:true`。但"没有消息"不是错误,脚本与 MCP 依赖这一点 ⇒ **退出码与 `success` 都不动**。
|
|
1666
|
+
2. **只把歧义说出来。** 输入是 `wxid_` / `@chatroom` / `@openim` 形状(`resolveTalker` 对这三种**原样放行**,见 `src/utils/talkerUtils.ts`)、且**会话与联系人名单里都没有它**时,人看的输出补一句并**回显那个 ID**;`--json` 多一个可选 `note` 字段。
|
|
1667
|
+
3. **判定做成纯函数**(`emptyMessagesNote` / `looksLikeTalkerId`,`src/services/messageQuery.ts`):名单由调用方传进去,于是四种情形(认识的 ID、只在联系人里、拼错的 ID、名字形状)都能被单测钉住;另加一条**接线守卫**盯着"函数写了却没接上"——这类缺口本仓从来没有任何东西会报错。
|
|
1668
|
+
4. 真实命令跑过:`messages wxid_zzz_not_a_real_id --json` → `{"success":true,…,"note":"这个 ID 既不在会话里、也不在联系人里:…"}`,退出码 0;人看的输出同样多那一行。
|
|
1669
|
+
|
|
1670
|
+
**Consequences:** `bin/weflow-cli.ts`(messages 命令里接线)、`src/services/messageQuery.ts`(两个纯函数)、`test/message-query.test.ts`(+3 条)、`CHANGELOG.md`、`docs/HEALTH-CHECK.md`(把那行从"没自动修"改成"退出码刻意不动、歧义已说明")。无破坏性变更——JSON 只多了可选的 `note`。
|
|
1671
|
+
|
|
1672
|
+
## D-102: 入站媒体落地**默认关**;文件名与上限由本地定,失败不留半个文件
|
|
1673
|
+
|
|
1674
|
+
**Status:** Active(新能力 + 三条不变量;字段与优先级照厂商实现)
|
|
1675
|
+
|
|
1676
|
+
1. **能力**:官方通道(ilink)的入站图片/语音/文件/视频现在能下载并解密落盘。下载与解密的本事本来就有(`WechatClient.downloadMedia`),缺的只是把它接上 `parseInboundMessage` 这条路——此前那四项只产出 `filePath: ''`,助手拿不到图。与 D-042(`look_at_image`)合起来,"助手能看图"在两个通道上都通了。
|
|
1677
|
+
2. **默认关**:新增 `wechatMediaDownload`,默认 `false`。打开它就是一次显式的网络访问,而本仓的取向是"网络访问要显式"(与 D-001/D-003 同族),所以这里是开关而不是默认行为。**关着时不发任何请求**——这条有测试钉住(不是"大概不会发")。
|
|
1678
|
+
3. **文件名由本地决定**:只取服务端名字的 basename,再过滤控制字符与路径字符、去掉前导点、限长。理由很直接:这是"外部输入变成本地路径"的唯一入口,`../` 必须在这一层被吃掉,不能指望调用方记得。
|
|
1679
|
+
4. **上限 25 MB**;失败/空结果/超限一律 `path: ''` + 一行日志写明原因,**不写半个文件**。半个文件比没有更难查:下游会把它当成"有这张图"。
|
|
1680
|
+
5. **字段与优先级照厂商实现**(`third-party/WeKnora/internal/im/wechat/longpoll.go`):图片先用 `image_item.aeskey`(**hex 字符串**),再退 `media.aes_key`(base64);语音/文件/视频只有 `media.aes_key`。**用错 key 不报错,只会解出乱码**,所以这条优先级本身就是一条要测的事实,而不是实现细节。
|
|
1681
|
+
6. 目录默认 `output/wechat-media/`(`output/` 已在 .gitignore 里),可用 `WEFLOW_WECHAT_MEDIA_DIR` 改——测试因此只用临时目录,从不写仓库。
|
|
1682
|
+
|
|
1683
|
+
7. **它是"谁发来的都下",不是"只下白名单的"**:下载发生在长轮询解析消息那一步,比"助手要不要回答"的判断**更早一层**(访问控制住在 `assistantService` 里)。所以白名单管的是**回不回答**,管不到**下不下载**;只要有人能给这个机器人发消息,他发的媒体就会落盘。这不是疏漏而是层次顺序的后果,所以写在这里而不是悄悄改行为;要收紧的话,正确做法是把"允许谁"的判据上移到解析层,而不是把下载搬到访问控制之后。
|
|
1684
|
+
|
|
1685
|
+
**Consequences:** 新配置键 `wechatMediaDownload`(五处齐全,`test/config-keys.test.ts` 会拦);新模块 `src/services/wechatMedia.ts`;`parseInboundMessage` 变为 async(唯一调用点已 `await`);`CHANGELOG.md`、`docs/PROJECT_STATE.md`、`OPERATIONS.md` 同步;测试 +10(`test/wechat-media.test.ts`,含"默认关不发请求""名字逃逸被吃掉""四种失败不留文件"与真实解密链路的假 fetch 用例)。**已在真实入站消息上验过**(2026-10-10,重新 `login-wechat` 之后):先收到一条文字并被回答(证明通道与访问控制都活着),再收到一张真图 —— **27,637 字节、PNG 魔数、PIL 打开成功(798×513 RGB)**。这一条比"目录里有文件"强得多:乱码是打不开的,能打开才说明解密正确。同一时刻日志里的 `忽略非文本消息 (image)` 也印证了"文件落盘、但助手不把它喂给模型"这条设计。下一步若要把图交给模型,走 D-042 的 egress 决策,不要在这里顺手放开。
|
|
1686
|
+
|
|
1687
|
+
## D-101: `business/xweb/` 是 MMKV 加密存储:本地读不出来,这条线不进能力面
|
|
1688
|
+
|
|
1689
|
+
**Status:** Active(负结论 + **同日更正**:一处"缺口"是我自己的夹具坏了;现在部分存储已能解开)**为什么值得写下来**:它是"看起来该做、量完发现做不了"的那一类,不写下来会被反复重提(同 D-099);而第 4、7 条记的是**两次把方法失败误当成对象性质**,这种错会以"有正对照"的样子出现,最值得留着。
|
|
1690
|
+
|
|
1691
|
+
1. **格式已钉死,可复核。** 账号数据目录下的 `business/xweb/`(含其 `mmkv/` 子目录)里是**成对的 MMKV 存储**:`<id>` + `<id>.crc`。客户端二进制里带 `MMKV.cpp` / `initializeMMKV` / `v2.4.0`,实现是厂商开源的那套。实测布局与源码一致:
|
|
1692
|
+
- 数据文件 = `[u32 actualSize][AES-CFB 密文]`,密文长度恰为 `actualSize`,其后是陈旧字节与零填充;
|
|
1693
|
+
- `.crc` = `MMKVMetaInfo`:`crc32 | version(=4) | sequence | IV[16] | actualSize | lastActualSize | lastCRCDigest | …`;
|
|
1694
|
+
⚠️ **密文段长度要取 meta 里那个 `actualSize`,不要用数据文件头(偏移 0)那个 u32** —— 实测
|
|
1695
|
+
`db_storage/MMKV/f2541f00tinfo.mmkv` 的头部是 **0**,而 meta 里是 192;`crc32(偏移 4 起 192 字节)`
|
|
1696
|
+
恰好等于 meta 的 `crc32`(2026-10-11)。明文里的记录布局是 **4 字节前缀 + `(klen)(key)(vlen)(value)`**
|
|
1697
|
+
—— 这两处 2026-10-11 我本人都先搞错过,两次都是**正对照**(拿一个已知可读的存储跑)当场拦下的。
|
|
1698
|
+
- **`crc32` 等于 `zlib.crc32(密文段)`** —— 在两个互相独立的存储上核对通过(这条同时钉住了"密文从偏移 4 开始、长度就是头部那个数")。
|
|
1699
|
+
2. **加密 = AES-128/256-CFB,IV 每次全量写回随机生成并写进 meta**(源码:`doFullWriteBack` → `fillRandomIV` → `resetIV` → `writeActualSize(..., newIV, ...)`)。⇒ **IV 是已知的;唯一的未知量是密钥**。
|
|
1700
|
+
3. **密钥不在任何能读的文件里。** 以下都试过,且都带正对照(自造 MMKV 明文 → 加密 → oracle 必须能解出、错 key 必须解不出):
|
|
1701
|
+
- 数据库 key、配置里的密钥、passphrase、wxid 派生、md5/sha 变体(34 族 × 16/32 字节)× 各存储的 meta IV / 全零 / key[:16];
|
|
1702
|
+
- 客户端二进制里所有 16/32 字节的**字符串型**候选(341,794 个,含 UTF-16LE);
|
|
1703
|
+
- Weixin.dll 解出的**字符串池**(1,493 个定长候选);
|
|
1704
|
+
- 运行进程内存里 **IV / 密文尾锚点 ±256 字节**内的所有窗口;
|
|
1705
|
+
- 从运行进程内存里**扫出的 892 把真 AES key**(按密钥扩展表的代数关系取值并整表核对过)× 16 个存储 × 8 种 IV。
|
|
1706
|
+
4. **更正(同日追查,重要)**:上面第 3 条里"**AES-NI 字节交换布局没被覆盖**"这个结论**是错的** —— 它的正对照本身就是坏的(`bytes(x)[::-1] for x in flat` 遍历的是**单个字节**,造出来的是垃圾串),所以当时"红"的是夹具,不是方法。修正后**两种布局、四种情形全部通过**,扩展表扫描确实能用。
|
|
1707
|
+
5. **实测:16 个非空存储里 13 个可读**,key 按 **MMKV 根**分两把(IV 一律取 `.crc` 里那 16 字节 —— "IV 存在 meta 里"至此由实测确认,不再只是源码推断):
|
|
1708
|
+
- `config/` 根(key = 随机 16 字节):`rs_config`、`file_config_2024v2`、`file_config_2025v2`、`file_config_2026v2`、`login_configv2`、`newinit_status`。解出来的键名是 `sync_11` / `2025-10…doc` / `SnsWeiShangSessionData` / `predownload_tasks` 这类。
|
|
1709
|
+
- `db_storage/MMKV/` 根(key = 字符串常量 `xwechat_crypt_key` 的**前 16 字节**,ASCII 可见 `xwechat_crypt_ke`):`brandprivatemsg@hardcode`(`unreadcount29` / `red_dot`)与 6 个 `f254*tinfo.mmkv` —— 后者解出 25/107/139/145/185/187 条记录,且**整段恰好走完**(`185/2772`、`187/2802` 字节),这是最强的确认。注意它们的**键名与值都是二进制**。
|
|
1710
|
+
6. **仍锁着 3 个**:`XEditorData` 与 `business/xweb/` 那两个(第三种 key)——这条负结论**逐条被验过**,不是"没试出来":
|
|
1711
|
+
- **补记(2026-10-11)**:本机还有一个**这份清单没覆盖过的 MMKV 根** ——
|
|
1712
|
+
`%APPDATA%\Tencent\xwechat\radium\mmkv\`(小程序运行时的),4 个存储,**每个自己一个目录**
|
|
1713
|
+
(`<名字>\<名字>` + `<名字>.crc`,与数据目录那种"文件平铺"的布局不同):`codecache_appid_dict`、
|
|
1714
|
+
`emu_pkg_appid_dict`、`xweb_config_storage`、`xweb_global_storage`。实测:格式与上面一致
|
|
1715
|
+
(3 个的 `crc32` 前置条件成立;`codecache_appid_dict` 的 meta 说 `actualSize=0`,是个空存储),
|
|
1716
|
+
而**我们能自己构造出来的那把 key(`xwechat_crypt_key` 前 16 字节)打不开它们**(同一次运行里
|
|
1717
|
+
正对照 13 条记录 / 100%、随机 key 0 条 ⇒ 探针本身是好的)。**结论只到这里**:它们用的是
|
|
1718
|
+
我们没拿到的那把(很可能就是 `config/` 根那把随机 16 字节),所以"仍需注入才能拿到 key"
|
|
1719
|
+
这个结论**不变**。**没有**尝试任何注入。
|
|
1720
|
+
——这条根是**另一条线**(微信启动器逆向)点出来的,不是本线枚举到的;两线扫的不是同一处。
|
|
1721
|
+
- **顺带一个口径提醒**:存储数量不是常数。2026-10-11 按"数据文件非空"数:`xwechat_files` 两个
|
|
1722
|
+
根(去重后)共 **38** 对,其中 `db_storage/MMKV/` 下已有 **13** 个 `f254*tinfo`(上面第 5 条写的是
|
|
1723
|
+
当时那次运行里的 6 个)。⇒ "16 个"是**那天的口径**,别当常量用。
|
|
1724
|
+
- 不是某个**能读的存储**里的东西:把 13 个已解开存储的明文全部取出(本地落盘、不入库),在里面按"原始 16/32 字节高熵窗口"和"以它为种子做 md5/sha256 派生"生成 **356,160** 个候选 ⇒ 0 命中(判据有正对照,已知可读的存储被判"可读 ✓");
|
|
1725
|
+
- 不是二进制里**常量样字符串**的截断:补上了"更长常量的前/后缀"这个缺口(此前只测过长度正好 16/32 的、以及含 crypt/xwechat/mmkv 的),`[A-Za-z0-9_]{16,64}` 的全部前后缀共 **278,190** 个候选 ⇒ 0 命中;
|
|
1726
|
+
- **也不是"扫得不够勤"**:一个只扫私有可写区域的持续扫描器跑了 **90 分钟、189 轮**(每轮 24-28 秒、1.18 GB),累计 **2170 把真 key** ⇒ 仍未命中。把窗口量出来就明白为什么:该存储每 ~5 分钟被写一次(`.crc` 的 mtime 在变),而它的 AESCrypt 只在那次调用内活毫秒级 ⇒ 90 分钟里堆被"看"了约 5300 秒、窗口总量约十几毫秒 ⇒ **撞上的概率量级 1e-6**,不是运气问题。
|
|
1727
|
+
- 顺带纠正一个我自己的读法:`.crc` 的 **mtime 变而 sequence 不变** = MMKV 的 `writeCRCAndActualSizeOnly`(只写 crc 与 actualSize、不推 sequence)⇒ "mtime 变了"不等于"全量写回了一次"。
|
|
1728
|
+
- 也排除:没有 >512MB 的大区域被跳过(实测 0 MB);key 不是根目录名/存储名/已知 key 串的哈希族(1761 个派生候选全灭)。
|
|
1729
|
+
- 也不是**注册表或别处的小文件**:`HKCU/HKLM` 下 `Tencent` 相关键几乎是空的(13 个子键、1 个值,没有名字像密钥的);账户目录 + `%APPDATA%/%LOCALAPPDATA%\Tencent` + 安装目录下 **8923 个 ≤8KB 的小文件**里取出 **14,850** 个候选(hex 串、以及"名字像密钥的文件"里的 16 字节窗口)⇒ 0 命中。
|
|
1730
|
+
- **剩下的唯一入口是注入进程**(调试器断在 `CryptGenRandom` 或 `AES_set_encrypt_key` 上、或挂钩写入路径)。它与"只读内存"不是一个风险等级:可被客户端检测、可能影响其稳定性与账号安全 ⇒ **本仓不做**,也不属于这个项目的能力面。真要走,应当是一次**由用户明确授权、单独进行**的实验,并在动手前把"会被检测"这件事说清楚。
|
|
1731
|
+
7. **判据本身错了五次,最后定下一套可验证的判据。** 落在最后的是三条同时成立(且**必须在 crc32 自洽的一对上判**):
|
|
1732
|
+
- 每条记录:键 ≤ 200 字节、值 ≤ 256 KB(否则一条 `vlen=整段` 的"记录"就能把走过比例凑到 99%);
|
|
1733
|
+
- 记录数 ≥ 8(实测真命中是 25 / 185 / 1229 条;随机 key 只凑得出 3-4 条);
|
|
1734
|
+
- 走过的字节 ≥ 90%(真命中都是 100%)。
|
|
1735
|
+
于是它**同时**挡掉了两种错:太松会把随机走完当命中(第 5 条那几步就是在修这个),太严会把带二进制内容的能解存储判死(第 3、4 条)。而且用三个已知可解的存储做正对照,**判据不过就停**。
|
|
1736
|
+
`.crc` 的 `crc32 == zlib.crc32(密文段)` 这一条是前置条件:这些存储一直在被写(实测 `login_configv2` 的 seq 两分钟内从 1253 变 1254),读到半写状态拿到的是"密文对不上 meta",会把能解的判成解不开。其它任何单一指标都**只能用来排序给人看**。
|
|
1737
|
+
8. **别按块熵跳过**:为了让扫描快些,我一开始对每个块算"不同字节数"、太低就整块跳过 —— 这会把"**小对象落在几乎全新的堆页里**"整块丢掉(`AESCrypt` 只有 72 字节)。反退化判据放进预筛里就够了,扫描本来就不慢。
|
|
1738
|
+
9. **即便解开,也未必是"浏览足迹"。** 客户端自带字符串显示这些存储的键是 `mmkv_key_use_sys_browser_key` / `mmkv_key_force_no_embedding` / `mmkv_key_dynamic_config`,加上存储名 `xweb_open_stat` —— 更像**内置浏览器的设置与统计**,不是页面访问史。投入之前应先定"要的是不是它"。
|
|
1739
|
+
10. 本轮读进程内存:只在本机、只读、不落盘、不入库;上面所有数字都是计数,没有标题、键值或路径。
|
|
1740
|
+
|
|
1741
|
+
**Consequences:** 无代码改动、无 CHANGELOG(非用户可见行为)。`docs/CONTACT_DB_SCHEMA.md` 不动(那份文档管 contact.db)。若将来要做,需要一个独立的"内存里定位 AESCrypt 对象"工具,并且必须先补上第 4 条的缺口。
|
|
1742
|
+
|
|
1743
|
+
## D-100: 两轮新否证(`#8` 不是键位图、字段×特征无判别式);`fav_notes` 的"本地正文优先"已量
|
|
1744
|
+
|
|
1745
|
+
**Status:** Active(补 D-095 / D-099;一半是否证,一半是"查过没坏")
|
|
1746
|
+
|
|
1747
|
+
1. **两轮否证**(都写进 §7 的"别重跑"清单):
|
|
1748
|
+
- **字段值 × "该号有什么"**(`external_info` 每个键的有/无、`type`/`child_type`/`brand_flag`、有没有 `Msg_` 表)——
|
|
1749
|
+
**没有任何"某取值下完全恒定"的判别式**。唯一部分信号:`#8` ↔ 有 `WxaAppInfo`(其 23 档里 3 档 P=1.0、3 档 P=0.0,
|
|
1750
|
+
其余 0.2~0.96)——**不是判别式**。
|
|
1751
|
+
⚠️ **一个混杂项**:`ScanQRCodeType` / `IsShowHeadImgInMsg` / `IsHideInputToolbarInMsg` **三个键总同时出现**,
|
|
1752
|
+
于是在很多字段上刷出 0.7~0.86 的"最大分离度" —— 那描述的是"**资料更全的号**",不是这个字段的含义。
|
|
1753
|
+
- **`#8` 是不是"哪些键存在"的位图** → **双向否掉**:同一个键集合对应多个 `#8`(51 个键集合里),
|
|
1754
|
+
同一个 `#8` 对应多个键集合(15 档里 11 档)。
|
|
1755
|
+
2. **`fav_notes` 的"本地正文优先"量过(因为用户问"能不能看收藏文章的划线"而顺带查的)**:
|
|
1756
|
+
- `MIN_ARTICLE_CHARS = 200`;本机 1,234 篇文章里 **1,227 条走抓取、7 条**因网页描述 ≥200 字而用"自带正文"——
|
|
1757
|
+
那 7 条用的是 **`weburlitem/pagedesc`(网页摘要,不是正文)**;门槛挡住了短噪声:
|
|
1758
|
+
那 20 条 `type=1` 的 `desc` 只有 **49 字且是路径式字符串**(含 `/` `\` `:`)⇒ 全部落 `none` ✓。
|
|
1759
|
+
- **不改行为**("本地优先、不联网"是它的卖点,且 `--dry-run` 的承诺就是只读本地),
|
|
1760
|
+
只把 `fav_notes.py` 里"文章类只有 `link`"改成"**基本**只有",并把量出来的数字写进注释
|
|
1761
|
+
(那条注释原本是断言,现在是带分母的观察)。
|
|
1762
|
+
3. **顺带(回答"划线"那条问题的依据)**:**本机收藏库里没有任何正文** —— 全部 1,259 条的文本节点里,
|
|
1763
|
+
最长的 1,177 字是**网页描述**,**>1,000 字的只有 2 个** ⇒ `fav-notes` 在文章上几乎全靠抓取,与设计一致。
|
|
1764
|
+
|
|
1765
|
+
**Consequences:** `scripts/fav_notes.py`(docstring 那句改准 + 写上数字)、
|
|
1766
|
+
`docs/CONTACT_DB_SCHEMA.md` §7(宽扫清单由三轮扩成五轮)。无 CHANGELOG(非用户可见行为变化)。
|
|
1767
|
+
|
|
1768
|
+
## D-099: `contact-schema` **不**进 `capabilities --json`(那是策展子集);小字段"出现情况位图"假设出局
|
|
1769
|
+
|
|
1770
|
+
**Status:** Active(补 D-088 / D-095;两条都是"量过之后不做")
|
|
1771
|
+
|
|
1772
|
+
1. **`capabilities --json` 是人工策展的读接口表**(顶层键:`read` / `primitives` / `workflows` / `safety` / `schema` / `version`),
|
|
1773
|
+
`read` 里只有主流读面(sessions / messages / contacts / exports / favorites / configuration / accessControl…),
|
|
1774
|
+
`daily-stats`/`sns`/`stats` **同样不在**。⇒ 这是**子集,不是穷举**,所以 `contact-schema`(含新加的 `--contacts`)**缺席不是缺口**,
|
|
1775
|
+
**不加**;`docs/AI_INTERFACE.md` 的"推荐调用顺序"同理(也是一份子集)。
|
|
1776
|
+
2. **一条同族的否证**:假设"某个小字段 = 该行可选字段的出现情况(计数或位图)"——
|
|
1777
|
+
拿 `#2/#8/#11/#12/#17/#18/#19/#22/#24/#37` 与"8 个可选字段里出现了几个"比:
|
|
1778
|
+
**命中 0~25/941(偶然水平)**,`#8` 的"置位数 == 个数"只 8 行 ⇒ **出局**(写进 §7 的宽扫清单)。
|
|
1779
|
+
3. **这两条都不改代码**,但值得记:它们是"看起来该做、量完发现不该做"的那一类,不写下来就会被反复重提。
|
|
1780
|
+
|
|
1781
|
+
**Consequences:** `docs/CONTACT_DB_SCHEMA.md` §7(宽扫清单加第三轮)。无代码改动、无 CHANGELOG。
|
|
1782
|
+
|
|
1783
|
+
## D-098: `#6`/`#7` 不在客户端的字符串存储里(更像服务端标签);短值搜大语料必须要求"独立成串"
|
|
1784
|
+
|
|
1785
|
+
**Status:** Active(补 D-096 / D-097;含一处**差点写错的"命中"**)
|
|
1786
|
+
|
|
1787
|
+
1. **两处搜过**:①混淆字符串池(41,922 条 / 去重 35,950)—— `#6` 的 51 个、`#7` 的 120 个取值 **0 命中**;
|
|
1788
|
+
②`Weixin.dll`(192 MB)逐字节 —— 表面上 `#6` **51/51**、`#7` **119/120** "命中"。
|
|
1789
|
+
2. **精度检查推翻了②**:要求"**独立成串**"(前后不是字母/数字/下划线)之后,`#6` 只剩 **9/51**、`#7` 只剩 **2/120**,
|
|
1790
|
+
其余都**嵌在更长的标识符里**。短英文词在 192 MB 二进制里**必然**作为子串出现 ⇒ 那些"命中"是**撞车**。
|
|
1791
|
+
**正对照通过**(`micromsg.ContactExtData` / `Weixin.exe` / `wcdb` 各命中 1 处)⇒ 这个否定是硬的。
|
|
1792
|
+
3. **结论**:`#6`/`#7` 的取值**既不在混淆池里、也不作为独立字面量存在于二进制里** ⇒ 更像**服务端下发的标签**
|
|
1793
|
+
(客户端只显示/透传)。本机没有这份词表 ⇒ UNKNOWN,并写进 §7 的"别再找"。
|
|
1794
|
+
4. **规则升级(写进 `AGENTS.md`)**:**在任何大语料里搜短值,必须要求"独立成串",并说明搜的是哪个语料** ——
|
|
1795
|
+
混淆池里的否定**不能**推广成"二进制里没有"(那是另一个存储区,反之亦然)。
|
|
1796
|
+
这是本会话**第五次**同类(子串)撞车:`#9` 在消息表里的子串命中、"2 字节的 `#5`"、自/他对照缺一不可、
|
|
1797
|
+
以及这次的"短词嵌在长标识符里"。
|
|
1798
|
+
5. **与 D-097 不冲突**:`#26` 的命名来自"**数据里就写着键名**",不是"在二进制里找到了它"。
|
|
1799
|
+
|
|
1800
|
+
**Consequences:** `docs/CONTACT_DB_SCHEMA.md` §7(`#6`/`#7` 两行补"不在字符串存储里";新增那一段两处搜过的记录)、
|
|
1801
|
+
`AGENTS.md`(Verification 补"短值搜大语料要独立成串")。无代码改动、无 CHANGELOG。
|
|
1802
|
+
|
|
1803
|
+
## D-097: 第四条定名路 = 字段内容自带键名;`#26` = `<bizuserattr>`(上次登录/发言/加好友时间)
|
|
1804
|
+
|
|
1805
|
+
**Status:** Active(补 D-081 / D-096;**第四个被命名的字段**)
|
|
1806
|
+
|
|
1807
|
+
1. **结果**:`#26` 的内容是 XML,**键名是客户端自己写的英文标识符** ——
|
|
1808
|
+
根标签 `<bizuserattr>`、内含 `<userop>`,三个属性 **`lastlogintime` / `lastmsgoptime` / `lastaddroptime`**,
|
|
1809
|
+
115 行全有、取值都是 **epoch 秒且落在 2013–2015**。
|
|
1810
|
+
⇒ 读成"**我对该号的用户侧属性:上次登录 / 上次发消息 / 上次加好友的时间**"。
|
|
1811
|
+
**这个名字不是我起的,是数据里就写着的**(与 `#4.#2` 的 JSON 键 `custom_info` 同一类)。
|
|
1812
|
+
2. **方法(第四条路,写进 §7)**:**先问"字段内容里有没有自带键名"**(XML 属性名 / JSON 的键)。
|
|
1813
|
+
全量扫大 proto 的所有 ld 字段 ⇒ **只有 `#26` 是**(没有别的 XML、没有顶层 JSON)。
|
|
1814
|
+
⇒ 至此定名路四条:**(a) 跨表整值 join**、**(b) 自/他对照的内容刻画**、**(c) 公开码表 + 不依赖码表的预测**、
|
|
1815
|
+
**(d) 内容自带键名**。遇到未命名字段,按这四条依次问一遍,别急着说"拿不到"。
|
|
1816
|
+
3. **边界**:`#26` 只有 **115/940** 行非空,且时间戳停在 2013–2015("很久没动"的记录)⇒ 它**不改变**别的结论;
|
|
1817
|
+
也**没有**加进 `contact-schema --contacts`(那个命令只放日报线要用的三个字段,见 D-088)——
|
|
1818
|
+
要加是一条独立决定。
|
|
1819
|
+
4. **仍未命名的**(各自缺什么写清):`#6`/`#7` 像"英文词形标签"但**本机无词表**;`#19` 四族解释已否(D-083/D-095);
|
|
1820
|
+
`#8` 像位掩码;`#25` 样本只 12 行。
|
|
1821
|
+
|
|
1822
|
+
**Consequences:** `docs/CONTACT_DB_SCHEMA.md` §7(`#26` 那行改成带名字的结论;新增"第四条定名路"块、
|
|
1823
|
+
并列出四条路)。无代码改动、无 CHANGELOG。
|
|
1824
|
+
|
|
1825
|
+
## D-096: `#6`/`#7` 是"分类标签"、`#26` 是 XML;要名字只能靠外部标签表
|
|
1826
|
+
|
|
1827
|
+
**Status:** Active(补 D-085 / D-095;一条口径 + 一组新结论)
|
|
1828
|
+
|
|
1829
|
+
1. **两步一问**(这条口径值得沿用):先问"**它是不是 message**"(判据:形状在同一批行里**全量一致**,D-085),
|
|
1830
|
+
再问"**不是 message 的话它是什么**"(长度 / utf8 / 可打印 / JSON / 去重数 / 首字符)。
|
|
1831
|
+
本轮把 `#6/#7/#26/#25` 与五个空壳一起过了一遍,结果见 `docs/CONTACT_DB_SCHEMA.md` §7 那张表。
|
|
1832
|
+
2. **`#6` / `#7` = 分类标签**:非空取值是 **5~19 字节、全 UTF-8 可打印、纯 ASCII 字母**,去重只有 ~36~51 / ~120 个
|
|
1833
|
+
⇒ 是**英文词形的标签**,不是消息、不是逐行唯一的值。
|
|
1834
|
+
3. **已经排除的路**(别再试):不是 `external_info`/`brand_info` 的任何叶子(前一轮全叶子比过)、
|
|
1835
|
+
不等于同行的 22 列、**也不由 `type`/`child_type`/`brand_flag` 决定**(只有 18/36 个取值唯一对应)。
|
|
1836
|
+
4. ⇒ **要给它名字,只能像 `#5` 那样找一份外部标签表**(D-081 的第三条路:公开码表 + 一条不依赖码表的预测)。
|
|
1837
|
+
本机没有这份表 ⇒ 记 UNKNOWN,**不再在本机数据里找对应列**。
|
|
1838
|
+
5. **顺带确认**:`#26` 是 **XML 片段**(100~118 字节、115 个互异值、首字符 `<`);`#25` 是 31 字节、首字符为数字的串
|
|
1839
|
+
(12 个样本,判不了);`#15/#20/#21/#28/#36` 与 `#33` 的壳性质再次确认。
|
|
1840
|
+
|
|
1841
|
+
**Consequences:** `docs/CONTACT_DB_SCHEMA.md` §7(新增"不是 message 的 ld 字段"表 + "别再找"的结论)。
|
|
1842
|
+
无代码改动、无 CHANGELOG。
|
|
1843
|
+
|
|
1844
|
+
## D-095: `#19` 的第三、第四族解释也被否;平凡命中已数到五种,筛子要写进判据函数
|
|
1845
|
+
|
|
1846
|
+
**Status:** Active(补 D-087;本轮的价值全在**否证**与一条方法)
|
|
1847
|
+
|
|
1848
|
+
1. **`#19`(0~10)又否掉两族**:①用"同一个号在 `biz_message_0.db` 里的 `Msg_` 表行数"(本地文章数)比 —— 只对上 6 行;
|
|
1849
|
+
②与 `type` / `child_type` / `VerifyBizType` 这些**分类**字段交叉 —— 都不确定。
|
|
1850
|
+
加上 D-083 否掉的"菜单按钮数"与"子字段重复条数",**一共四族**。
|
|
1851
|
+
⚠️ 但它现在的分布值得记:**0 占 667、1~10 各 20~37(平坦)** ⇒ **不像计数(计数会衰减),像枚举/序号**。
|
|
1852
|
+
2. **两轮宽扫都判负**(写进文档,省后人重跑):把十个小字段与"同行 biz 元数据里的十来种计数"逐个比 —— 无一对上;
|
|
1853
|
+
与"253 个非 biz 行才有的特征"(类别/`local_type`/加密名/是否会话/是否在 `chat_room`/有无那个 URL)交叉 ——
|
|
1854
|
+
没有任何"某取值下恒定"。**`#2/#12/#17/#22/#24` 在 biz 行上非零行为 0**(D-087),所以"用 biz 计数测它们"天然无效。
|
|
1855
|
+
3. **平凡命中数到五种,于是规则升级**:①A 常量 ②B 常量 ③A 取值近乎唯一 ④**双方同时为 0**(第四种本轮才遇到:
|
|
1856
|
+
`#2=0` 与"小程序条目数=0"⇒ 假 676/688)⑤**筛子只写在调用方** —— 第四种的教训刚写进文档,
|
|
1857
|
+
**我新写的脚本就再犯了一次**(`RegisterSource` 那列整列不存在 ⇒ B 恒为常量 ⇒ 报"完全确定")。
|
|
1858
|
+
⇒ 结论写进 `AGENTS.md`:**排除常量的判断要写在判据函数内部**,不能指望每次调用时记得。
|
|
1859
|
+
|
|
1860
|
+
**Consequences:** `docs/CONTACT_DB_SCHEMA.md` §7(`#19` 那行补四族否证与"平坦分布"的观察;新增"两轮宽扫"与
|
|
1861
|
+
"平凡命中五种形态"两块)、`AGENTS.md`(Verification 那条补"筛子写进函数里")。无代码改动、无 CHANGELOG。
|
|
1862
|
+
|
|
1863
|
+
## D-094: 全量集成核对揪出 `--contacts` 的分类缺陷;库是活的,判解码对不对要看逐行关系
|
|
1864
|
+
|
|
1865
|
+
**Status:** Active(补 D-088;一条口径修正 + 一条方法)
|
|
1866
|
+
|
|
1867
|
+
1. **做法**:把刚发布的 `contact-schema --contacts` 在**全量**数据上跑一遍,与文档的断言逐条对
|
|
1868
|
+
(kind 分布 / region 数 / updatedAt 数 / **`#13` 与 `biz_info.type` 逐行相等**)。
|
|
1869
|
+
2. **揪出的缺陷**:`#13 == biz_info.type` 在 **688/688** 行上成立(0 处不一致,这是"解码对不对"的硬判据);
|
|
1870
|
+
但 kind 分布是 **944 / 43**,文档是 **940 / 44** ⇒ 差 1 行。根因:我的判据是"字段号全 ≤ 9 **且**有 `#1`",
|
|
1871
|
+
而那一族里**确有一行没有 `#1`**(B3 当年就报过 `#1` 只有 43 行)⇒ 它被错判成联系人。
|
|
1872
|
+
修法:判据只留"**字段号全 ≤ 9**"(大 proto 一定有 `#2..#38`,不可能全 ≤ 9)⇒ 复测 **943 / 44**,与文档的 44 一致。
|
|
1873
|
+
合成测试补了"没有 `#1` 的小形状"两条,把边界钉住。
|
|
1874
|
+
3. **一条方法**:**绝对值对不上不等于解码错**。同一时刻的复测里 987 vs 986、718 vs 717、566 vs 564 都是**库在变**
|
|
1875
|
+
(这段时间多了一个账号、有行被更新过 ⇒ `#41` 从 0 变成时间戳)。所以判解码正确性要用**逐行关系**
|
|
1876
|
+
(`#13 == type` 这类),**不要用绝对计数** —— 文档 §0 已把这条写成提示。
|
|
1877
|
+
4. **这也是"先发布、再全量核对"的收益**:合成单测抓不到口径缺陷 —— 我当时的用例**恰好都带 `#1`**,
|
|
1878
|
+
于是判据里那个多余条件一直没被触发。
|
|
1879
|
+
|
|
1880
|
+
**Consequences:** `scripts/contact_schema.py`(kind 判据)、`test/contact_schema_test.py`(+2 条边界用例)、
|
|
1881
|
+
`docs/CONTACT_DB_SCHEMA.md` §0("库是活的"提示)、`CHANGELOG.md`(给未发布的 `--contacts` 那条补一句口径修正)。
|
|
1882
|
+
|
|
1883
|
+
## D-093: 一条"已修却没标"的限制行(`dashscopeApiKey`);文档协议补一条
|
|
1884
|
+
|
|
1885
|
+
**Status:** Active(补 D-015 那条"文档要跟源码同步";含一处**非破坏性验证**的示范)
|
|
1886
|
+
|
|
1887
|
+
1. **发现**:`docs/HEALTH-CHECK.md` 的"目前已知、但**未修**的问题"里仍写着
|
|
1888
|
+
"`dashscopeApiKey` 无法通过 `config set` 写入 …… 需要决定是否扩大密钥配置面"。但它**早就修了**:
|
|
1889
|
+
键在 `CliConfig` 里、在 `configurableKeys` 里、也在 `ENCRYPTED_KEYS`(加密存储)里;
|
|
1890
|
+
`test/config_keys_declared_test.py` 这个测试**就是为它建的**;而 `docs/PROJECT_STATE.md` 早写着
|
|
1891
|
+
"Both are settable now",`README.md` / `OPERATIONS.md` 也都在教用户 `config set dashscopeApiKey`
|
|
1892
|
+
⇒ **四处口径里有一处没跟上**,而它偏偏是"限制清单"——最容易被读者当成现状的那一处。
|
|
1893
|
+
2. **验证方式(值得抄)**:`weflow-cli config set dashscopeApiKey sk-test… --dry-run --json`
|
|
1894
|
+
→ `{"success":true,"dryRun":true,"action":"config.set","key":"dashscopeApiKey",…}` ——
|
|
1895
|
+
既证明该键被接受,又因为 `--dry-run` **不落盘**、没碰用户配置。
|
|
1896
|
+
3. **修**:那一行改成"**已修** + 守卫测试名"(同一节里另一行早就是这种写法);该节引言补一句"修好的行留在表里并标注"。
|
|
1897
|
+
4. **协议补一条**(`AGENTS.md` 的 Documentation Protocol):**若某次改动修好了某份文档的"已知未修 / 局限 / 尚未实现"
|
|
1898
|
+
清单里的某一项,必须在同一次改动里更新那张清单** —— 跳过它**不会有任何测试变红**,这正是它会烂的原因:
|
|
1899
|
+
代码那侧会长出测试(`config_keys_declared_test.py` 就是),紧挨着它的散文会漂。
|
|
1900
|
+
5. **给用户的可执行结论**:本机语义检索那条线**卡在两步**上(设 `dashscopeApiKey` + 建语义索引;`PROJECT_STATE`
|
|
1901
|
+
记着"这台机器两样都没有")。补上之前,`search_semantic` 这个工具**根本不会摆给模型**(设计如此,见 D-058 一族)。
|
|
1902
|
+
|
|
1903
|
+
**Consequences:** `docs/HEALTH-CHECK.md`(那一行 + 该节引言)、`AGENTS.md`(Documentation Protocol 加一条)、本条目。
|
|
1904
|
+
无代码改动、无 CHANGELOG。
|
|
1905
|
+
|
|
1906
|
+
## D-092: 文本模式的 `open()` 必须写明 `encoding=`(已钉住);判据别把 `Image.open` 当内置 `open`
|
|
1907
|
+
|
|
1908
|
+
**Status:** Active(补 D-090;**不是修 bug,是把一条已经普遍执行的规矩钉住**)
|
|
1909
|
+
|
|
1910
|
+
1. **审计结论(配了正对照)**:60 个脚本里文本模式的 `open()/write_text()/read_text()` **186 处全部带 `encoding=`、
|
|
1911
|
+
0 处遗漏** ⇒ 读写文件这一侧本来就是干净的。漏了 `encoding=` 时中文 Windows 会按 **GBK** 读写,
|
|
1912
|
+
而下游(HTML / Vault / 日报里)按 UTF-8 解 —— **静默乱码、不报错**,所以值得钉住。
|
|
1913
|
+
2. **判据踩的坑(值得记)**:第一版按"函数名是 `open`"匹配,把 **PIL 的 `Image.open()`** 也算成文本读 ⇒
|
|
1914
|
+
**10 处误报**。它是**二进制图片解码器**,给它 `encoding=` 才是错的。修法:只认**内置** `open`(`ast.Name`),
|
|
1915
|
+
属性调用只认 `write_text`/`read_text`;并把 `Image.open` / `tarfile.open` / `zipfile.open` 三个"不该报"写进对照用例。
|
|
1916
|
+
(又一次证明:**审计脚本自己也要有对照**,见 AGENTS.md。)
|
|
1917
|
+
3. **顺带 3 处真的例外**:`nt_decrypt.py` 里 Linux 分支读 `/proc/<pid>/{comm,statm,maps}`(内容是 ASCII、Linux locale
|
|
1918
|
+
也是 UTF-8,**不算真风险**),仍补上 `encoding='utf-8'` —— 让规矩**没有例外**,省得以后在判据里开白名单。
|
|
1919
|
+
4. **守卫落点**:同一个 `test/script_stdout_encoding_test.py`(CI 的 `unittest discover` 自动收),
|
|
1920
|
+
连同 stdout 那半一起;"0 漏"后面跟着正对照(数不到 186 就报扫描器有问题)。
|
|
1921
|
+
|
|
1922
|
+
**Consequences:** `scripts/nt_decrypt.py`(3 行)、`test/script_stdout_encoding_test.py`(新增文件编码那一类 + 2 条用例)、
|
|
1923
|
+
`docs/EXTENDING.md`(Recipe E 指向同一份测试,说明它同时管 stdout 与文件)。无 CHANGELOG(不是用户可见的行为变化)。
|
|
1924
|
+
|
|
1925
|
+
## D-091: "每个命令的旗标都必须转发给脚本"这条规则**不推广**(量过了,20 个命中绝大多数是误报)
|
|
1926
|
+
|
|
1927
|
+
**Status:** Active(记录**已搜范围**,同 D-072 的做法;结论是"不加这条测试")
|
|
1928
|
+
|
|
1929
|
+
1. **动机**:D-088 给 `contact-schema` 立了一条守卫 —— 声明的每个 `.option` 都必须在 `.action` 之后**被转发给脚本**。
|
|
1930
|
+
很自然想问:能不能推广到整个 CLI?于是用同样的源码扫描把 `bin/weflow-cli.ts` 里 **114 个命令块**量了一遍。
|
|
1931
|
+
2. **结果:20 个块命中,但逐条看下来基本是误报**,原因三类(都不是 bug):
|
|
1932
|
+
- **`--dry-run` / `--yes` / `--json` 由 TS 层自己消费**(两阶段确认门与机器可读输出;`opts.dryRun` / `opts.yes`
|
|
1933
|
+
在文件里出现几十处),本来就不该转发给脚本;
|
|
1934
|
+
- **`--api-key` 是故意不进 argv 的**(密钥走配置/环境;进程列表可见,`test/privacy-paths.test.ts` 也守着这条);
|
|
1935
|
+
- **我的"块切分"对带子命令的父命令是错的**:`.command('pipeline')` 把几十个子命令并成一个块,
|
|
1936
|
+
于是"声明"列里出现十几遍 `--dry-run,--yes,--json` —— 命中数里有一部分纯是切分造成的。
|
|
1937
|
+
- 另有 `opts.path` / `opts.month` / `opts.talker` / `opts.open` 这类"在 TS 层用掉、或按别的方式传给脚本"的。
|
|
1938
|
+
3. **决定:不加** repo-wide 版本。`contact-schema` 那条守卫保留 —— 它的每个声明旗标**确实都是字面转发**
|
|
1939
|
+
(已做变异检查:把转发改成 `[]` 立刻变红),所以那条规则在**那个命令上**是硬事实,在别的命令上不是。
|
|
1940
|
+
4. **顺带把"旗标的三种归宿"记下来**:**转发给脚本** / **TS 自己消费** / **故意不进 argv(密钥)**。
|
|
1941
|
+
以后要立类似规矩,得先按这三种归宿分类,**不能按"命令块里有 `.py`"一刀切**。
|
|
1942
|
+
|
|
1943
|
+
**Consequences:** 无代码改动(这轮的价值是"别再试一遍")。`contact-schema` 的守卫不变。
|
|
1944
|
+
|
|
1945
|
+
## D-090: 两个脚本在"重定向"下会崩(已修);这类 bug 被环境变量盖住,所以守卫必须是静态的
|
|
1946
|
+
|
|
1947
|
+
**Status:** Active(补 D-076 / D-089;含一处口径收紧)
|
|
1948
|
+
|
|
1949
|
+
1. **把"有风险"收紧成"可证明会崩"**:审计 `scripts/*.py` 时,第一版判据是"会打印中文却没有 `reconfigure`" ⇒
|
|
1950
|
+
**13 个**文件命中;但真正的崩点是"**打出 GBK 编不出来的字符**"(纯中文在 GBK 下只是乱码,不崩)⇒
|
|
1951
|
+
逐字测 GBK 可编码性后只剩 **2 行**:`quality_eval.py` 的 `⚠️`、`wechat_emoticon.py` 的 `✓`。
|
|
1952
|
+
(又一次"先说清数的是什么"——见 AGENTS.md 新加的那条。)
|
|
1953
|
+
2. **复现(这一步同时解释了第一次为什么没崩)**:本机与桥接都注入 `PYTHONIOENCODING=utf-8`,
|
|
1954
|
+
于是 `python scripts/x.py > out.txt` **在这里永远不崩**;用 `python -E`(忽略 PYTHON* 变量)才看得见真实处境:
|
|
1955
|
+
重定向时 `sys.stdout.encoding = gbk`(locale cp936),`print('✓')` →
|
|
1956
|
+
`UnicodeEncodeError: 'gbk' codec can't encode character '✓'`、**退出码 1**。
|
|
1957
|
+
⇒ **同一个变量既会伪造 bug(D-076 那条),也会掩盖 bug(这条)**。
|
|
1958
|
+
3. **修法**:给这两个脚本加模块级 `sys.stdout.reconfigure(encoding='utf-8', errors='replace')`
|
|
1959
|
+
(`wechat_emoticon.py` 顺带补 `import sys`),与已在这么做的 39 个脚本一致 ⇒
|
|
1960
|
+
**脚本不再依赖调用方有没有设环境变量**(这正是 Recipe E 的原话)。
|
|
1961
|
+
4. **守卫写成静态的(这是本条的重点)**:新增 `test/script_stdout_encoding_test.py`,
|
|
1962
|
+
用 `ast` 查"stdout 字面量里有没有 GBK 编不出的字符 ∧ 文件里有没有 `reconfigure`",**不 spawn 子进程** ——
|
|
1963
|
+
因为 spawn 版本在本机是**假阴性**(见 2)。它自带 4 条正对照(该报的报、纯 ASCII 与纯中文不报、
|
|
1964
|
+
有 reconfigure 就不报、反"空扫"的计数下限),CI 的 `unittest discover -s test -p '*_test.py'` 自动收。
|
|
1965
|
+
**先跑红(精确报出那两行)再修**,修完全绿(Python 全量 834 条)。
|
|
1966
|
+
5. **`docs/EXTENDING.md` Recipe E** 补了这条两面性,并指向新测试。
|
|
1967
|
+
|
|
1968
|
+
**Consequences:** `scripts/quality_eval.py`、`scripts/wechat_emoticon.py`(各加一行 reconfigure)、
|
|
1969
|
+
`test/script_stdout_encoding_test.py`(新)、`docs/EXTENDING.md`、CHANGELOG 一条(**崩溃修复算用户可见**)。
|
|
1970
|
+
|
|
1971
|
+
## D-089: 负结论必须配"写进脚本"的正对照(同族错误第三次);`contact` 的群行;`#2` 与会话
|
|
1972
|
+
|
|
1973
|
+
**Status:** Active(规则升级到 `AGENTS.md`;补 D-076 / D-087)
|
|
1974
|
+
|
|
1975
|
+
1. **同族错误第三次,所以规则升级**:本轮查"`#2` 与有没有会话"时,第一次得到"**253 个非 biz 行全都不是会话**"——
|
|
1976
|
+
假的:`contact` 那条连接设了 `text_factory = bytes`(返回 bytes),消息库那条没设(返回 str),
|
|
1977
|
+
**bytes ∩ str 恒为空集**,而"没有交集"长得**就像一个发现**。第一次(D-076)是同一个坑,当时只在文档里记了;
|
|
1978
|
+
这次把规则写进 **`AGENTS.md` 的 Verification 一节**(未来所有会话/Agent 先读的那个文件):
|
|
1979
|
+
- **负结论必须先跑正对照,而且正对照要写进脚本**(控制组为空就让脚本自己报"这次比较无效",别报 0)——
|
|
1980
|
+
本次的正确做法就长这样:先断言 `Name2Id ∩ contact.username = 587/617` 非空,再谈别的。
|
|
1981
|
+
- **"相等"类结论强不强,看两个错格**(`bit3 ⟺ 在 biz_info` 之所以能写下来,是因为两个不一致的格子都是 0)。
|
|
1982
|
+
- **报"有多少种"时必须说清数的是什么**(长度 / 取值 / 行 —— 这三个在 `#27.#2` 上就差过一回,D-086)。
|
|
1983
|
+
2. **两条新事实**:
|
|
1984
|
+
- **`contact` 里 78 个 `@chatroom` 行,77 个就是 `chat_room` 的群**(76 个 `local_type = 2`),
|
|
1985
|
+
另有 1 个"像群但不在群表里"的行 ⇒ 非 biz 的 253 行里的"78 个群"就是表里同一批群。
|
|
1986
|
+
- **`#2` 与"有没有会话"相关但不等价**(`Name2Id.is_session`,正对照 587/617):`#2 = 0` 的 109 个非 biz 行里
|
|
1987
|
+
**75 个是会话**(那 78 个群基本都在),`#2 = 1` 36/98、`#2 = 2` 14/46 ⇒ `#2 = 0` 偏"有会话"、
|
|
1988
|
+
`#2 ∈ {1,2}` 偏"没会话",而后者同时也与那个 URL 强相关(123/144)。`#2` 仍 UNKNOWN,但有两个相关项。
|
|
1989
|
+
3. **文档协议的一处自我纠正**:`AGENTS.md` 写着"`CHANGELOG.md` 只记**用户可见**的发布说明,别当工程日记"。
|
|
1990
|
+
前几轮给纯文档/研究轮次也加了 CHANGELOG 条目,**不符合这条**;从本轮起,纯研究/文档轮次**不加**,
|
|
1991
|
+
只有动到 CLI/行为(如 D-088 的 `--contacts`)才加。
|
|
1992
|
+
|
|
1993
|
+
**Consequences:** `AGENTS.md`(Verification 加两条规则)、`docs/CONTACT_DB_SCHEMA.md` §7(新增"群行 + `#2` 与会话"块、
|
|
1994
|
+
含那次假负结论的复盘)。**本轮不加 CHANGELOG**(按第 3 条)。
|
|
1995
|
+
|
|
1996
|
+
## D-088: `contact-schema --contacts`:把 `contact.extra_buffer` 那三个已验证字段做成出口(并说明为什么不输出 `#4`/`#9`)
|
|
1997
|
+
|
|
1998
|
+
**Status:** Active(**取代 D-081 里"代码这边不用动"那句**)
|
|
1999
|
+
|
|
2000
|
+
1. **为什么改主意**:D-081 当时写的是"没有任何代码读 `contact.extra_buffer` 的字段 ⇒ 这次命名只进文档,不进实现" ——
|
|
2001
|
+
那是**命名只有一个字段时**的取舍。到 D-084 已经有三个字段被验证(`#5` 地区码、`#13` = `biz_info.type`、
|
|
2002
|
+
`#41` = 资料最近更新时间),再让它们只躺在文档里,就成了"**库里有值而工具不说**"。⇒ 加 `--contacts` 放出来。
|
|
2003
|
+
2. **放出来的三个**(与文档同一口径):`region`(`#5`,**只在"恰好两个大写字母"时给**,否则留空、原值仍以长度留在 `unrecognized`)、
|
|
2004
|
+
`bizType`(`#13`)、`updatedAt`(`#41`;**`0` 按"显式未设置"处理,不当 1970**)。
|
|
2005
|
+
3. **故意不输出的**(写进代码注释与测试,免得后人以为是漏了):
|
|
2006
|
+
- **`#4`/`#9`**:已定性为"该账号自身资料文本的汇集"(含主体名/菜单按钮名)—— 它是**内容不是字段名**,
|
|
2007
|
+
打进 CLI 输出等于把账号资料漏出去 ⇒ 只以**长度**出现在 `unrecognized` 里;测试**断言内容不在输出里**。
|
|
2008
|
+
- OpenIM 那 44 行的小形状:只报 `kind='openim'`,不套联系人那三个字段、也不输出它的 `#1`/`#2`。
|
|
2009
|
+
- 其余 30 来个字段:未定名 ⇒ 原样进 `unrecognized`(**D-068 不变**)。
|
|
2010
|
+
4. **顺手补的一条守卫(这才是本轮"需要修复"的那处)**:TS 层是**手写转发旗标**的,而此前**没有任何测试守它** ——
|
|
2011
|
+
少写一行 `--contacts`,`commander` 照样接受、脚本照样跑,**只是安静地去读了群**(错的是"读什么",不是"报错")。
|
|
2012
|
+
`test/contact-schema-cli.test.ts` 现在加两条:①**声明了 `.option(...)` 的每个旗标都必须在 `.action(` 之后的片段里
|
|
2013
|
+
被转发**(通用断言,以后新加旗标一样生效;**已做变异检查**:把转发改成 `[]` 立刻变红);
|
|
2014
|
+
②`--contacts` 必须是命令行认得的旗标(不是被 commander 拒掉的死开关,判据是"同样走到脚本再失败")。
|
|
2015
|
+
判据写清楚:**旗标声明在 TS、解析在 Python,中间那一步是手写的,所以它必须被测试盯住**。
|
|
2016
|
+
|
|
2017
|
+
**Consequences:** `scripts/contact_schema.py`(`decode_contact` / `load_contacts` / `--contacts`)、
|
|
2018
|
+
`bin/weflow-cli.ts`(声明 + 转发)、`test/contact_schema_test.py`(6 条合成测试,含"内容不出现在输出里")、
|
|
2019
|
+
`test/contact-schema-cli.test.ts`(2 条新的守卫测试)、`docs/CONTACT_DB_SCHEMA.md` §5、`OPERATIONS.md` §3、
|
|
2020
|
+
`docs/PROJECT_STATE.md`(测试数 856 → 860、入口清单加一条)、`docs/EXTENDING.md`(守卫表加一行)同步。
|
|
2021
|
+
**测试数**:`npm test` 858 → **860**(新增 2 条 TS);Python 单测该文件 4 → **10** 条。
|
|
2022
|
+
|
|
2023
|
+
## D-087: blob 内部两两扫描:真结果三条 + "决定"的三类平凡形态
|
|
2024
|
+
|
|
2025
|
+
**Status:** Active(补 D-082 / D-086;方法更正为主)
|
|
2026
|
+
|
|
2027
|
+
1. **做了什么**:940 行的 `ContactExtData` 主体里,遍历所有"trait 对"(15 个 varint 字段的取值 + 17 个 ld 字段的"有没有值" + `#41` 的年份),
|
|
2028
|
+
问"**A 的取值是否唯一决定 B 的取值**"。**未筛前 398 对**,筛掉平凡情形后 **16 对**,其中真信息三条:
|
|
2029
|
+
- **biz 行(`#13` 存在、687 行)上,`#2`/`#12`/`#17`/`#22`/`#24` 恒 0、`#25` 恒缺** ⇒ 这批"小旗"**只在非 biz 行**出现。
|
|
2030
|
+
- **`#10 = 0 ⟺ #11 = 0`** 被这套独立方法**再确认一次**(`v11 → v10` 的映射表就是它)。
|
|
2031
|
+
- **`#2` 与那个 URL 强相关**:非 biz 的 253 行里 **78 行是 `@chatroom`(群、全 `#2=0`)**、余 175 行为个人;
|
|
2032
|
+
**`#2 ∈ {1,2}` 的 144 行里 123 行带 `#27.#2` 的 URL(85%)**,`#2 = 0` 的 109 行只 18 行带(**17%**)。
|
|
2033
|
+
⇒ **相关但不等价**,`#2` 的含义仍 UNKNOWN(这条把 `#2` 从"纯未知"推进到"与某个 URL 的在场强相关")。
|
|
2034
|
+
2. **"决定"关系的三类平凡形态(本轮全踩了)**:① **A 是常量**;② **B 是常量**(本轮第一版就是栽在这 ——
|
|
2035
|
+
`has15` 恒真、`v16`/`v23` 恒 0,于是"398 对完全确定"里绝大多数是噪声);
|
|
2036
|
+
③ **A 的取值几乎唯一**(`#41` 有 565 个取值,每个只对应一两行 ⇒ "决定"必然成立)。
|
|
2037
|
+
⇒ **判据必须加两条**:A 与 B **都**得有 ≥2 种取值;**A 的每个取值都要有 ≥5 行**。
|
|
2038
|
+
这与 D-076/D-079/D-080/D-085 是同一条纪律的第四次应用:**任何"关系"都要先问"不成立时会是什么样"**。
|
|
2039
|
+
|
|
2040
|
+
**Consequences:** `docs/CONTACT_DB_SCHEMA.md` 的 §7(新增"blob 内部两两扫描"块:真结果三条 + 三类平凡形态 + 两条判据要求)。
|
|
2041
|
+
|
|
2042
|
+
## D-086: `#27` 的 `#2` 是 URL(141 行、3 个 host);"长度种数"别读成"取值数";到形状为止
|
|
2043
|
+
|
|
2044
|
+
**Status:** Active(补 D-085;含一处**读数更正**与一条**止损**)
|
|
2045
|
+
|
|
2046
|
+
1. **形状**:`#27.#2` 只在 **141/940** 行有值,**141/141 是 `http://` 的 URL**(长度众数 107~110 字节、
|
|
2047
|
+
**只有 3 个 host**),承载行**全不在 `biz_info` 里**(`local_type` 1 占 97、0 占 44)。
|
|
2048
|
+
这 3 个 host 在 `contact` / `biz_info` / `chat_room_info_detail` 的**任何列里都不出现**
|
|
2049
|
+
(对照:`big_head_url` 3914 行非空、`brand_icon_url` 691 行,命中 **0**)⇒ **不是本机已知的任何 CDN**;
|
|
2050
|
+
与同行列也不同(0 行含该行 `encrypt_username`,仅 4 行含昵称/拼音)。**含义 UNKNOWN。**
|
|
2051
|
+
2. **读数更正(我自己的)**:先前那句"`#2` 只有 14 种"是**长度的种数**,不是**取值数** ——
|
|
2052
|
+
`min/max/种数` 这一栏本来就是长度口径,我把它读成了取值口径。**报"种数"时必须写清是"什么"的种数。**
|
|
2053
|
+
3. **止损(这条也是决定)**:URL 这条线**到形状为止**。再往下解就要碰 URL 的路径内容,
|
|
2054
|
+
而它既不解锁别的字段、又涉及本机账号的主页地址,**收益与隐私都不划算** ⇒ 记 UNKNOWN,不挖。
|
|
2055
|
+
|
|
2056
|
+
**Consequences:** `docs/CONTACT_DB_SCHEMA.md` 的 §7(`#27` 那行补 `#2` 的口径 + 新增"那个 URL 单独记一笔")。
|
|
2057
|
+
|
|
2058
|
+
## D-085: "走线能走通"不是"它是 message"的判据;据此更正两处旧写法
|
|
2059
|
+
|
|
2060
|
+
**Status:** Active(更正 §3 与 A3 报告里两处;补 D-068)
|
|
2061
|
+
|
|
2062
|
+
1. **一条判据更正**:把所有 ld 字段再走一层之后发现,**"能走通"的行数**并不能说明它是 message ——
|
|
2063
|
+
文本字段 `#4` / `#5` / `#9` 也各有 **294 / 252 / 393** 行"走通"(随机字节撞成合法 tag)。
|
|
2064
|
+
**正确判据是"形状在同一批行里全量重复"**:`#14` **830/830** 一致(`{#1: 0/1, #2: 13 字节}`)、
|
|
2065
|
+
`#27` **937/937** 一致(5 个字段)⇒ 这两个是 message;而 `#6`(555/940)、`#7`(473/940)、`#26`(825/940)
|
|
2066
|
+
**达不到全量** ⇒ 不算(至少不能当"形状固定的 message")。
|
|
2067
|
+
⇒ 据此**更正** §7 那张形状目录里的一行("`#4 #5 #6 #7 #14 #15` 可解析成嵌套消息" —— 它是**照 `stranger` 的 1 行**
|
|
2068
|
+
记下来的,套到 `contact` 的 940 行不成立),以及 A3 报告里同源的表述。
|
|
2069
|
+
2. **顺带拿到的形状**:`#14` = `{#1: 0/1, #2: 13 字节}`;`#27` = `{#1: 0/1, #2: 84~141 字节长文本(仅 14 种),
|
|
2070
|
+
#3: 142 种取值, #4: 11 种, #5: 掩码 {0,72,720,4320,-1}}`(`72/720/4320` = 72×1/×10/×60,形似**时长**,
|
|
2071
|
+
**无对照源 ⇒ 不命名**);`#33` / `#36` / `#15` / `#20` / `#21` / `#28`(以及多数的 `#26`)
|
|
2072
|
+
**是空壳标记** —— 它们"存在"本身就是信息(例如 `#33` ⟺ `#36` ⟺ `#27.#3` 那组同现)。
|
|
2073
|
+
3. **这条纪律与 D-076/D-079/D-080 同族**:**任何"看起来像 X"的判据,都要先问"不成立时会是什么样"** ——
|
|
2074
|
+
文本撞 tag 就是"走通"的假阳性版本。
|
|
2075
|
+
|
|
2076
|
+
**Consequences:** `docs/CONTACT_DB_SCHEMA.md` 的 §7(形状目录里"可解析成嵌套消息"那行加删除线 + 更正)、
|
|
2077
|
+
§7(新增 ld 内层形状表 + 判据更正 + `#27.#5` 的"不命名"说明)、§9(新增复跑 22:**形状全量重复**才是判据)。
|
|
2078
|
+
|
|
2079
|
+
## D-084: `#41` = 该行资料的"最近更新时间";`#43` 不是 id 而是个小结构;行属性那条线是负的
|
|
2080
|
+
|
|
2081
|
+
**Status:** Active(补 D-082 / D-083)
|
|
2082
|
+
|
|
2083
|
+
1. **第三个定性的字段**:`#41` = **该行资料的最近更新时间(epoch 秒)**。证据三条:
|
|
2084
|
+
① epoch 秒、691 行出现(127 行显式 0)、非零 564 行、样本 2019–2026 且 70% 在 2026;
|
|
2085
|
+
② **与 `contact.id` 的 Spearman = 0.945**(n=564),且 564 个值**互不重复**;
|
|
2086
|
+
③ **"创建时间"被反例否掉** —— **40 行是"`id < 1500` 却带 2025/2026 时间戳"**(创建时间不可能这样)。
|
|
2087
|
+
⇒ 读成"被更新过";多数行没改过,所以看起来≈创建时间(这正是 ② 高而不到 1 的原因)。
|
|
2088
|
+
口径写明:这是**本地推断**(22 列里没有任何时间列能对应),不是厂商命名。
|
|
2089
|
+
2. **`#43` 的旧描述("22 字节的 id 样值")是错的**:内层解开是
|
|
2090
|
+
`{#1 ∈ {0,1}, #2 = {1:1, 2:∈{1,3,4}, 3:3, 4: repeated {1:10/11/12}}}` —— **只有 10 种变体**、
|
|
2091
|
+
最大一种占 448/628 ⇒ 更像"**一组能力/范围编号**",**不是不透明 id**。形态高置信,含义 UNKNOWN。
|
|
2092
|
+
3. **一条负结果(省后人一轮)**:把 12 个小 varint 字段逐个与**行级属性**(账号类别 / `local_type` / `flag` /
|
|
2093
|
+
`verify_flag≠0` / `is_in_chat_room` / `chat_room_type` / `chat_room_notify` / 有无 `encrypt_username`)
|
|
2094
|
+
做交叉,找"某属性在某取值下完全恒定"的干净关系 —— **一个都没有**(唯一"干净"的是 `#13`,那是同义反复)。
|
|
2095
|
+
⇒ **这批字段不挂在行的这些属性上**,别再沿这条线找名字。
|
|
2096
|
+
4. ⚠️ 我自己那轮扫描有个设计缺陷(如实记):把"是大 proto 行"这个**恒定属性**也放进了候选判别式,
|
|
2097
|
+
于是每个字段都"在 big 上完全确定" —— 输出里那一堆 `big=True` 全是噪声。**候选属性里不能有常量。**
|
|
2098
|
+
|
|
2099
|
+
**Consequences:** `docs/CONTACT_DB_SCHEMA.md` 的 §0(`contact.extra_buffer` 行补"`#41` 已定性")、
|
|
2100
|
+
§7(`#41` 那条改写并给出判据、`#43` 那条改写为"嵌套小结构"、新增"小字段不挂在行属性上"的负结果、
|
|
2101
|
+
候选名表两行更新)、§9(新增复跑 21:`#41` 的 Spearman + 反例计数)。
|
|
2102
|
+
|
|
2103
|
+
## D-083: `#10 = 0 ⟺ #11 = 0` 已验;`#19` 的"重复条数"假设被否
|
|
2104
|
+
|
|
2105
|
+
**Status:** Active(补 D-082;两条都属"把值域表继续做厚")
|
|
2106
|
+
|
|
2107
|
+
1. **上一条留的线头验了**:`#10 = 0` 与 `#11 = 0` **是同一批 129 行** —— 940 行里 `(F,F) 811 / (T,T) 129`,
|
|
2108
|
+
**两个错格都是 0** ⇒ 两者**共用一个"未设置"状态**(与 `#10`/`#38` 的 `0 / -1` 哨兵同一族写法),
|
|
2109
|
+
**不能当两个独立标志读**。
|
|
2110
|
+
2. **`#19`(0~10 的计数形态)的"= 某个子字段的重复条数"假设被否**:把 blob 里 10 个 ld 字段的子字段计数
|
|
2111
|
+
全量算出来逐个对,最好的候选(子字段 `#1` 的条数)也只对上 **104/940** ⇒ 排除掉一整类解释,仍 **UNKNOWN**。
|
|
2112
|
+
3. **记账方式**:§7 那张"取值域表"**本身就是资产** —— 每补一条(今天两条:一条命名、一条耦合)与
|
|
2113
|
+
**每排除一条**(`#19` 的两类),后人的搜索空间就小一块。别只在"解出来了"时动笔。
|
|
2114
|
+
|
|
2115
|
+
**Consequences:** `docs/CONTACT_DB_SCHEMA.md` 的 §7(`#11` 行补耦合、`#19` 行补排除项、"未验线头"改成"已验")。
|
|
2116
|
+
|
|
2117
|
+
## D-082: `#13` = `biz_info.type`(第二个被命名的字段);取值域也要量,别只量"有没有值"
|
|
2118
|
+
|
|
2119
|
+
**Status:** Active(补 D-073 / D-081)
|
|
2120
|
+
|
|
2121
|
+
1. **第二个命名的字段**:`contact.extra_buffer` 主体的 **`#13` = 该行 `biz_info.type`(账号服务类型)** ——
|
|
2122
|
+
**687/687 逐行相等、零反例**,五个取值(0/1/2/3/5)一个不差。它还**顺带解释了** §3 那条一直挂着的硬事实:
|
|
2123
|
+
`#13` 出现 ⟺ `verify_flag ≠ 0` ⟺ 在 `biz_info` 里 —— 因为 blob 里存的就是**服务类型的副本**,
|
|
2124
|
+
非 biz 行**根本没有这个值可写**(不是"没查到",是"不写")。
|
|
2125
|
+
2. **取值域这次是全量补的**(此前文档多处只写"值域未测",等于留白):`#2` 三值、`#11` 四值、
|
|
2126
|
+
`#12`/`#17`/`#18`/`#22`/`#24` 是布尔/极稀有的小枚举、**`#10` 与 `#38` 是 `0 / -1` 两态哨兵**
|
|
2127
|
+
(`4294967295` 就是 -1 的无符号写法)、`#8` 的 23 个取值**形态像位掩码**、
|
|
2128
|
+
**`#19` 是 0~10 的 11 个值、像某个"计数"**(但已排除"菜单按钮数")、`#37` 只用到 bit8/bit11、
|
|
2129
|
+
`#16`/`#23` 恒 0(显式序列化默认值)。⇒ **`#19` 是一个新的、形状明确的待解项**("像计数")。
|
|
2130
|
+
3. **方法(值得单记)**:**"字段值域"和"字段有没有值"是两件事**。前几轮一直在量"出现与否"(`#13`/`#14`/`#27`/`#36`
|
|
2131
|
+
都是这么解的),而这次把 varint 的**取值分布**一次性铺开,立刻捞出 `#13`(可命名)与 `#19`(形状清楚)。
|
|
2132
|
+
⇒ 遇到"结构清楚、名字不知道"的字段,**先把取值域量出来再谈命名**。
|
|
2133
|
+
4. 留一个**未验**的线头(写在文档里,不当作结论):`#10 = 0` 与 `#11 = 0` 的行数**都是 129**,疑似同一批行。
|
|
2134
|
+
|
|
2135
|
+
**Consequences:** `docs/CONTACT_DB_SCHEMA.md` 的 §0(`contact.extra_buffer` 行补"`#13` 已命名")、
|
|
2136
|
+
§7(新增整张 varint 取值域表 + `#13` 的解释 + 未验线头)、§9(新增复跑 20:`#13` 的逐行比对,
|
|
2137
|
+
并强调"相等"要双向看)。
|
|
2138
|
+
|
|
2139
|
+
## D-081: 定名的第三条路 = 公开码表 + 一条不依赖该码表的预测;`#5` = 国家/地区代码
|
|
2140
|
+
|
|
2141
|
+
**Status:** Active(补 D-068 / D-080;这是 37 个字段里**第一个被命名**的)
|
|
2142
|
+
|
|
2143
|
+
1. **结论**:`contact.extra_buffer` 主体(`ContactExtData`)的 **`#5` = 国家/地区代码(ISO 3166-1 alpha-2)**。
|
|
2144
|
+
形态是"恰好 2 个大写 ASCII 字母"、717 行有值(另 223 行显式空串)、25 个去重值。三条证据:
|
|
2145
|
+
① **25/25 全部落在 ISO 3166-1 的 249 个 alpha-2 码里** —— 随机 2 大写字母的命中率是 249/676 ≈ 0.37,
|
|
2146
|
+
全中概率 **≈ 1.4e-11**;② **众数就是 `CN`、占 94.0%** —— 这是对 ① 的**独立**确认;
|
|
2147
|
+
③ 按 **ISO 639-1 语言码**比只有 **15/25**(语言码还是小写)⇒ 是**地区**不是**语言**。
|
|
2148
|
+
2. **方法(这条值得单独记)**:**用外部码表当判据时,必须再给一条"不依赖该码表"的预测**。
|
|
2149
|
+
因为码表是**我硬编码**的(249 条,与官方数量一致,但可能有个别错),只靠它得到的"25/25 命中"会把
|
|
2150
|
+
**裁判自身的错**算成结论。②(众数应为 `CN`)就是为了挡这一层:它只依赖"本机账号多在中国"这个**本机事实**。
|
|
2151
|
+
⇒ 至此,blob 字段"能定名"的路有三条:**(a) 跨表整值 join**(那 44 行的 `#1`/`#2`)、
|
|
2152
|
+
**(b) 自/他对照的内容刻画**(`#4`/`#9`)、**(c) 公开码表 membership + 不依赖码表的预测**(`#5`)。
|
|
2153
|
+
3. **一个必须点名的陷阱**:`#5` 这个字段号在两张表里是**两回事** ——
|
|
2154
|
+
`chat_room.ext_buffer` 的顶层 `#5` 是"成员 status 位 11 的投影"(D-070),
|
|
2155
|
+
`contact.extra_buffer` 的 `#5` 才是地区码。**报"#5"时必须先说清是哪张表。**
|
|
2156
|
+
4. **代码这边不用动**:D-068 的"只解已验证的那部分"针对的是 `chat_room` 那条链(`contact-schema`),
|
|
2157
|
+
而**没有任何代码读 `contact.extra_buffer` 的字段** ⇒ 这次命名只进文档,不进实现。
|
|
2158
|
+
|
|
2159
|
+
**Consequences:** `docs/CONTACT_DB_SCHEMA.md` 的 §0(`contact.extra_buffer` 行补"`#5` 已命名")、
|
|
2160
|
+
§7(`#5` 那条从"❓ UNKNOWN"改成带三条证据的结论;候选名表里那行标注为"已是结论")、
|
|
2161
|
+
§9(新增复跑 19:**码表 + 不依赖码表的预测**)。
|
|
2162
|
+
|
|
2163
|
+
## D-080: `#4`/`#9` 是"该账号自身资料文本的汇集";短值/子串命中必须配对照
|
|
2164
|
+
|
|
2165
|
+
**Status:** Active(补 D-068 / D-076 / D-079;这是"同对象"纪律第一次**产出正面结果**)
|
|
2166
|
+
|
|
2167
|
+
1. **正面结果**:把 `biz_info` 的 `external_info` + `brand_info` 递归展成叶子(16,294 条去重叶子),
|
|
2168
|
+
再看大 proto 的字段里含不含**同一行自己的**叶子。**本行 336 vs 换成下一行 7(48×)** ⇒ 不是撞车。
|
|
2169
|
+
分工:**`#9` ↔ 主体名/认证描述**(`RegisterSource.RegisterBody` 147、`VerifySource.Description` 67)、
|
|
2170
|
+
**`#4` ↔ 菜单按钮名**(`MMBizMenu.button_list[].name` 31 + 子按钮名 21),两者都还含商标名/认证人名。
|
|
2171
|
+
⇒ **`#4` / `#9` 定性为"该账号自身资料文本的汇集"**(像检索用文本 / 资料摘要)。**这不是名字**,
|
|
2172
|
+
具体的组织形式(拼接 / JSON / 键值堆)**未测**,边界写清楚:包含 ≠ 相等。
|
|
2173
|
+
2. **方法(三次误报换来的)**:**子串命中必须配对照**。"本行 vs 换一行"这一个对照就能把撞车筛掉 ——
|
|
2174
|
+
本机 `#5`(2 字节)那 22 条"含菜单值"在对照下**全部消失**;只有 ≥8 字节的叶子才立得住。
|
|
2175
|
+
这与 D-079 的"同对象判据"(防**假阳性**)和 D-077 的"地址口径"(防**假阴性**)是同一族的纪律:
|
|
2176
|
+
**任何"命中"都要能说出一个"不该命中却可能命中"的对照情形**。
|
|
2177
|
+
3. 顺带把 `#9` 的"半命名"数字收紧:整值相等 **214** 行(148 归 `RegisterBody`、67 归 `Description`),
|
|
2178
|
+
而 `#4` 整值相等只有 **1** —— 这个不对称说明 `#9` 确实承载主体名,但也再次说明**它只是"有时"**。
|
|
2179
|
+
|
|
2180
|
+
**Consequences:** `docs/CONTACT_DB_SCHEMA.md` 的 §0(`contact.extra_buffer` 行补"`#4`/`#9` 已定性")、
|
|
2181
|
+
§7(`#9` 那条改写为"资料文本汇集"+ 自/他对照 + `#5` 的撞车教训)、§9(新增复跑 18:**必用自/他对照 + 长度门槛**)。
|
|
2182
|
+
|
|
2183
|
+
## D-079: `#3`/`#4` 被三条独立否证围死;而"搜第二份"必须用"同对象"严判据
|
|
2184
|
+
|
|
2185
|
+
**Status:** Active(补 D-071 / D-078;含一条**假阳性**教训)
|
|
2186
|
+
|
|
2187
|
+
1. **收口**:`#3`/`#4` 这对每房计数器现在有**三条独立否证**,方向各不相同 ——
|
|
2188
|
+
① **不是服务端 seq**(D-071:42/42 房的值低于本房最小非零 `server_seq`,差 8.7e8;
|
|
2189
|
+
而且 `server_seq ∈ [1e4, 8e8)` 在全库 84,279 行里**一条都没有**);② **不是成员变更事件**(D-078);
|
|
2190
|
+
③ **本地没有第二份**(本轮:24 个库、207,156 行按"同对象"判据搜 `#3` 与其 offset,**0 条有意义命中**)。
|
|
2191
|
+
⇒ "一步是什么"仍是 **UNKNOWN**,但现在**本地已无路可走**,只剩**机外对照源**(另一台机器 / 另一个版本 / 一个更早的库)。
|
|
2192
|
+
2. **本轮的方法规则(会反复用到)**:判"某个值在别处有没有第二份",**必须限定"同一对象"** ——
|
|
2193
|
+
先找该行的**键列**(`username`/`room_id`/`chat_name`)命中目标对象,**再看这一行的其它列**。
|
|
2194
|
+
否则会得到"命中一堆、全是撞车"。本机实例:第一版我拿"候选整数 `IN (...)`"全库扫 → **279 条命中**,
|
|
2195
|
+
逐条看**全部是小整数撞车**(0/1/2/10 这些值到处都是,而 `#3 = 0` 的房本来就在贡献 0 这个候选);
|
|
2196
|
+
加上"同对象"这一层筛子后剩 46 条,其中**有意义的仍是 0 条**。
|
|
2197
|
+
⇒ **报"命中"之前先问"撞车的概率有多大"**(这是 D-076 那条"报命中率要问没命中的是什么"的**镜像版**)。
|
|
2198
|
+
3. **报"0 命中"要配三样东西**(这次的格式固定下来):**正对照**(同一次扫描里有没有本该命中的东西命中了 ——
|
|
2199
|
+
本机是 `#3 = 0` 的房命中 0 列、`session.last_msg_type = 10000` 命中整数列)、**跳过了什么**
|
|
2200
|
+
(本机跳过 735 张 `Msg_*` 表 + 用哈希作键的表)、**范围有多大**(24 个库 / 20.7 万行)。
|
|
2201
|
+
4. **一个巧合,写下来防止被当成对应关系**:`session.SessionTable.last_msg_type == 10000` 与**底数 10000** 相同,
|
|
2202
|
+
而 `10000` **同时也是成员变更系统消息的类型码**(D-078)。三个无关的 10000。
|
|
2203
|
+
|
|
2204
|
+
**Consequences:** `docs/CONTACT_DB_SCHEMA.md` 的 §0(`#3`/`#4` 那行改为"三条独立否证")、§6.1(新增 ⑩:范围、
|
|
2205
|
+
0 命中、46 条撞车、正对照、范围限制、与 ⑦⑨ 合并成三条否证)、§9(新增复跑 17:**必须用同对象判据**、
|
|
2206
|
+
别用 `IN` 全库扫)。
|
|
2207
|
+
|
|
2208
|
+
## D-078: `#3` 的"一步"不是成员变更事件;而"需要第二时点"这个措辞本身太窄了
|
|
2209
|
+
|
|
2210
|
+
**Status:** Active(补 D-071 / D-072;含一处**自我收窄**)
|
|
2211
|
+
|
|
2212
|
+
1. **一件事之前被记错了性质**:`#3`/`#4` 的"一步是什么"一直在文档里挂着"**需要第二时点**"。
|
|
2213
|
+
但**库里自己就有一份带时间戳的事件日志** —— 群消息里的成员变更通知(基础 `local_type == 10000`,全库 1,764 条)。
|
|
2214
|
+
⇒ 少了的那步不是"再拿一个快照",而是"**先问库里有没有自带事件序列**"。凡"要外部对照源"的结论,**都该先过一遍这道筛**。
|
|
2215
|
+
2. **这条路的结论是"否",而且是硬否**(三种候选读法全部否掉,n = 71):`off = #3 − 底数` 与
|
|
2216
|
+
**事件条数**(秩相关 0.168、差 0 的房 3/71)、**加入人数**(0.143、4/71)、**加入−退出**(0.144、4/71)都对不上;
|
|
2217
|
+
只有与**当前成员数**(0.911,既有结论)对得上。
|
|
2218
|
+
3. **关键是我复核过的那个反例**:5 个 **2026-08 之后才建**的房,整段历史都在保留窗口内 ——
|
|
2219
|
+
其中 `chat_room.id = 3906`(2026-08-31 → 2026-09-28、36 人)**全部**类型 10000 的消息只有 **48** 条
|
|
2220
|
+
(子 Agent 按"成员变更"归类后 9 条),而 `off = 104`。**用最大的分母也不够** ⇒ "一步 = 进一个人 / 净变化"
|
|
2221
|
+
**不是被小样本或窗口截断蒙蔽的假否证**。差额跟**成员数**同向(0.897)、跟窗口天数只 0.363。
|
|
2222
|
+
4. **"净变化"那条读法其实从数据侧就不成立**:主动退群通知 `left` = **0 / 1,622**(微信不给普通成员发退群通知)、
|
|
2223
|
+
`removed` 全库 1 条 ⇒ "减"的那一半本来就看不见。**下"两边同权"的判据前,先确认两边的数据都存在。**
|
|
2224
|
+
5. **两处独立复核对上的**:`off = 0` 的房**恰好 7 个**(2024/2887/3348/3601/3668/3729/4025,与 ⑧ 的"成员表没动过"吻合),
|
|
2225
|
+
子 Agent 与我的两套数一致;`#3 = 0` 的 3 个房里 **2007 有一条事件(群主解散群聊)却仍为 0** ⇒ 未初始化的房不随事件走。
|
|
2226
|
+
6. **一处口径差如实记下**:子 Agent 的每房"事件条数"是**按成员变更归类后**的子集,我报的是**全类型 10000** 的条数
|
|
2227
|
+
(全库一致:1,764 = 它的 1,622 + 对不上房间的 142)。两套都对 —— 写进文档是为了**别让后人以为两边矛盾**。
|
|
2228
|
+
|
|
2229
|
+
**Consequences:** `docs/CONTACT_DB_SCHEMA.md` 的 §0(那行从"需要第二时点"改写成"事件日志这条路已试并否 + 剩余唯一办法")、
|
|
2230
|
+
§6.1(新增 ⑨:三条否证、决定性反例、`left`=0、7 个 `off`=0 房、口径差的调和)、§9(新增复跑 16)。
|
|
2231
|
+
"一步"的语义仍是 **UNKNOWN**,但这次是**有否证的 UNKNOWN**。
|
|
2232
|
+
|
|
2233
|
+
## D-077: blob 字段名的"读二进制"这条路封口了;根因是一条地址口径;负结论必须先确认口径
|
|
2234
|
+
|
|
2235
|
+
**Status:** Active(补 D-068 / D-071 / D-076;这是一条**封路**的决定,目的是别让后人重跑)
|
|
2236
|
+
|
|
2237
|
+
2026-10-06 试了"用已知字段号的消息去标定客户端那份明文名字组,再给 `ContactExtData` 的 37 个字段对号"这条路。**断在第 1 步,而且是结构性的**(我的复核数与子 Agent 一致):
|
|
2238
|
+
|
|
2239
|
+
1. **没有"每个 message 一份字段名表"这种东西。** `micromsg.ContactExtData` / `ChatroomExtData` /
|
|
2240
|
+
`ChatroomMemberLocalData` / `WeclawExternalInfo` 的**全文件 4 字节引用数都是 0**(只有 `ChatroomDetailInfoExtData`
|
|
2241
|
+
有 2 处);类型名那一片只有同族类型名 + `local_proto\local_contact.pb.cc` 编译路径;类型名后的 `(VA,1)` 指针表
|
|
2242
|
+
指向的要么是 96 字节密文块、要么是代码。⇒ **"类型名 → 字段表"这一层没有可静态恢复的形式。**
|
|
2243
|
+
2. **全文件穷举也没有我们要的组**:43,411 个"对齐 + NUL 结尾"名字槽 → 1,729 个像字段名的连续串,
|
|
2244
|
+
**没有 4 个成员字段的组、也没有 ~37 个 contact 字段名的组**。
|
|
2245
|
+
3. **硬负结果:连续挨着 ≠ 同一组**。`Announcement|InfoVersion|AnnouncementEditor|AnnouncementPublishTime|ChatRoomStatus`
|
|
2246
|
+
这 5 个名字挨在一起,但后两者**全文件引用各为 0** ⇒ 组的边界由描述符记录的槽决定,**不能按相邻性划**。
|
|
2247
|
+
4. **地址口径(本轮最有复用价值的一条)**:本版 `Weixin.dll` 是 **64 位**(imagebase `0x180000000`)、
|
|
2248
|
+
**每节 `VA ≠ PRAW`**,而**描述符表里存的是"文件偏移"**:按文件偏移搜 4 字节能命中(`InfoVersion` 4 处),
|
|
2249
|
+
按**真 RVA 或 VA 搜全是 0**。⇒ 文档 2026-10-04 那次"全文件搜这些名字的指针引用都是 0"**就是这条口径错造成的假阴性**,
|
|
2250
|
+
当时的解释("file-offset ↔ RVA 错位")方向对但没说到点上;现在说法固定为:**表存文件偏移,用 RVA 搜必得 0**。
|
|
2251
|
+
**规则**:任何"0 命中/不被引用"的负结论,**先确认自己用的是哪套地址口径**,并跑一条**正对照**
|
|
2252
|
+
(同一个查询换一个已知该命中的对象 —— 本机就是 `InfoVersion` 的 4 处)。
|
|
2253
|
+
5. **半条正面结果(但不足以支撑命名)**:客户端里那种 snake_case 名字数组**在 SQL 列这一层是保序的**
|
|
2254
|
+
(`chat_room_info_detail` **8/8**、`contact` 连续 **18/18** 与 `PRAGMA table_info` 逐项相等)——
|
|
2255
|
+
但 `biz_info` 的数组与列序**不匹配**(`sync_version` 在表里是最后一列、在数组里排第 2),
|
|
2256
|
+
同一片 `.rdata` 里"保序列名数组"和"不保序的别的数组"外形无法区分 ⇒ **不许**拿"名字在第几位"去推 proto 字段号。
|
|
2257
|
+
**D-071 的撤回维持**(唯一能测的 proto 组只有"缺项消隙"一种读法能复现两个锚点,却要求一个不可信的巧合)。
|
|
2258
|
+
6. **交付里有三个候选名(`#41`→`UpdateTime`、`#43`→共享 id、`#5`→地区/语言码),全部标注"纯推测"并各带证伪办法,
|
|
2259
|
+
不进代码。** `#2`…`#38` 继续只报字段号 —— 与 D-068 一致。
|
|
2260
|
+
7. **一处未消的 1 之差**:`AnnouncementPublishTime` 的引用数子 Agent 报 1、我量 0(其余名字两边一致)。
|
|
2261
|
+
不影响结论(1 与 0 都远低于"被正常引用"的量级),但要如实留在文档里,别让它假装一致。
|
|
2262
|
+
|
|
2263
|
+
**Consequences:** `docs/CONTACT_DB_SCHEMA.md` 的 §0("名字"那行改写为三条路封口 + 唯一可用机制是跨表 join)、
|
|
2264
|
+
§3.3(地址口径写进正文;"4 字节 RVA"改称文件偏移;新增"封口"四条)、§7(新增三个标注"纯推测"的候选名 + 证伪办法)、
|
|
2265
|
+
§9(新增复跑 15:二进制搜索**必须先确认地址口径**)。
|
|
2266
|
+
|
|
2267
|
+
## D-076: `verify_flag` 解出来了(它根本不是"好友验证");`#9` 的命名要降级;两处"看着对其实错"的脚本坑
|
|
2268
|
+
|
|
2269
|
+
**Status:** Active(补 D-073 / D-075;含一次"对子 Agent 结论的降级")
|
|
2270
|
+
|
|
2271
|
+
2026-10-06 同一轮里另外三条,**每条都经我独立复算**:
|
|
2272
|
+
|
|
2273
|
+
1. **`contact.verify_flag` 解出来了 —— 它跟"加好友/待验证"没关系。** 它是**位掩码**、9 种取值
|
|
2274
|
+
(此前清单漏了 3 个单例 `56/264/1053`)。逐位(全部我的数):
|
|
2275
|
+
- **`bit3`(值 8)⟺ 该行 `username` 在 `biz_info` 里** —— **687/687 双向、两个错格都是 0**;
|
|
2276
|
+
而且所有非零值都带 bit3 ⇒ "8" 就是"非零"本身。
|
|
2277
|
+
- **`bit4`(16)⟺ 有 `VerifySource`**(机构认证):265/265,反向 2 例(0.75%);
|
|
2278
|
+
**`bit8`(256)⟺ 有 `PersonVerifyInfo.VerifyDesc`**:12/12;**`bit9`(512)⟹ 有 `PersonVerifyInfo`**:65/65,反向 7 例;
|
|
2279
|
+
**`bit4 ⊥ bit9`(0 行)** ⇒ 机构认证与个人认证互斥。
|
|
2280
|
+
- **一条副产品**:`contact.extra_buffer` 出现 `#13` ⟺ `verify_flag ≠ 0` ⟺ 在 `biz_info` 里 —— **三条是同一批 687 行的三面**,
|
|
2281
|
+
且 `verify_flag ≠ 0` 的行**没有一个是非大 proto**。此前那条"`verify_flag != 0` ⟹ 不在任何群"由此有了原因
|
|
2282
|
+
(都是 biz/系统号)。⇒ **"位 = 好友关系"整类读法判死**(能证伪的计数:`≠0 且不在 biz_info = 0`、
|
|
2283
|
+
`≠0 且有 remark = 0`、`≠0 且是群成员 = 0`、2946 个 `wxid_` 里 2941 个为 0、`room_verify_application` 是空表)。
|
|
2284
|
+
2. **`chat_room_info_detail.ext_buffer_` 的 `#1` 翻案:不是"一坨二进制",是两层嵌套 message**
|
|
2285
|
+
(`DetailList → Entry`,`Entry` 字段号 {1..7} 8/8)。72/77 空、5 行非空;`Entry.#2` 是成员 `username`
|
|
2286
|
+
(8/8 命中 `contact`,其中 **7 个正是该房 `owner`**)、`#5` 是合法 UTF-8 自由文本。⇒ 文档里"少数是一大块二进制"作废。
|
|
2287
|
+
顺带把三张 `openim_*` 的 `ext_buffer` 分清:前两张是「`#1` + 若干 `{key, value}` 对」,
|
|
2288
|
+
`acct_type.ext_buffer#1` **逐字节 == 该行 `acc_type_id`**,而 **`openim_wording.ext_buffer` 21/21 全 0 字节**
|
|
2289
|
+
("3~4 个字段"那格把三张表并一起了,错)。
|
|
2290
|
+
3. **对子 Agent 的"已命名"结论做降级(这条是流程要求,不是结论)。** 一个 Agent 报「`#9` 已命名 = 主体名」
|
|
2291
|
+
(213/567 逐字节等于该行 `RegisterSource.RegisterBody`)。复算后:这 213 是**真的**、且 `#4` 只有 **1/673**(不对称 ⇒ 非撞车),
|
|
2292
|
+
但另有 **21 行只是把主体名当子串含住**、**333 行与主体名无关** ⇒ 合计 **234/567 = 41%**。
|
|
2293
|
+
⇒ 只能写"**`#9` 有时含认证主体名**",**不能写"`#9` = 主体名"**(58% 的行会给出别的东西)。
|
|
2294
|
+
**规则**:子 Agent 报"命中率 p"时,母 Agent 要问 p 的另一面 —— **不命中的那些是什么**;
|
|
2295
|
+
只有"另一个字段几乎不命中"这种**不对称**才能支撑"命名",单看一个比例不能。
|
|
2296
|
+
4. **两条"看着对其实是错"的脚本坑(我这轮自己踩的两个 + 子 Agent 报的两个)**:
|
|
2297
|
+
- **bytes vs str 混比 → 假 0 命中**:blob 里取出的值是 `bytes`,从表里读回来的可能是 `str`,
|
|
2298
|
+
`set` 比较直接给 0 命中 —— 而"0 命中"看起来正好像一个**负结论**。我因此把一条已经验过的关系(`wording_id` 全覆盖)
|
|
2299
|
+
判成"0 命中",两者输出长得一样。**修法**:比之前统一成 `bytes`(`text_factory = bytes`),并给"0 命中"配一条**正对照**。
|
|
2300
|
+
- **同一个键被两个循环各加一次 → 恰好 2×**:`VerifyDescribe` 我量到 128、真实是 64。**恰好两倍**是这类错的指纹。
|
|
2301
|
+
- 子 Agent 报的两条同族:`D:\tmp_eyetrack\numbers.py` **遮蔽标准库 `numbers`**(会让 `import statistics` 崩在 `_decimal`,
|
|
2302
|
+
且顶层 `print` 会混进别的分析的输出里);**宽 `except Exception` 包 `json.loads` 会吞掉 `NameError: json 未导入`**,
|
|
2303
|
+
把"代码没跑起来"打成"所有键都为空"的**业务事实**,而所有计数照样出得来。
|
|
2304
|
+
|
|
2305
|
+
**Consequences:** `docs/CONTACT_DB_SCHEMA.md` 的 §0(`contact` 列 / `detail #1` / `openim_*` 三行升级)、
|
|
2306
|
+
§1.1(`external_info` 其余键全量清点;`contact.description` 改为"**全表** 4014 行全空";`VerifyDescribe`/`VerifyIdentity`
|
|
2307
|
+
的 68 串位更正)、§3(那行加 `#9` 与 openim 形状)、§6(`verify_flag` 整块重写 + 三面合一)、§7(`#9` 半命名 + 子串门槛)、
|
|
2308
|
+
§9(新增复跑 13 / 14)同步。
|
|
2309
|
+
|
|
2310
|
+
## D-075: `contact.extra_buffer` 是「一列两型」;字段名不止二进制一条路;行为足迹打位域基本落空
|
|
2311
|
+
|
|
2312
|
+
**Status:** Active(补 D-068 / D-074;含两条口径更正)
|
|
2313
|
+
|
|
2314
|
+
2026-10-06 三个子 Agent 从互不重叠的方向推,**三条头条我都用独立脚本量过**(这是本条的流程要求):
|
|
2315
|
+
|
|
2316
|
+
1. **`contact.extra_buffer` 是「一列两型」,按 `local_type` 分。** 主体 940 行是 `ContactExtData` 骨架;
|
|
2317
|
+
另 **44 行是 OpenIM 专属**:`#1` = `openim_appid.app_id`、`#2` = `openim_wording.wording_id`
|
|
2318
|
+
(各 **43/44** 逐字节命中)、`#4.#2` 的 JSON 键**恒为 `custom_info`**(43/43);承载行 42/44 的 username
|
|
2319
|
+
以 `@openim` 结尾、`local_type ∈ {5,6}`,且**全库 `local_type ∈ {5,6}` 的 42 行恰好全在这族**(双向)。
|
|
2320
|
+
⇒ **更正**:文档里的"46 行小形状"按字段号看真值是 **44 行**(另 2 行是字段号 `#10`/`#40` 的大 proto 残行);
|
|
2321
|
+
并且 §3.1 把 `stranger.extra_buffer` 挂在 `OpenIMContactExtData` 上**挂错了**(它与 `contact` 主体同型 ⇒ `ContactExtData`),
|
|
2322
|
+
真正的 OpenIM blob 是那 44 行。
|
|
2323
|
+
2. **"跨表外键"是解 blob 字段名的一条独立于二进制的路。** 那 44 行的 `#1`/`#2` 之所以能定含义,靠的是
|
|
2324
|
+
**在别的表里逐字节命中**(`openim_appid` / `openim_wording`),不是读二进制、也不是猜。此前把
|
|
2325
|
+
"字段名拿不到"记成 ❌(D-068)时,**没有把"表间比对"这条算进去** —— 现在它有了一个成功案例。
|
|
2326
|
+
⇒ 以后遇到有值可查的 blob 字段,先做**跨表 join**,再谈"名字拿不到"。
|
|
2327
|
+
3. **行为足迹(该成员在这个群里发过言吗)打成员 `status`,基本落空。** 唯一稳的是 **bit13**:
|
|
2328
|
+
房内载体**系统性更沉默**(P(sent) **0.087 vs 0.208**、房内 MH OR **0.40**、19/20 房同向)——
|
|
2329
|
+
所以 D-074 里"bit13 成员级没找到"**作废**。同时被削的:**bit4 ↔ 有群昵称** raw OR 6.19 → **MH 2.05**
|
|
2330
|
+
(≈2/3 是"昵称房"混杂,且 **bit3 房内一样强**)、**"bit4 偏老成员"房内不成立**(0.353 vs 0.333, p=0.42)、
|
|
2331
|
+
**bit3 的"更活跃"是房间级**(房内符号 15/13/11、均值差 −0.002)、**G 的"更爱发言"全来自一个房**
|
|
2332
|
+
(10 个发过言的 G 载体 8 个在同一房)。⇒ **"某位 = 某种人"整类读法在本轮没有被任何一条支持住。**
|
|
2333
|
+
4. **两条"方法别踩"**:
|
|
2334
|
+
- **两个看似独立的代理可能同一个**:`chatroom_member.rowid` 分位与 blob 成员位置分位 **76/77 房完全同序**
|
|
2335
|
+
⇒ 此前把它们并列成两条证据是**重复计数**。
|
|
2336
|
+
- **小样本的取值窗口会骗人**:`#41` 的年份,一个 Agent 从部分样本得"2021–2024",我量完整分布是
|
|
2337
|
+
**2019–2026、70% 在 2026**(691 行里还有 127 行显式写 0)⇒ 结论从"老数据"翻成"最近更新时间"。
|
|
2338
|
+
凡"取值范围"类断言,**先报分母与全量分布**。
|
|
2339
|
+
5. **一处不裁决**:`bit0` 的性质("该房的行状态" vs "人的属性 93% 一致率")在上一轮报告里**自相矛盾**,
|
|
2340
|
+
本轮的行为足迹**帮不上**(缺位者 74/100 在无消息表的房里 ⇒ 量不到)⇒ **如实记 UNKNOWN,不硬裁**。
|
|
2341
|
+
|
|
2342
|
+
**Consequences:** `docs/CONTACT_DB_SCHEMA.md` 的 §0(状态表:`contact.extra_buffer` 升级为"一列两型"、
|
|
2343
|
+
`openim_appid`/`openim_wording` 升 ◐、`openim_acct_type` 的 `ext_buffer` 仍 ❓)、§3(那行重写)、
|
|
2344
|
+
§3.1(`stranger` 的类型名更正)、§6(bit13 补脚印 + 三条削档)、§7(新增"逐字段取值轮廓"块)同步。
|
|
2345
|
+
`#3`/`#4` 仍只报字段号。
|
|
2346
|
+
|
|
2347
|
+
## D-074: `status` 是"字段"不是"一堆标志";`contact.extra_buffer` 主体是 37 字段 proto(含两处数字更正与一条"更正之更正")
|
|
2348
|
+
|
|
2349
|
+
**Status:** Active(补 D-068 / D-073;本轮的价值一半在"错了什么、数从哪来")
|
|
2350
|
+
|
|
2351
|
+
2026-10-05 又一轮多 Agent + 复核,五件事:
|
|
2352
|
+
|
|
2353
|
+
1. **成员 `status` 的取值语法 = 5 个不重叠区间**:`status = bit0 | A<<3 | bit11 | bit13 | G<<20`。
|
|
2354
|
+
**A(bits3–4)是 2-bit 字段**(bit3&bit4 同现 **46**、独立期望 19.7 ⇒ 2.3×,不是两个 flag),最强倾向是
|
|
2355
|
+
"有群昵称"(A=0→3 单调 0.26→0.78),**但 31% 载体没昵称、逐房集合相等 0/33 ⇒ 只到"强倾向",不是定义**。
|
|
2356
|
+
**G(bits20–22)是"有强制位的 3-bit 组"**:取值只出现 {2,3,6,7} ⇒ **G≠0 时 bit21 恒置位**
|
|
2357
|
+
(bit20&bit22 同现 **18**、独立期望 0.12 ⇒ ~150×)。**两条硬排斥我逐位复核过(各 0/4151)**:
|
|
2358
|
+
`bit4 ⊥ bit13`、`bit11 ⊥ {13,20,21,22}`。⚠️ `bit4 ⊥ bit13` **不是**"同一字段的两个取值"——
|
|
2359
|
+
两者外延**相反**(bit4 ↔ 多有昵称、偏老房;bit13 ↔ 少昵称、偏 openim / 大房)。
|
|
2360
|
+
⚠️ 并更正一处错值:本节此前写 bits20–22"取值范围只有 `1/3/5/7`",那是**当独立标志读出的错值**
|
|
2361
|
+
(`1` = 只 bit20、`5` = bit20+bit22 本机**不存在**)。
|
|
2362
|
+
2. **`contact.extra_buffer` 的形状被记歪了,已更正**:初版写"`#3`(varint),2 字节上下"——那是**46 行小形状**的样子;
|
|
2363
|
+
**主体 940 行是一张 37 字段(`#2`…`#38`)、长度中位约 115 字节的 proto**。旧的两个判据现在对得上:
|
|
2364
|
+
**984 行有顶层 `#3`**,其中 **942 行是 varint**(940 大 + 2 小)、42 行 ld、2 行无 ⇒ 旧文的"942 / 44"**就是**
|
|
2365
|
+
「`#3` 是不是 varint」,与形状切法(940 / 46)差的正是那 2 行小形状。**别再把小形状当整列的样子。**
|
|
2366
|
+
3. **"骨架同型"是这轮的真收获**:`contact.extra_buffer` 主体那 940 行与 `stranger.extra_buffer`(本机仅 1 行)
|
|
2367
|
+
**逐字段号 + 逐线格式完全一致**(我复核)⇒ 两者是**同一张 proto**(contact 主体另多 `#41` / `#43`),
|
|
2368
|
+
于是 §7 那张"只有 1 行样本"的形状目录**可以依 940 行给取值范围**。顺带两条新事实:`#41` 是 **epoch 秒**
|
|
2369
|
+
(691 行、样本 2021–2024,而 22 列里**没有任何时间列**)、`#43` 是跨行高度重复的 22 字节 id 样值(628 行、仅 11 种)。
|
|
2370
|
+
4. **"值对齐"给不出名字(判负,独立复核)**:对**全部 986 行**、每个字段、6 种编码(raw/utf8/md5/sha1/b64/utf-16le)
|
|
2371
|
+
与 22 列逐一比,**只 2 处稀疏命中**(`nick_name` == `#4` 8/716、== `#9` 12/953),哈希 / URL / wxid 级 **0 命中**
|
|
2372
|
+
⇒ 判为**偶发撞车,不据以命名**。⇒ blob 的字段名**本机 + 公开渠道两头仍拿不到**(D-068 边界不变)。
|
|
2373
|
+
5. **一条"更正之更正"**:初版 §1.1 写"33 个号**有消息表**却不在 `biz_info`",我先前复核成"两处都复现不出"——
|
|
2374
|
+
现在定出**那个 33 是真数,但来源不同**:它是**订阅号库自己的 `Name2Id`(573 行)里 33 个 `gh_` 不在 `biz_info`**,
|
|
2375
|
+
而这 33 个**没有消息表、也不在 contact-db 的 `name2id`** ⇒ 准确说法是"**被订阅号库登记过名字、但既无会话也无消息**"。
|
|
2376
|
+
⇒ **"复现不出"要先分清"数不存在"还是"我把条件记错了"**(这次是后者 —— 别急着把数一起撤掉)。
|
|
2377
|
+
同类还把缺口 B(10 个 `gh_` 在 biz / name2id 而 `contact` 里没有)的读法改了:**8/10 `sync_version` 为空、9/10 无消息表**
|
|
2378
|
+
⇒ 数据**更支持"只被登记过、从未真正关注"**,"取关残留"只是相容的另一读法,**本机判不了**。
|
|
2379
|
+
|
|
2380
|
+
**Consequences:** `docs/CONTACT_DB_SCHEMA.md` 的 §1.1(两个缺口)、§3(`contact.extra_buffer` 行重写、
|
|
2381
|
+
`stranger` 行 **38 → 37** 并注明同型)、§6(`status` 字段表 + 两条硬排斥 + 错值更正)、§7(取值目录改依 940 行)、
|
|
2382
|
+
§9(新增复跑 11 / 12)同步。`#3` / `#4` 仍只报字段号。
|
|
2383
|
+
|
|
2384
|
+
## D-073: 位域要逐位做、`biz_info` 已测绘、以及"恒/从不"类断言必须先核分母
|
|
2385
|
+
|
|
2386
|
+
**Status:** Active(补 D-068 的字段边界;含一条方法更正)
|
|
2387
|
+
|
|
2388
|
+
2026-10-05 继续把 `contact.db` 剩下的部分做完,三件事值得记:
|
|
2389
|
+
|
|
2390
|
+
1. **位域必须逐位做列联,别拿整值做。** 早前对 `contact.extra_buffer` 的 `#3` 是拿**整值**
|
|
2391
|
+
(0/3/9/1/515)去跟 21 列做列联表,于是写下"取值本身无从判定"——**那是方法错**:位域里两个位会互相抵消。
|
|
2392
|
+
逐位重做后立刻有了两条:`bit0 ⟺ 值 ≠ 0`(恒等)、**`bit1 = 1` 的 423 行全部是 `local_type = 1`
|
|
2393
|
+
且不在任何群里**(423/423 单向干净)。同一次也把房间级 `chat_room_status_` 的**位 19 解出来了**:
|
|
2394
|
+
**⟺「该房已初始化(`status ≠ 0`)且与 openim 不互通」77/77**(我独立复核过)——它此前被记成"只是众数"。
|
|
2395
|
+
⇒ 以后遇到 ❓ 的位域字段,**先逐位**,再谈"取值判不了"。
|
|
2396
|
+
2. **`biz_info`(公众号表)已首次测绘**,并明确它对日报线的增量与隐私边界(详见
|
|
2397
|
+
`docs/CONTACT_DB_SCHEMA.md` §1.1):代码**从来没读过**它,而里面有 `RegisterSource.RegisterBody`
|
|
2398
|
+
(主体名称,93.1%)、`VerifySource.*`(认证类型)、`ServiceType`、`brand_icon_url`(头像直链)等;
|
|
2399
|
+
同时 `sync_version` 列**声明 TEXT 实为二进制 blob**(`SELECT *` 会崩,要 `text_factory=bytes`),
|
|
2400
|
+
且 `PersonVerifyInfo.*` 含**认证人真名**、`ServicePhone`、经纬度 ⇒ **只取需要的字段,别整包带出**。
|
|
2401
|
+
3. **子 Agent 报的"全表恒 X / 从来没有 Y",母 Agent 必须先核分母。** 本轮一个子 Agent 报
|
|
2402
|
+
"`contact` 四列全表恒 0",直接查库即否(只有 `delete_flag` 真全 0)。根因**不是查错列**,
|
|
2403
|
+
而是**统计时把"成员子集"当成了全表** —— 这类断言的错法恰恰是分母被悄悄换掉,而它的下游结论看着毫无破绽。
|
|
2404
|
+
把它唤醒复核后,结论不变,还顺手多挖出一条真结论(`verify_flag != 0 ⟹ 不在任何群`)。
|
|
2405
|
+
⇒ 复核要它**把当时的查询原样贴出来**,并要求**在交付文件里如实记下这次更正**。
|
|
2406
|
+
|
|
2407
|
+
**Consequences:** `docs/CONTACT_DB_SCHEMA.md` 的 §1.1(`biz_info`)、§3(`contact.extra_buffer` 那行
|
|
2408
|
+
把空行数 **3072 更正为 3028**、并补上非空 986 的三态划分)、§6(逐位结论)同步;
|
|
2409
|
+
`#3`/`#4` 仍只报字段号(D-068 边界不变)。
|
|
2410
|
+
|
|
2411
|
+
## D-072: `#3`/`#4` 的边界到此为止 —— 把"已经搜过的范围"写下来,别重搜
|
|
2412
|
+
|
|
2413
|
+
**Status:** Active(补 D-068 / D-071 的边界;这条的价值在**范围**,不在结论)
|
|
2414
|
+
|
|
2415
|
+
2026-10-04 用多智能体并行把"还能从哪拿到答案"逐条走完,结论是**本机这一侧已经无路**。
|
|
2416
|
+
把范围记下来,免得后人(或下一轮的我)重搜一遍:
|
|
2417
|
+
|
|
2418
|
+
**语义(已收窄到能写进文档的程度)**:`#3 − 底数` 是"**每房、从底数起算、随该房成员表历史变更累计**"
|
|
2419
|
+
的计数器。三条硬否定(不是服务端 seq / 不是唯一序号 / 不是"曾经出现过的人数")+ 一条正面证据
|
|
2420
|
+
(**offset=0 的 7 个房,其 `chatroom_member` 恰好只有一段连续 rowid、段长 = 成员数、且无成员变动系统消息
|
|
2421
|
+
与公告** ⇒ 计数器初值=底数)。**分不开的**:每次 +1 对应"一次成员增删"还是"一次成员表更新"。
|
|
2422
|
+
|
|
2423
|
+
**已经搜过、别再搜的范围**:
|
|
2424
|
+
|
|
2425
|
+
- **二进制**:本机**四个版本目录**(`4.1.15.11` / `4.1.15.13` / `4.1.12.26` / `4.1.13.12`;其中
|
|
2426
|
+
`4.1.15.11\` 2026-10-11 实测**只剩 1 个文件**、不是能跑的版本 —— 见 `CONTACT_DB_SCHEMA.md` 那节)
|
|
2427
|
+
加其余腾讯系程序文件,共 **84 个二进制** —— 没有任何 `RoomData`/`RoomDataUser` 字段名组;
|
|
2428
|
+
三版 `Weixin.dll` 的明文名字组一字不差,我们那三个类型的注册块逐字相同。企业微信主程序已卸载。
|
|
2429
|
+
- **公开圈**:拿类型名(`ChatroomExtData` / `ChatroomMemberLocalData` / `win_local_define`)做代码搜索
|
|
2430
|
+
—— 只命中本仓库或 0 命中。
|
|
2431
|
+
- **数据**:全机只有一个真库;`Backup` / `msg\migrate` / `business\migrate` / `temp` / `config` 无副本或导出;
|
|
2432
|
+
企业微信只剩数据(自定义加密、无 `chat_room`/`ext_buffer`);D 盘上一个名字像"WeChat 数据分析"的旧目录
|
|
2433
|
+
只是源码仓库,不含任何真实库。
|
|
2434
|
+
- **时间维度**:`contact.db-wal` 的帧按边界截断(拿不到 `chat_room` 的改动)、`.material` 不是页快照、
|
|
2435
|
+
工作副本只差 35 分钟且窗口内无成员变更。
|
|
2436
|
+
|
|
2437
|
+
**要再往前只有一条路**:换一个**参照实例**(另一台机器的库 / 另一个微信版本,理想是 V3 时代、
|
|
2438
|
+
或者能拿到同一房间两个时点的快照)。在那之前,`#3`/`#4` **继续只报字段号、不编名字**(D-068 的边界不变)。
|
|
2439
|
+
|
|
2440
|
+
**一条方法教训**:不要把 `name2id.rowid` 当"成员进群先后"的代理 —— 它是"此人进**我的联系人库**"的时刻,
|
|
2441
|
+
控制规模后的偏相关 ≈ 0,会得出"只跟当前人数走"的**错结论**;要用 `chatroom_member.rowid`。
|
|
2442
|
+
这条是本轮子 Agent 纠正我的,一并记下。
|
|
2443
|
+
|
|
2444
|
+
## D-071: `#3`/`#4` 是"本地每房计数器",不是服务端 seq、不是唯一序号;`InfoVersion` 候选撤回
|
|
2445
|
+
|
|
2446
|
+
**Status:** Active(收窄 D-069,并撤掉它留下的名字候选)
|
|
2447
|
+
|
|
2448
|
+
2026-10-04 用多智能体并行复核(三个方向各自独立、互为对照),把这对字段又钉了两步、撤掉一个候选:
|
|
2449
|
+
|
|
2450
|
+
1. **它不是服务端 seq(硬)** —— 三条互相印证的证据:42/42 个 `10000` 族房满足
|
|
2451
|
+
`#3 < 该房自己 MIN(非零 server_seq)`(差值中位 **8.72e8**);全库 **84,279 行**消息里
|
|
2452
|
+
`server_seq ∈ [1e4, 8e8)` 的有 **0 行**;2024~2026 年建的房仍是 `10000+x`,而这个账号的
|
|
2453
|
+
服务端 seq **2021-11 就已 8.41e8**(收藏侧 `update_seq`)。
|
|
2454
|
+
2. **它不是唯一序号(硬)** —— `#3` **精确等于 `10000` 的房有 7 个**(另有其它重号)。
|
|
2455
|
+
⇒ 与"**本地从常量 `10000` 起算、随本地事件递增**"一致;被证伪的是"服务端每房唯一版本号"。
|
|
2456
|
+
底数 `7×10^8` 也不是"某个计数器的历史读数"(收藏侧 seq 2021-11 已是 8.41e8,而公告 2024-05 的房
|
|
2457
|
+
底数仍是 `7×10^8`),更像**固定号段底**。
|
|
2458
|
+
3. **撤回 `InfoVersion` 候选。** 客户端里确实有一组明文名字含 `InfoVersion`,且同组的
|
|
2459
|
+
`AnnouncementPublishTime` 与我们**已独立验证**的 `#6` 吻合 —— 但两条独立证据否掉它:
|
|
2460
|
+
(a) **名字顺序 ≠ 字段号**(读成 1..5 会让 `AnnouncementPublishTime` 落在 `#4`,实际在 `#6`);
|
|
2461
|
+
(b) **行为对不上**(detail 的 `#3` 跟成员侧 ρ 0.889/0.773、跟公告侧 ≤0.55,控制成员后公告侧 ≈0;
|
|
2462
|
+
唯一"多一次变更"的对照——`#3 = #4+1` 的 5 个房——正好是成员 +1 的那 5 个房且**零公告**;
|
|
2463
|
+
另有 325 人房 `#3=9` 这种**双向反例**,说明它不是当前状态的函数而像事件累计计数)。
|
|
2464
|
+
|
|
2465
|
+
**Consequences:** 文档 §6.1 / §3.3 同步;`#3`/`#4` 仍然只报字段号、不编名字(D-068 的边界不变)。
|
|
2466
|
+
另一条留给后人的方法教训:**看到"明文名字恰好和某字段对得上"时,先验证名字顺序能否对上字段号** ——
|
|
2467
|
+
对不上就只能当线索,不能当名字(这条来自本轮把 `InfoVersion` 撤掉的过程)。
|
|
2468
|
+
|
|
2469
|
+
## D-070: `#5` 不是"附加参与者";`chat_room_info_detail` 的配对语义不能搬到 `chat_room` 上
|
|
2470
|
+
|
|
2471
|
+
**Status:** Active(更正 D-067 / D-068 里的一处**事实错误**)
|
|
2472
|
+
|
|
2473
|
+
这两条是 2026-10-03 用多智能体并行复核时**先由子 Agent 提出、我再独立复核**的:
|
|
2474
|
+
|
|
2475
|
+
1. **`#5` 的真实身份**:`chat_room.ext_buffer` 顶层 `#5` 的 id 集合,与"该群成员里
|
|
2476
|
+
`status & 2048`(位 11)的那批人"**逐群完全相等(77/77)**;全库 4151 个成员里带 bit11 的正好
|
|
2477
|
+
28 个,与 `#5` 的 28 条目**一一对应**。⇒ `#5` 是成员列表的**投影**,**没有独立信息**。
|
|
2478
|
+
D-067 / D-068 里"`#5` 是附加参与者 id(库里的表没有这一串)"**是错的**。
|
|
2479
|
+
**后果**:`contact-schema` 的输出键由 `extraIds` 改为 `statusBit11Ids`,人读标签改成
|
|
2480
|
+
「`#5`(status 位 11 的成员)」。这是一次**对外可见的改名**,但留着旧名字等于在输出里
|
|
2481
|
+
继续断言一个已被证伪的含义。
|
|
2482
|
+
2. **"配对差值 = 未落库"只对 `chat_room_info_detail` 成立**:`chat_room` 自己的 `#3`/`#4`
|
|
2483
|
+
**77/77 恒等**,在那 5 个"成员比关系表多 1 人"的群里**仍然恒等**。把 detail 的配对语义搬到
|
|
2484
|
+
chat_room 上,本机数据**不支持**;"三个存储落后程度不同"是**同样相容的另一种读法**,
|
|
2485
|
+
一次快照区分不了。文档 6.1 的 ①/② 据此改写。
|
|
2486
|
+
3. 另外两条已验的位含义(都 77/77):`chat_room_info_detail.chat_room_status_` 的**位 17 ⟺
|
|
2487
|
+
本群与企业微信/openim 互通**(置位 6 群全有 `@openim` 成员/群主,不置位 71 群全无);
|
|
2488
|
+
`524288`(位 19)**只是众数**(51/77)**不是常量** —— 文档里"77 行全是 524288"一并更正。
|
|
2489
|
+
4. **`chatroom_seq` 这条名字候选降级**:客户端字符串池里确实有它,但两轮独立追查都指向它是
|
|
2490
|
+
**账号级的"群列表游标"**(出现在 InitContact 的收尾日志里,日志里**没有任何房间标识**)。
|
|
2491
|
+
⇒ 不采用,`#3`/`#4` 照旧不编名字。
|
|
2492
|
+
|
|
2493
|
+
**Reason:** 记"我们对外说过的话里哪些是错的"比只记新增发现更重要——`#5` 那句已经进了命令输出
|
|
2494
|
+
和 npm 包的说明文字。
|
|
2495
|
+
|
|
2496
|
+
**Consequences:** `docs/CONTACT_DB_SCHEMA.md` §2/§5/§6/§6.1/3.2 与 `contact-schema` 的措辞同步;
|
|
2497
|
+
`#3`/`#4` 仍然只报字段号(D-068 的边界不变)。
|
|
2498
|
+
|
|
2499
|
+
## D-069: `#3`/`#4` 定性到"一对版本号、跟着成员走",但**仍然不给它名字**
|
|
2500
|
+
|
|
2501
|
+
**Status:** Active(补充 D-068 的"语义未定")
|
|
2502
|
+
|
|
2503
|
+
2026-10-03 又追了一轮,把这对字段从"完全未定"推到**行为可描述**(细节与可复跑步骤见
|
|
2504
|
+
`docs/CONTACT_DB_SCHEMA.md` 6.1):
|
|
2505
|
+
|
|
2506
|
+
- 它是**同一个计数器的两份** —— 最硬的证据在结构相同的 `chat_room_info_detail.ext_buffer_` 里:
|
|
2507
|
+
同一对字段号在 5 行上 `#3` 比 `#4` 大 1;
|
|
2508
|
+
- 那 5 个群**正好**是 `chat_room.ext_buffer` 的成员比 `chatroom_member` 多 1 个人的 5 个群
|
|
2509
|
+
(一次已到达、还没落到关系表的成员变更)—— 两个标志互为证据,不是巧合;
|
|
2510
|
+
- 去掉底数后的大小与**成员数**秩相关 0.92 ~ 0.94,与消息数几乎无关(0.04 ~ 0.40);
|
|
2511
|
+
- 底数有两种(`10000` 47 个群 / `7×10^8` 24 个群),与记录进库的先后相关;`owner` 为空的
|
|
2512
|
+
3 个群值为 0;另有 3 个群是第三种来源(1254/1304/2021),样本不够;
|
|
2513
|
+
- 它**会变**(同一天实测 +2),所以不是静态 id。
|
|
2514
|
+
- **类型名有,字段名没有**:从客户端二进制里读到 `chat_room.ext_buffer` 其实是
|
|
2515
|
+
`micromsg.ChatroomExtData`、成员子消息是 `micromsg.ChatroomMemberLocalData`、
|
|
2516
|
+
`contact.extra_buffer` 是 `micromsg.ContactExtData`(`docs/CONTACT_DB_SCHEMA.md` 3.1);
|
|
2517
|
+
但**字段名不以明文出现** —— 明文只到"表的列名"这一层(`nick_name / … / extra_buffer`),
|
|
2518
|
+
本地 message 的字段名是二进制的、不可读。**(更正:早前一版说"字段名被打包成 `<长度><hex>`
|
|
2519
|
+
记录"是错的 —— 那批记录是客户端的字符串池,见 3.2。)**
|
|
2520
|
+
- **顺手破了客户端的一层字符串混淆**(3.2):`0xa1 <小端 uint16 的 4 位 ASCII hex> 0xb1 <等长 hex>`
|
|
2521
|
+
这种记录,载荷是**明文 XOR 一条固定的逐位密钥**(判据:解出来每一位的可打印率 86%~100%,
|
|
2522
|
+
随机密钥只有 ~37%)。解出来是客户端的符号/文件名/日志串。**对本文的用处只有一条**:
|
|
2523
|
+
日志键里有 **`chatroom_seq`**(与 `contact_seq` 并排,在 `init_contact_manager.cc` 的流程里)——
|
|
2524
|
+
它跟 `#3`/`#4` 的行为对得上,但**没有对照,只能算名字候选**。
|
|
2525
|
+
|
|
2526
|
+
**没有改的一件事:名字。** 上面是**行为**证据,不是厂商给的字段名;本机也没有对照源
|
|
2527
|
+
(没有 V3 数据、没有第二台机器、同一房间只有一个时点)。所以 `contact-schema` 继续把
|
|
2528
|
+
`#3`/`#4` 放在 `unrecognized` 里、只报字段号 —— **D-068 的边界不变**,变的只是文档里
|
|
2529
|
+
"能说到哪"的那一段,以及"blob 比关系表新"这条可以直接用的事实。
|
|
2530
|
+
|
|
2531
|
+
**Consequences:** 想给它们命名,得先拿到对照源(另一台机器 / 另一个微信版本 / 同一房间的
|
|
2532
|
+
两个时点快照)。在那之前,任何"这是群号 / 成员数 / 时间戳"的说法都只算假设。
|
|
2533
|
+
|
|
2534
|
+
## D-068: 只解 `ext_buffer` 里**已验证**的字段,未定的原样标注
|
|
2535
|
+
|
|
2536
|
+
**Status:** Active(细化 D-067 的"解析代码不进")
|
|
2537
|
+
|
|
2538
|
+
D-067 定的是"测绘进仓库、解析不进"。随后的问题是"解析完并纳入功能了吗" —— 于是把**已经验证过**
|
|
2539
|
+
的那部分做成能力:`weflow-cli contact-schema`(`scripts/contact_schema.py`,只读本地)输出成员的
|
|
2540
|
+
userName / displayName / status(位标志)/ inviter,加上 `#5`。(`#5` 当时被叫成"附加参与者 id";
|
|
2541
|
+
2026-10-03 更正:它等于成员 `status` 位 11 的投影,见 D-070。)
|
|
2542
|
+
|
|
2543
|
+
**边界写在输出里,不写在注释里。** 顶层 `#3`/`#4` 语义未定(三次证伪:不是成员 id、不是房间 id、
|
|
2544
|
+
不在消息库任何 id 列;**2026-10-03 更新**:行为已定性到「一对跟着成员走的版本号」,见 D-069
|
|
2545
|
+
与 `CONTACT_DB_SCHEMA.md` 6.1 —— 但**名字照旧不编**),所以它们进 `unrecognized`、键就是字段号,**不给它们编名字**;成员里出现
|
|
2546
|
+
没见过的小字段同样照原样带出(`members[].unrecognized`)。编了名字,下游就会拿猜出来的含义写逻辑。
|
|
2547
|
+
|
|
2548
|
+
**解不动就报错,绝不返回空成员表。** "解不动"和"这个群没人"在数据上是相反的两件事,
|
|
2549
|
+
静默把前者写成后者是最坏的一种错。`test/contact_schema_test.py` 钉住这两条 —— 其中"声明长度
|
|
2550
|
+
超出缓冲区"那个用例是**变异检查补出来的**:第一版测试只用了"长度的 varint 被截断",
|
|
2551
|
+
压根走不到 `i + ln > len(buf)` 那条检查,把那个 raise 改成 `return []` 测试照样绿。
|
|
2552
|
+
|
|
2553
|
+
**Consequences:** 要素在 blob 里而库里的表没有的那两样(status / inviter)时,
|
|
2554
|
+
用这个命令;其余字段与群昵称仍以 DLL 那条路为准。
|
|
2555
|
+
|
|
2556
|
+
**`#3` 的边界(2026-10-03 追到尽头)**:24 个大值 `#3` 在 `db_storage` 下**全部 24 个库**的每一列里
|
|
2557
|
+
命中 0(大整数不会撞车),另加三条独立排除(不是本地行号、不是群号、不是消息表名哈希的输入 ——
|
|
2558
|
+
后两条分别由厂商 DLL 的 SQL 与 139 张 `Msg_*` 表反推)。所以它只活在这个 blob 里。
|
|
2559
|
+
这就是"不猜"的底气:不是懒得查,是查到了尽头。
|
|
2560
|
+
|
|
2561
|
+
**为什么"找对应列"这条路注定失败**:这类字段多半是**位标志**(群成员 `status` 实测 8 个位、
|
|
2562
|
+
`contact.extra_buffer` 的 `#3` 实测 4 个位)—— 位标志的集合不会与任何单一列镜像。
|
|
2563
|
+
先定性到"它是位标志"再谈别的,比拿列联表硬找有意义。另外**本机没有 V3 的数据**
|
|
2564
|
+
(`WeChat Files` 与注册表 InstallPath 都为空),所以"拿另一个版本对照"这条在这里走不通。
|
|
2565
|
+
|
|
2566
|
+
**`#5` 的更正(2026-10-03)**:本文(以及 D-067 引用的那段)把顶层 `#5` 叫"附加参与者 id"是**错的** ——
|
|
2567
|
+
实测它等于"成员里 `status` 位 11 置位的那批 id"(77/77 集合相等)。见 **D-070**。密钥只从配置读、**不做成命令行参数**
|
|
2568
|
+
(进程列表可见,同 EXTENDING 的 "state goes in over stdin, not argv")。
|
|
2569
|
+
|
|
2570
|
+
## D-067: `ext_buffer` 的结构已测绘,但**不**在仓库里解析它
|
|
2571
|
+
|
|
2572
|
+
**Status:** Active
|
|
2573
|
+
|
|
2574
|
+
`contact.db` 里那几列二进制(`chat_room.ext_buffer`、`chat_room_info_detail.ext_buffer_`、
|
|
2575
|
+
`contact.extra_buffer`、`stranger.extra_buffer`、`openim_*`)没有官方文档。2026-10-03 把
|
|
2576
|
+
`chat_room.ext_buffer` 解出来了:它是 protobuf,成员段与上游
|
|
2577
|
+
[chatlog-export](https://github.com/Wing900/chatlog-export) 的 `roomdata.proto` **一致**,
|
|
2578
|
+
但那份定义**不全也不准**(它写的 `int32 roomCap = 5` 实测是长度分隔的字符串列表;顶层还多
|
|
2579
|
+
`#3`/`#4`/`#6`)。整库 17 张表逐列测绘记在 `docs/CONTACT_DB_SCHEMA.md`。
|
|
2580
|
+
|
|
2581
|
+
**决定:测绘进仓库,解析代码不进。** 群昵称现在走原生 DLL
|
|
2582
|
+
(`wcdbCore.ts` 的 `wcdb_get_chat_room_ext_buffer`)已经够用;而 blob 里最诱人的那个 `#3`
|
|
2583
|
+
(每群一个数、与 `#4` 恒相等)**语义没定** —— 实测排除了"成员 id / 房间 id / 消息侧 id"
|
|
2584
|
+
三种解释,剩下最像"V4 新 id 空间里的某个会话标识",但本机数据不足以判定。
|
|
2585
|
+
|
|
2586
|
+
**Consequences:** 拿一个语义未定的字段写逻辑,等于把猜测当事实。真需要 blob 里那三样
|
|
2587
|
+
(成员的 `status` 位标志、`inviter`、`#5` —— 库里的表都没有)时,回来读那份
|
|
2588
|
+
笔记,先把 `#3` 定死或明确只用已验字段。笔记里也记了**证伪过的猜测**,省得后人再试一遍。
|
|
2589
|
+
|
|
2590
|
+
## D-066: The tickle no longer tilts - rotation belongs to the lift alone
|
|
2591
|
+
|
|
2592
|
+
**Status:** Active (supersedes the computed-tilt half of D-065)
|
|
2593
|
+
|
|
2594
|
+
The tickle used to do two things at once: cycle four generated poses, and rotate the ball by up to 4 degrees
|
|
2595
|
+
computed from the pointer's speed, leaning away from the direction you swept. The rotation is **gone** as of
|
|
2596
|
+
2026-10-02; the four poses stay.
|
|
2597
|
+
|
|
2598
|
+
**Why:** the tilt and the lift share one visual channel - both are a `rotate` on `#ball` - so a pointer merely
|
|
2599
|
+
crossing the ball produced a small version of the take-off. The user read it exactly that way: "why does it sway
|
|
2600
|
+
when I move the mouse across it, instead of only swaying in the lift state". Two features competing for one
|
|
2601
|
+
transform is also why the older code had to keep them mutually exclusive in JS (`canTickle()` excludes
|
|
2602
|
+
`ball-lift`, and a press settles the tickle first).
|
|
2603
|
+
|
|
2604
|
+
**Consequences:** `--tickle-deg` is no longer written and its CSS rule is gone, so `body.ball-tickle` now only
|
|
2605
|
+
carries the pose frames and the hidden iris layer. The rest of the behaviour is unchanged: a pose every 110 ms,
|
|
2606
|
+
settled 260 ms after movement stops, skipped under `prefers-reduced-motion`, while peek-hidden, and while the
|
|
2607
|
+
ball is lifted. Two guards keep it from creeping back: `test/panel-tickle.test.ts` asserts the tickle writes
|
|
2608
|
+
**no** angle at all, and `test/panel-lift-frames.test.ts` asserts that no `body.ball-tickle #ball` rule carries a
|
|
2609
|
+
`transform`. **Not yet verified live** - as with D-065, nobody has watched it on a real window.
|
|
2610
|
+
|
|
2611
|
+
## D-065: The tickle is a computed tilt plus four poses, and it rotates about the centre because that is what the circular clip allows
|
|
2612
|
+
|
|
2613
|
+
**Status:** Superseded by D-066 (the computed tilt was removed on 2026-10-02; the four poses stay)
|
|
2614
|
+
|
|
2615
|
+
Running the pointer across the floating ball makes it squirm. Two things are decided here.
|
|
2616
|
+
|
|
2617
|
+
**The motion is computed, the poses are drawn.** Four generated poses (`mascot-tickle-1..4.png`) carry the
|
|
2618
|
+
expression and the paw detail, but the **tilt is computed in the renderer** from the pointer's horizontal
|
|
2619
|
+
direction and its speed (maximum 4 degrees at 900 px/s, settling 260 ms after movement stops). The split is
|
|
2620
|
+
forced: a fixed set of frames can only replay one amplitude, and the whole point of a tickle is that it answers
|
|
2621
|
+
how much you are moving. The direction is inverted on purpose - sweeping right makes it lean left, the way being
|
|
2622
|
+
pushed would.
|
|
2623
|
+
|
|
2624
|
+
**Rotation, not translation, and about the centre.** The ball is clipped to a circle, and its art already uses
|
|
2625
|
+
125.2 of the available 125.44 radius (the remaining 2% is reserved for the hover `scale(1.02)`). Rotating about
|
|
2626
|
+
the centre preserves every pixel's distance from that centre, so no pixel can be pushed past the clip - a
|
|
2627
|
+
translation of the same visual magnitude would immediately clip the ears. `transform-origin` defaults to the
|
|
2628
|
+
centre, so this holds without extra CSS, and **that default is now load-bearing**: changing it would silently
|
|
2629
|
+
start cutting the ball. The four poses stay at scale 1.0 so the ball does not change size while being tickled.
|
|
2630
|
+
|
|
2631
|
+
**Consequences:** the tickle is skipped under `prefers-reduced-motion` and while the ball is peek-hidden (that
|
|
2632
|
+
artwork has a straight cut edge, which a rotation turns into a visible seam), and a press settles it first so the
|
|
2633
|
+
lift owns the ball. The IRIS layer is hidden while a tickle pose is showing, for the same reason as the other
|
|
2634
|
+
painted-eye faces. No new IPC. **Not yet verified live** - the geometry, the wiring and the settle-on-stop
|
|
2635
|
+
behaviour are covered by `test/panel-lift-frames.test.ts` and `test/panel-tickle.test.ts`, but nobody has watched
|
|
2636
|
+
it on a real window yet.
|
|
2637
|
+
## D-064: The ball's lift animation is generated art, and the contract that keeps its frames registered is now executable
|
|
2638
|
+
|
|
2639
|
+
**Status:** Active
|
|
2640
|
+
|
|
2641
|
+
Pressing the floating ball plays a twelve-frame sequence - six frames up while it is held, six back down on release -
|
|
2642
|
+
and the twelve new frames are produced by the project's image-generation model rather than drawn by hand. Four things are
|
|
2643
|
+
decided here.
|
|
2644
|
+
|
|
2645
|
+
**The frames may only express the lift through pose.** The ball is `border-radius: 50%` and the art is normalized to the
|
|
2646
|
+
inscribed circle, with the remaining 2% of the radius reserved for the hover `scale(1.02)`. A frame that simply drew the
|
|
2647
|
+
cat higher would have its ears clipped by that circle, so the lift is carried by the pose (paws hanging, ears pressed
|
|
2648
|
+
back, eyes wide) plus a per-frame shrink to 0.975 / 0.945 / 0.920 of the resting cat width. Relaxing the clip, growing
|
|
2649
|
+
the window during the gesture, and adding a shadow or a halo were each rejected: the shadow and the halo were removed at
|
|
2650
|
+
the user's request on 2026-09-29 and may not come back.
|
|
2651
|
+
|
|
2652
|
+
**The normalization contract is executable now, not folklore.** Its parameters previously lived only in a comment in
|
|
2653
|
+
`panel.css`, which is why the same quantity was measured two different ways in one afternoon: from the canvas centre the
|
|
2654
|
+
existing art reads 125.88 ("4 pixels outside the circle"), from the content centre 124.47, and only **canvas centre with
|
|
2655
|
+
solid = alpha >= 128** reproduces the documented 125.25. `scripts/panel_frames.py` applies the contract and
|
|
2656
|
+
`test/panel-lift-frames.test.ts` asserts it; `--check-raw` gates a raw generation's silhouette before it is accepted.
|
|
2657
|
+
|
|
2658
|
+
**Two invariants are pinned because their absence is silent.** The suite asserts that **no two frames are the same
|
|
2659
|
+
image** - comparing **all pairs, not just adjacent ones**: the first version compared neighbours only, and a mutation that
|
|
2660
|
+
copied one frame over another passed green, which is how the hole was found. It also asserts that the **largest connected
|
|
2661
|
+
component** keeps its width and aspect ratio, because a regenerated frame can stop being the same character while every
|
|
2662
|
+
geometry assertion still passes. The first generation did exactly that: the hood became a pointed cone and the cat's
|
|
2663
|
+
aspect ratio moved from 0.794 to 0.650 (-18%).
|
|
2664
|
+
|
|
2665
|
+
**No new IPC, and the squint face keeps a job.** The action is entirely renderer-side; a new IPC method would have to touch
|
|
2666
|
+
the pinned method list in `preload.cjs` and three test doubles for no benefit. `mascot-happy.png` is not retired - it is
|
|
2667
|
+
the **reduced-motion press face**, which is what it is for once an animation exists.
|
|
2668
|
+
|
|
2669
|
+
**Consequences:** the frames are generated art, so they are re-rollable but not hand-editable, and the resting frame stays
|
|
2670
|
+
`mascot-base.png` (it is pixel-pinned to the iris layer, so regenerating it would break the eye-follow). **Not yet verified
|
|
2671
|
+
live** - the wiring, the geometry and the frame sequence are covered by tests, but the animation has not been watched on a
|
|
2672
|
+
real window.
|
|
2673
|
+
|
|
2674
|
+
## D-063: Sending stays unreachable from the model, and becomes reachable from a human who names the candidate
|
|
2675
|
+
|
|
2676
|
+
**Status:** Active
|
|
2677
|
+
|
|
2678
|
+
**Decision.** `weflow-cli draft <talker>` already did the hard half - judge the intent, draft three deliberately
|
|
2679
|
+
different candidates, rank them with the decision model - and ended by saying the candidates are **only text** and that
|
|
2680
|
+
sending is the user's call. It now also carries that decision out, under three rules:
|
|
2681
|
+
|
|
2682
|
+
1. **`--send` requires `--pick N`.** There is no "draft three, let it choose, let it send" path. The human names the
|
|
2683
|
+
candidate; the model never selects what goes out.
|
|
2684
|
+
2. **Two-phase.** Without `--yes` there is no send: JSON mode returns `CONFIRMATION_REQUIRED` **with the text in the
|
|
2685
|
+
payload**, interactive mode asks "确定把第 N 条发给 X?". Both non-`--yes` paths are covered by tests that also assert
|
|
2686
|
+
**no send attempt reached the audit log**.
|
|
2687
|
+
3. **One send policy, not two.** Blacklist → whitelist → rate limit → audit now lives in `src/services/outboundSend.ts`
|
|
2688
|
+
and is called by both `send` and `draft --send`. Before this, that chain existed only inside the `send` command's
|
|
2689
|
+
action; a second copy is how a "send" path ends up quietly skipping the whitelist.
|
|
2690
|
+
|
|
2691
|
+
**Why the model stays out of it.** `docs/EXTENDING.md` states that sending is *structurally unreachable from a
|
|
2692
|
+
model-driven path*, and that is not a mood: the assistant and the MCP surface have no send tool, and
|
|
2693
|
+
`test/draft-send-cli.test.ts` now asserts that `send`, `draft`, `send_message` and `publish_article` are absent from
|
|
2694
|
+
`TOOL_DEFS` (while `draft_reply` stays present - drafting is not sending). What changes here is only the **human's**
|
|
2695
|
+
side: after reading the candidates, the user no longer has to copy one into another terminal.
|
|
2696
|
+
|
|
2697
|
+
**Constraints that fell out of the measurements.** (a) `--pick` out of range and `--send` without `--pick` are
|
|
2698
|
+
**usage errors, so they are reported before any model call** - the first version validated them after the confirmation
|
|
2699
|
+
gate, which meant the model ran, and was paid for, before the command said "there is no candidate 9". (b) `--dry-run`
|
|
2700
|
+
means **zero egress**, and candidates can only come from a model, so `--dry-run` cannot preview "what candidate 2 would
|
|
2701
|
+
send"; it reports the character count the script would send and says why, rather than pretending. (c) Sending still
|
|
2702
|
+
requires the other side's `context_token` - the channel needs a message from them first - so "send to anyone" was never
|
|
2703
|
+
available, and the whitelist remains the real boundary.
|
|
2704
|
+
|
|
2705
|
+
**Consequences.** The tests do not call a model and do not need one: refusing early is exactly what they assert. The
|
|
2706
|
+
audit file (`~/.weflow-cli/audit-send.log`) is what they read, and the positive control that proves that read is not
|
|
2707
|
+
vacuous does **not** come from a real send - with a temporary home the channel is not logged in, so `send --yes` exits
|
|
2708
|
+
at `MESSAGE_CHANNEL_NOT_LOGGED_IN` before the audit line. It uses the **blacklist** branch instead, which audits before
|
|
2709
|
+
that check. That detail cost one debugging round and is written here because the next person will hit it too.
|
|
2710
|
+
|
|
2711
|
+
## D-062: Relationship temperature stays on the command line, and its "who counts as a person" rule flags rather than drops
|
|
2712
|
+
|
|
2713
|
+
**Status:** Active
|
|
2714
|
+
|
|
2715
|
+
**Decision.** `scripts/bonds.py` reports three things from the local message database: relationships that are cooling
|
|
2716
|
+
(substantial history, then silence), relationships heating up (the last 7 days well above the long-run rate), and
|
|
2717
|
+
contacts whose history never mentions another channel (phone, email, meeting up) - i.e. the ones reachable *only*
|
|
2718
|
+
here. It is **read-only, local, and calls no model**. Two rules are pinned:
|
|
2719
|
+
|
|
2720
|
+
1. **It is not in the tool table.** It is not exposed to the assistant, to MCP, or to any cloud model - not as a tool
|
|
2721
|
+
and not as context. Everything else this project computes about the user is either explicitly shareable or has a
|
|
2722
|
+
confirmation gate; this one is neither. Its output is an **inference about third parties** (which relationships the
|
|
2723
|
+
user is neglecting, who they would lose), derived from private messages, and the person it could most embarrass is
|
|
2724
|
+
not the person running it.
|
|
2725
|
+
2. **"Who counts as a person" flags, it does not drop.** The rule is the **shape of the id** - anything containing
|
|
2726
|
+
`@` (`@chatroom` groups, `@openim` service accounts, `@weclaw` bots), `filehelper`, or a `gh_` official account, is
|
|
2727
|
+
not a person; everyone else is. Accounts that are structurally indistinguishable from a person but whose *name*
|
|
2728
|
+
looks like a shop or a hotline (普华口腔门诊部 is one on this machine, with `local_type = 1` and its own alias) are
|
|
2729
|
+
listed under "flagged, you decide", with a skip file (`output/bonds-skip.txt`) and `--skip` to act on it. And every
|
|
2730
|
+
account the run excluded is printed with the reason, so a filter can never quietly remove two friends.
|
|
2731
|
+
|
|
2732
|
+
**Reason.** Three measurements killed three simpler designs. (a) Ranking by message volume produced a leaderboard
|
|
2733
|
+
with no information - the people you message most are not news to you. (b) "Never mentions another channel" was
|
|
2734
|
+
**inverted**: it is trivially true of bots, so the first version of the irreplaceability ranking had 星巴克小助手,
|
|
2735
|
+
天天神券福利君, 串掌门(南村店)and 文件传输助手 at the top. (c) `flag` looked like the clean human/bot marker - real
|
|
2736
|
+
friends mostly carry `flag = 3` - until the distribution was read: of 38 people, three differ, and only one of those
|
|
2737
|
+
(`微信ClawBot`, 2049) is a bot; 彪弟 (2051) and 平凡的世界 (2563) are people whose bit was set by however they were
|
|
2738
|
+
added. Shipping `flag == 3` would have silently deleted two friends, which is the failure this project keeps meeting:
|
|
2739
|
+
a filter that is 95% right and reports nothing.
|
|
2740
|
+
|
|
2741
|
+
**Consequences.** The thresholds are constants with tests and mutation checks (`COOL_DAYS`, `HOT_RATIO`, the minimum
|
|
2742
|
+
history required for "cooling"), because they are the whole judgement. The skip file lives under `output/` (gitignored,
|
|
2743
|
+
never committed). Adding it as a command in `bin/weflow-cli.ts` is fine and expected; adding it to the assistant or MCP
|
|
2744
|
+
tool tables is not, and that is the part of this decision worth keeping written down.
|
|
2745
|
+
|
|
2746
|
+
## D-061: The MCP server can speak HTTP - so the boundary had to be written down, not just implemented
|
|
2747
|
+
|
|
2748
|
+
**Status:** Active
|
|
2749
|
+
|
|
2750
|
+
**Decision.** The MCP server serves its tools over **Streamable HTTP** as well as stdio, and the second transport is
|
|
2751
|
+
**off unless asked for** (`--http`, or `WEFLOW_MCP_HTTP`). The tool table is the same one (`MCP_TOOL_DEFS` derived from
|
|
2752
|
+
`TOOL_DEFS`), so the two transports cannot drift; only the carrying changes. Four rules are enforced in
|
|
2753
|
+
`mcp-server/httpConfig.ts` - pure functions with offline tests - rather than left to the caller:
|
|
2754
|
+
|
|
2755
|
+
1. **Off by default.** No flag means stdio, byte for byte as before.
|
|
2756
|
+
2. **A token is mandatory, with no default.** Missing or under 24 characters, the process refuses to start.
|
|
2757
|
+
3. **Loopback only.** `0.0.0.0` and every other non-loopback address are rejected with the tunnel command in the
|
|
2758
|
+
error message.
|
|
2759
|
+
4. **DNS-rebinding protection on.** The SDK's default is `false`; the realistic attack on a loopback server is a web
|
|
2760
|
+
page the user happens to visit reaching `127.0.0.1`, so the `Host` header must match and any `Origin` must be
|
|
2761
|
+
allowlisted.
|
|
2762
|
+
|
|
2763
|
+
**Why HTTP at all.** stdio can only be spoken by a client that is able to spawn a process. Serving HTTP is the norm
|
|
2764
|
+
in this ecosystem - `chatlog` is the de-facto standard and does exactly this - and it is what lets a resident
|
|
2765
|
+
service, a browser-side client, or several clients at once use the same query surface.
|
|
2766
|
+
|
|
2767
|
+
**Why the boundary is written down rather than only coded.** This is the first time this project opens a **listening
|
|
2768
|
+
socket that serves the user's private messages to whoever can reach it**. The failure mode is not a crash: it is a
|
|
2769
|
+
port that stays open, bound wider than the author intended, or opened with a token that looks configured and is not.
|
|
2770
|
+
So each rule is (a) in a pure function with its own test, (b) mutation-checked - disabling the rebinding protection,
|
|
2771
|
+
removing the token check, widening the loopback list, lowering the token floor, or making the check return `true`
|
|
2772
|
+
each turn the suite red - and (c) stated in `docs/MCP.md` where a user meets it.
|
|
2773
|
+
|
|
2774
|
+
**Why no remote bind.** The tempting convenience - `--host 0.0.0.0` so a laptop can reach the desktop - is exactly
|
|
2775
|
+
the change that moves private chat data onto the network. A tunnel is one command and keeps the exposure local; if
|
|
2776
|
+
someone later wants a real remote surface, that is a separate decision with its own threat model, not a flag here.
|
|
2777
|
+
|
|
2778
|
+
**Consequences.** `mcp-server/index.ts` now exports `buildServer()` and starts only when run as a script (importing
|
|
2779
|
+
it must not open a listener - the tests import it). The token is never logged. Requests are stateless (one server per
|
|
2780
|
+
request, no session id), so there is no cross-client state to leak. Nothing about the stdio path changed: an existing
|
|
2781
|
+
client configuration keeps working untouched.
|
|
2782
|
+
|
|
2783
|
+
## D-060: A backfilled day records which topics it holds, because "the file exists" is not "the day is done"
|
|
2784
|
+
|
|
2785
|
+
**Status:** Active
|
|
2786
|
+
|
|
2787
|
+
**Decision.** `backfill_articles.py` can fetch one topic instead of a whole day (`--topic AI`), and a day written
|
|
2788
|
+
that way is **partial and says so**. It uses the classifier the script already had - `_guess_topic`, which reads only
|
|
2789
|
+
the title and the account name and never the article body - so filtering first selects exactly the articles a full run
|
|
2790
|
+
would have filed under that topic. There is no second criterion. What is new is the bookkeeping: `.articles.json`
|
|
2791
|
+
carries `topicFilter`, the README states which topics are missing, and `day_done` refuses to call that day complete,
|
|
2792
|
+
so a later full run still covers it.
|
|
2793
|
+
|
|
2794
|
+
**Reason: one truncation prevented, and one found already live.** Without the marker, a day holding only its AI
|
|
2795
|
+
articles looks finished - the md files exist, `.articles.json` exists, and `day_done` asked nothing more than "is the
|
|
2796
|
+
list non-empty". The other five topics would never be fetched and **nothing would report it**, which is the failure
|
|
2797
|
+
shape this repository has already paid for once: `--vault-sync` was keyed off a `backfilled` marker that only one of
|
|
2798
|
+
its two writers sets, so 12 days were never copied *and* never reported as skipped (2026-09-28, 187 → 199 days).
|
|
2799
|
+
Checking for this one turned up the same defect already shipped in `--limit-per-day`: the day is sliced, written, and
|
|
2800
|
+
then reads as done, so everything past the limit was silently out of scope forever. It is recorded in the same marker
|
|
2801
|
+
(`truncated`) because it is the same mechanism, and it is a **behaviour change** - a limited day is now redone on the
|
|
2802
|
+
next run rather than skipped.
|
|
2803
|
+
|
|
2804
|
+
The filtering is applied **inside `write_day`** as well as at the call site. If only the caller filtered, a caller
|
|
2805
|
+
that forgot to would write all six topic directories and stamp "only AI" on them; a marker that lies is worse than no
|
|
2806
|
+
marker, and this is the only way to make the two structurally unable to disagree.
|
|
2807
|
+
|
|
2808
|
+
**Consequences.** `.articles.json` gains two optional keys; a full run's output is byte-identical to before, so no
|
|
2809
|
+
existing output needs migrating, and `day_done` reads a missing `topicFilter` as "complete" - which is also what the
|
|
2810
|
+
days already on disk mean. `--topic` changes no criterion: over 2025-09-05 ~ 2026-03-02 the script's own classifier
|
|
2811
|
+
puts **1,758 of 9,207** in-scope articles in `AI` (5-day sample: 300 → 66, the same share the daily pipeline reports
|
|
2812
|
+
over the 3,498 articles already backfilled). That figure is the tool's **keyword** view and not the daily line's model view -
|
|
2813
|
+
these articles have never been through a model, which is why the script can pre-filter at all, and why the count is
|
|
2814
|
+
labelled as the keyword's. The scope is official accounts only (`gh_%`), which is what the script queries - an earlier
|
|
2815
|
+
count over every session in `Name2Id` read 10,880 and was wrong by 1,673 articles that live in non-official
|
|
2816
|
+
conversations and that this path never fetches. Anything scripted against a partially backfilled day must read `topicFilter` rather than
|
|
2817
|
+
infer completeness from the presence of `.articles.json`. An unknown `--topic` is rejected before the database is
|
|
2818
|
+
opened, since a typo would otherwise select 0 articles and report "nothing to do" - indistinguishable from a day that
|
|
2819
|
+
genuinely had none.
|
|
2820
|
+
|
|
2821
|
+
## D-059: The ball's eyes follow the mouse - and that makes the cursor position a data flow, so it is written down
|
|
2822
|
+
|
|
2823
|
+
**Status:** Active
|
|
2824
|
+
|
|
2825
|
+
**Decision.** The mascot's irises track the cursor. The position comes from the **main process**
|
|
2826
|
+
(`screen.getCursorScreenPoint()`, sampled every 120 ms, pushed **only when the coordinates change**), because the
|
|
2827
|
+
renderer cannot see it: the ball is 96x96 and the pointer is outside the window almost all of the time, so
|
|
2828
|
+
`mousemove` never fires. The page eases toward the pushed point and **stops its rAF loop the moment it settles** -
|
|
2829
|
+
a permanently-running 60 fps transform would be a compositor cost paid for decoration, on a window that is always
|
|
2830
|
+
open.
|
|
2831
|
+
|
|
2832
|
+
**Why there are now two images instead of one.** The mascot PNG has its eyes **drawn into** it, so nothing in it can
|
|
2833
|
+
move. `mascot-base.png` is the same art with the iris region repainted in the sclera colour, and `mascot-iris.png` is
|
|
2834
|
+
the iris (with its pupil and highlight) as its own layer. Composited at zero offset the two are **pixel-identical**
|
|
2835
|
+
to `mascot.png` - a test decodes all three PNGs and asserts that pixel by pixel, because every weaker check (same
|
|
2836
|
+
size, present in the whitelist, present in `PANEL_FILES`) stays green if somebody re-cuts them one pixel off, and a
|
|
2837
|
+
one-pixel misalignment is visible to a human and to nothing else. `mascot.png` stays in the repo as the source the
|
|
2838
|
+
other four expressions are generated from, but it is **no longer what the ball shows** - pointing the ball's face at
|
|
2839
|
+
it while an iris layer moves on top would show two pupils, one of them stationary.
|
|
2840
|
+
|
|
2841
|
+
**The travel limits are measured, not chosen.** Scanning the source pixels along the iris: to its left there are
|
|
2842
|
+
about 7 px of sclera before the dark rim, and above it there is none at all (the upper lid sits on the iris). At the
|
|
2843
|
+
ball's 96 px that is roughly 2.6 px of usable travel sideways and essentially zero upward, so the clamp is an
|
|
2844
|
+
**ellipse** (2.6 sideways / 0.8 up / 2.6 down) rather than a circle. Past that the iris separates from the rim and
|
|
2845
|
+
the eye reads as a sticker sliding across the face. The constants are written for the 96 px ball and are scaled by
|
|
2846
|
+
the element's measured width, so changing `--ball-size` does not require re-deriving them.
|
|
2847
|
+
|
|
2848
|
+
**The eyes move only on the two faces that have any to move.** Four of the five faces have their eyes baked in;
|
|
2849
|
+
stacking a moving iris on those shows two pupils. The iris layer is therefore hidden for `busy` / `offline` /
|
|
2850
|
+
`quota` / `ball-happy`. That is deliberate, not a limitation: those are precisely the "not idle" states, and they
|
|
2851
|
+
already speak with the whole face.
|
|
2852
|
+
|
|
2853
|
+
**Why a data-flow entry.** Cursor coordinates are the panel's second input that does not come from the daemon (the
|
|
2854
|
+
first is the window geometry from `ball-position.cjs`). They travel **in-process only** - main process to this
|
|
2855
|
+
window's renderer - are narrowed in `preload.cjs` (two clamped integers; malformed payloads are dropped rather than
|
|
2856
|
+
handed to the page) along the same rule as every other channel, are never logged and never leave the machine, and
|
|
2857
|
+
the feed stops entirely while the window is hidden. "The app knows where your mouse is" is a data flow even when it
|
|
2858
|
+
never crosses the machine boundary, so it is recorded rather than left implicit.
|
|
2859
|
+
|
|
2860
|
+
**Not yet verified live.** The wiring (the feed, the clamp, the reduced-motion path, the pixel identity) is covered
|
|
2861
|
+
by the suite, but the effect itself has not been run on a real window - that window only exists on a machine with
|
|
2862
|
+
Electron, via `weflow-cli panel`. The amplitudes come from the pixel measurements above; whether 2.6 px reads as
|
|
2863
|
+
"looking at you" at 96 px is a judgement only a person in front of the screen can make.
|
|
2864
|
+
|
|
2865
|
+
## D-058: The assistant reaches the MCP ceiling minus the exclusions - and two of the new capabilities carry a residual risk that is written down here rather than discovered later
|
|
2866
|
+
|
|
2867
|
+
**Status:** Active
|
|
2868
|
+
|
|
2869
|
+
**Decision.** The assistant's tool table was brought up to the same ceiling the MCP surface already had, minus
|
|
2870
|
+
everything the project has decided stays out. Three surfaces had drifted apart: the CLI exposes ~93 command
|
|
2871
|
+
declarations, `TOOL_DEFS` had 21 tools, and `mcp-server/index.ts` additionally hand-wrote 11 tools that the chat path
|
|
2872
|
+
could not reach at all (`get_review`, `get_concepts`, `format_article`, `list_themes`, `fetch_article`,
|
|
2873
|
+
`search_public`, plus the merged `get_stats`). Ten tools were added - `list_contacts`, `get_review`, `get_concepts`,
|
|
2874
|
+
`fetch_article`, `search_public`, `format_article`, `list_themes`, `lint_wiki`, `check_skills`, `set_todo_status` -
|
|
2875
|
+
the seven that existed only on the MCP side were **moved** into `TOOL_DEFS` (same tool names, same argument names,
|
|
2876
|
+
single implementation, so the hand-written table shrank from 11 entries to 4), and four existing tools were widened
|
|
2877
|
+
rather than duplicated: `get_daily_report` gained `full` (the human-readable daily) and cross-date search (which is
|
|
2878
|
+
what the MCP-only `search_articles` did), `get_todos` gained the item id and `group_by`, `get_weread` gained five
|
|
2879
|
+
`mode`s, `get_stats` gained the knowledge-base half it had been missing while the MCP copy had it.
|
|
2880
|
+
|
|
2881
|
+
**The direction is safe because it is not a new kind of access.** Every capability added here was already reachable
|
|
2882
|
+
by an MCP client; the change removes an inconsistency rather than widening what the project exposes. What did *not*
|
|
2883
|
+
move is the ceiling itself: sending, publishing, configuration, access-list writes, `evidence-review`, `vault promote`,
|
|
2884
|
+
scene reads and writes, `login-wechat`/`logout`/`listen`/`assistant run`, `sns capture-key`, `decide` and `init`/`dbkey`
|
|
2885
|
+
all stay unreachable from a model-driven path, each with the decision or comment that says so.
|
|
2886
|
+
|
|
2887
|
+
**Two capabilities carry a residual risk, and both were chosen deliberately by the user rather than slipped in.**
|
|
2888
|
+
|
|
2889
|
+
*Network egress from the chat path.* `fetch_article` and `search_public` are now callable from a WeChat message. The
|
|
2890
|
+
user was shown the asymmetry - the MCP path is documented as a trusted local integration, while a chat message is
|
|
2891
|
+
untrusted input that can carry a prompt injection - and chose to expose them **without** a confirmation gate, matching
|
|
2892
|
+
MCP. What each actually is: `fetch_article` accepts only credential-free HTTPS URLs on the exact `mp.weixin.qq.com`
|
|
2893
|
+
host and **re-checks that allowlist on every redirect hop** (the default `follow` would validate only the entry URL;
|
|
2894
|
+
the test asserts the `redirect: 'manual'` option, because a stubbed fetch cannot observe undici's internal follow -
|
|
2895
|
+
that hole was found by a mutation check). `search_public` is the weaker one and is recorded as such: it scrapes a
|
|
2896
|
+
third-party search page, its URL is a constant so no allowlist applies, and **the model's query leaves the machine
|
|
2897
|
+
with no preview**. Anyone reviewing that decision should read it as: a chat-driven model can cause an arbitrary string
|
|
2898
|
+
to be sent to a search engine, and that is accepted. The mitigation that exists is not a gate but the shape of the
|
|
2899
|
+
data: the query is a search phrase, never chat content.
|
|
2900
|
+
|
|
2901
|
+
*A bounded local write.* `set_todo_status` flips one existing todo between `done` and `pending`. It **cannot create,
|
|
2902
|
+
delete or edit** anything else - there is no `todos rm`, no `daily add/remove`, no `todos extract` in the tool table,
|
|
2903
|
+
and a test pins that (`工具表里没有任何删除待办的工具`). It carries **no** `ctx.requiresConfirm` gate, following the
|
|
2904
|
+
`export_chat` precedent (a write tool with no gate, because the gate set is machine-checked by
|
|
2905
|
+
`test/assistant-tools.test.ts` to be exactly `callsCloudModels` - adding a gate to a tool that does not send user data
|
|
2906
|
+
would either break that equality or require writing a false claim into `capabilities --json`). The residual risk is
|
|
2907
|
+
therefore stated plainly: **an MCP client does not get this tool at all** (`MCP_EXCLUDED`, because "mutating todos
|
|
2908
|
+
stays out" of that surface), while the chat/panel path can flip a status with no preview. Ambiguity is refused rather
|
|
2909
|
+
than resolved: a task-text match must be unique, and the reason is that a wrong guess looks like success while
|
|
2910
|
+
changing the wrong row.
|
|
2911
|
+
|
|
2912
|
+
**Reason.** Adding tools is not free: it makes the model choose among more near-duplicates, which is a failure mode
|
|
2913
|
+
this project has already measured once (`get_reading_stats`'s description had to be rewritten after it stole WeRead's
|
|
2914
|
+
question space and answered a reading question with official-account push counts). So the widenings in this entry are
|
|
2915
|
+
mostly *replacements*: cross-date article search went into `get_daily_report` rather than a second article-finder,
|
|
2916
|
+
`todos remind`'s grouping went in as a parameter rather than a tool, per-book WeRead data went in as `mode` values
|
|
2917
|
+
rather than five tools, and `get_concept` was deliberately **not** added because `search_knowledge` already returns
|
|
2918
|
+
the page plus its neighbour subgraph and a second, weaker lookup is a net loss for routing.
|
|
2919
|
+
|
|
2920
|
+
**Consequences.** Two injection points were added - `WEFLOW_ASSISTANT_BIZ_DAILY_DIR` and
|
|
2921
|
+
`WEFLOW_ASSISTANT_REVIEWS_DIR` - following `WEFLOW_ASSISTANT_EXPORT_ROOT`, and both are read **per call** rather than
|
|
2922
|
+
frozen at import so a test can swap fixtures inside one process. That closed the last uncovered tool branch: the test
|
|
2923
|
+
suite's coverage note had said for months that `get_daily_report` was never executed because its directory was a
|
|
2924
|
+
module-level constant with no injection point, and that the only possible assertion was a "either there is data or
|
|
2925
|
+
there is not" shape check - which is not an assertion. It is now asserted against a fixture, with its three branches
|
|
2926
|
+
(date, cross-date search, prose). The coverage claim is therefore "**all 31 tools are executed**" and the note says
|
|
2927
|
+
where each group lives. Locally-held identifiers were the other thing to get right: `list_contacts` returns names
|
|
2928
|
+
only (remark / display name / nickname - never `username`, never the avatar URL), because keeping wxids out of the
|
|
2929
|
+
model's context is a standing rule of this repository.
|
|
2930
|
+
|
|
2931
|
+
**Added 2026-09-30.** A third injection point, `WEFLOW_ASSISTANT_VAULT_DIR`, was added the same way and for the
|
|
2932
|
+
same reason: `get_concepts` read a module-level constant, so in the behaviour eval's temporary home it could only
|
|
2933
|
+
ever report "the knowledge base has not been compiled yet" - there was no fixture that could change that. With the
|
|
2934
|
+
root injectable it has a real case, as does `get_review` (its variable already existed, but the eval harness had no
|
|
2935
|
+
way to set environment variables at all, so it too had no case). **"That closed the last uncovered tool branch"
|
|
2936
|
+
above was true of the deterministic test suite, not of the behaviour eval** - the eval still had ten tools with no
|
|
2937
|
+
case at all, and those are what the two 2026-09-30 passes went after.
|
|
2938
|
+
|
|
2939
|
+
## D-057: A scene is bound to a conversation by the one id that already exists, it is picked by three tiers that refuse to guess, and the model cannot write one
|
|
2940
|
+
|
|
2941
|
+
**Status:** Active
|
|
2942
|
+
|
|
2943
|
+
**Decision.** A scene is a named prompt preset - keywords, an extra instruction, an output spec, a required-skill list,
|
|
2944
|
+
an enabled flag - stored in its own versioned file (`~/.weflow-cli/assistant_scenes.json`, schema `weflow-scenes/v1`,
|
|
2945
|
+
unknown version quarantined rather than migrated, atomic write, same discipline as the memory file). A turn picks at
|
|
2946
|
+
most one scene through three tiers in order: an explicit **binding**, then a **keyword** hit in the incoming text, then
|
|
2947
|
+
the **last scene used** in that conversation. No tier matching means no scene section in the prompt - the default
|
|
2948
|
+
behaviour is byte-identical. When two scenes match a keyword of the same length the turn carries **no** scene and the
|
|
2949
|
+
trace records why, following `resolveUniqueTalker` and `resolvePanelUserId`: ambiguous input is refused, never guessed.
|
|
2950
|
+
|
|
2951
|
+
**The binding key is `userId`, which is the conversation on the path that has conversations.** On the WeChat path
|
|
2952
|
+
`resolveInboundRouting` sets `conversationId` to the `@chatroom` id for a group and the peer's wxid for a direct chat,
|
|
2953
|
+
and `assistantService` passes exactly that through as `userId`; so binding by `userId` *is* binding by conversation, and
|
|
2954
|
+
it also gives the panel path (one stable bucket) something coherent to bind to. The cost is written down rather than
|
|
2955
|
+
discovered later: the MCP path collapses every caller into the literal `'mcp'`, so **MCP has no conversation to bind a
|
|
2956
|
+
scene to** - the same collapse that already applies to memory. Keying scenes per `userId` inside the *memory* file was
|
|
2957
|
+
rejected outright: that file is rewritten by an LLM during compression and fact extraction, and it can be quarantined
|
|
2958
|
+
wholesale, neither of which is an acceptable fate for something the user authored by hand.
|
|
2959
|
+
|
|
2960
|
+
**The model cannot touch scenes, and that is the point.** There is no assistant tool that lists, reads, creates or
|
|
2961
|
+
binds them; scenes are administered by `weflow-cli scene …` and by one built-in chat command (`场景 <id>`, `场景 无`,
|
|
2962
|
+
`场景`). This is the same position the `隐私` built-in takes - a chat message must not be able to change configuration -
|
|
2963
|
+
and it is why `capabilities --json` reports `modelWritable: false` for the workflow. The built-in command is also the
|
|
2964
|
+
only prefix-matching command in the assistant, which is precisely the shape that silently eats ordinary sentences, so
|
|
2965
|
+
it is narrowed twice: the argument must name an existing scene id, and anything else (`场景切换怎么用`) falls through to
|
|
2966
|
+
the model untouched.
|
|
2967
|
+
|
|
2968
|
+
**Reason:** the roadmap's P3 item asks for per-conversation control of the assistant rather than one global
|
|
2969
|
+
personality, and the three tiers are ordered by how much the user meant them: a binding is an explicit statement, a
|
|
2970
|
+
keyword is an inference from one message, and "last used" is only a convenience that keeps a habit from decaying. The
|
|
2971
|
+
refusal to break a tie is the same rule the codebase already applies to names - a coin flip between two scenes looks
|
|
2972
|
+
like the feature working while producing an answer nobody asked for.
|
|
2973
|
+
|
|
2974
|
+
**Consequences:** `scene add`/`remove`/`bind`/`unbind`/`enable`/`disable` are two-phase (`--dry-run` then `--yes`),
|
|
2975
|
+
matching every other local mutation. The scene section enters the prompt wrapped by `frameLocalData`, because a
|
|
2976
|
+
scene's instruction is user-authored text that can contain `</weflow-local-data>`; scene ids are therefore validated
|
|
2977
|
+
against quotes and angle brackets before they reach the label, and sanitised again at render time. Verified end to end
|
|
2978
|
+
through the resident service with a stubbed model: the scene section appears in the system prompt, the trace carries a
|
|
2979
|
+
`note` step naming the scene and the tier that matched, a keyword tie produces no section plus an explanatory note, and
|
|
2980
|
+
a turn with no match produces no section at all.
|
|
2981
|
+
|
|
2982
|
+
## D-056: A skill is material, not a plugin - it is referenced by path, it grants no tools, and a broken one is reported rather than skipped
|
|
2983
|
+
|
|
2984
|
+
**Status:** Active
|
|
2985
|
+
|
|
2986
|
+
**Decision.** The assistant can use skill packages: directories containing a `SKILL.md` with `name`/`description`
|
|
2987
|
+
frontmatter, scanned from a configurable list of roots (`skillDirs`, default `~/.claude/skills` and
|
|
2988
|
+
`~/.weflow-cli/skills`, overridable per-process by `WEFLOW_ASSISTANT_SKILL_DIRS`). Two read-only tools expose them -
|
|
2989
|
+
`list_skills` returns the catalogue (id, name, one-line description, version, state) and `read_skill` returns one
|
|
2990
|
+
body wrapped in `frameLocalData` - and the system prompt carries the catalogue so the model knows what exists. Skills
|
|
2991
|
+
are **not installed anywhere**: the prompt tells the model to read the file at its path, which is what the reference
|
|
2992
|
+
implementation does and is far cheaper than writing an installer per target agent. A referenced skill that is missing,
|
|
2993
|
+
disabled or unreadable is rendered as exactly that state, and the instruction is to continue and say which part was
|
|
2994
|
+
not done - never to silently drop the reference, and never to invent the capability.
|
|
2995
|
+
|
|
2996
|
+
**This is the constrained form of the roadmap's plugin/adapter item (P4), and it is deliberately the smallest one that
|
|
2997
|
+
is still useful.** Its acceptance criteria were already written down - extensions must not read configuration,
|
|
2998
|
+
databases or arbitrary files, and must be individually disableable - and a prompt-time document satisfies all of them by
|
|
2999
|
+
construction: `read_skill` can only return a file named `SKILL.md` that a scan already produced, so an id like
|
|
3000
|
+
`../config.json` is not a path at all, and disabling is one config key (`skillDisabled`) or one line in the skill's own
|
|
3001
|
+
frontmatter (`enabled: false`). Nothing here executes skill content, and installing a skill does not add a tool to the
|
|
3002
|
+
model's table. D-048 stands: the only way to add a *capability* is still to edit this source tree.
|
|
3003
|
+
|
|
3004
|
+
**Two rules were taken from real data rather than from the specification.** First, the id rule is **wider than
|
|
3005
|
+
Anthropic's**: that spec allows only lowercase letters, digits and single hyphens, which would reject five of the 28
|
|
3006
|
+
skills on the development machine (`clz_docx_to_mp`, `agent-dialog_management`, `clz_wechat_mp_ops`,
|
|
3007
|
+
`ylx_onehub_usage_monitor`, `ylx_research_evidence_synthesis`). Those are skills the user actually uses, so a
|
|
3008
|
+
non-conforming id is **warned about and kept**, and only the quoting characters that could escape a prompt label are
|
|
3009
|
+
refused. Second, the parser reads **only the leading `---` block** and understands block scalars: the same 28 files
|
|
3010
|
+
carry `categories:`/`created:`/`tags:` inside their bodies, so a whole-file scan reads body text as fields, and
|
|
3011
|
+
`description: >-` with indented continuation lines is how several of them are actually written - a naive line reader
|
|
3012
|
+
returns the two characters `>-` as the description.
|
|
3013
|
+
|
|
3014
|
+
**Consequences:** symlinked skill directories are **followed** (a deliberate choice, not an oversight): developing a
|
|
3015
|
+
skill in a git checkout and linking it into `~/.claude/skills` is a normal workflow, the link is placed by the user and
|
|
3016
|
+
not by an extension, and the only file reachable through it is one named `SKILL.md`. A skill with no frontmatter block
|
|
3017
|
+
is reported as unreadable by `skill check` instead of being skipped - a skill that vanishes silently is indistinguishable
|
|
3018
|
+
from one that was never installed. Verified against the real 28 skills with `skill check`: 0 unreadable, 0 collisions, 5
|
|
3019
|
+
spec-deviation warnings, 1 self-disabled.
|
|
3020
|
+
|
|
1403
3021
|
## D-055: The Vault's copy of an article is cleaned, the fetched original is not - and "which days does the copy run cover" was being decided by a marker that only one writer sets
|
|
1404
3022
|
|
|
1405
3023
|
**Status:** Active
|
|
@@ -1605,6 +3223,12 @@ reports the two skip reasons separately (a re-run versus a deliberate non-duplic
|
|
|
1605
3223
|
N" line would read as if all of them were re-runs. The alternative - keeping both pages, one per line - was
|
|
1606
3224
|
rejected: a browseable graph is the thing the user asked for, and an ambiguous link is not a link.
|
|
1607
3225
|
|
|
3226
|
+
**Superseded on 2026-10-10 by D-104.** The user asked for the two concept **namespaces** to be separate, so both
|
|
3227
|
+
pages are kept and that skip was removed. The Obsidian facts quoted above are still true and are quoted again in
|
|
3228
|
+
D-104 - what changed is the remedy: the ambiguity is now *reported* (`wiki lint`'s `crossLineSameName` section)
|
|
3229
|
+
rather than prevented by not creating the page. 36 such pairs already existed in the vault when this was written,
|
|
3230
|
+
so the invariant this paragraph describes was never true of the data on disk.
|
|
3231
|
+
|
|
1608
3232
|
**A migrated parameter is a silent failure too.** `conceptNeighbors(pageName, wikiDirs)` took one directory and
|
|
1609
3233
|
now takes a list; a caller still passing a string is not a type error at runtime, and `for (const dir of
|
|
1610
3234
|
wikiDirs)` walks the string **one character at a time**, so the function answers "no neighbours" with full
|