weflow-cli 1.16.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +19 -4
  3. package/bin/weflow-cli.ts +515 -6
  4. package/dist/bin/weflow-cli.js +580 -8
  5. package/dist/bin/weflow-cli.js.map +1 -1
  6. package/dist/src/core/ntCore.d.ts +47 -0
  7. package/dist/src/core/ntCore.d.ts.map +1 -1
  8. package/dist/src/core/ntCore.js +24 -0
  9. package/dist/src/core/ntCore.js.map +1 -1
  10. package/dist/src/services/assistantService.d.ts +8 -1
  11. package/dist/src/services/assistantService.d.ts.map +1 -1
  12. package/dist/src/services/assistantService.js +47 -8
  13. package/dist/src/services/assistantService.js.map +1 -1
  14. package/dist/src/services/assistantTools.d.ts +8 -0
  15. package/dist/src/services/assistantTools.d.ts.map +1 -1
  16. package/dist/src/services/assistantTools.js +67 -11
  17. package/dist/src/services/assistantTools.js.map +1 -1
  18. package/dist/src/services/configService.d.ts.map +1 -1
  19. package/dist/src/services/configService.js +3 -2
  20. package/dist/src/services/configService.js.map +1 -1
  21. package/dist/src/services/cursorProbe.d.ts +44 -0
  22. package/dist/src/services/cursorProbe.d.ts.map +1 -0
  23. package/dist/src/services/cursorProbe.js +183 -0
  24. package/dist/src/services/cursorProbe.js.map +1 -0
  25. package/dist/src/services/diagnosticReport.d.ts +6 -0
  26. package/dist/src/services/diagnosticReport.d.ts.map +1 -1
  27. package/dist/src/services/diagnosticReport.js +16 -0
  28. package/dist/src/services/diagnosticReport.js.map +1 -1
  29. package/dist/src/services/qchatImport.d.ts +89 -0
  30. package/dist/src/services/qchatImport.d.ts.map +1 -0
  31. package/dist/src/services/qchatImport.js +273 -0
  32. package/dist/src/services/qchatImport.js.map +1 -0
  33. package/dist/src/services/setupVenv.d.ts +43 -0
  34. package/dist/src/services/setupVenv.d.ts.map +1 -0
  35. package/dist/src/services/setupVenv.js +198 -0
  36. package/dist/src/services/setupVenv.js.map +1 -0
  37. package/dist/src/services/wechatCompat.d.ts +29 -0
  38. package/dist/src/services/wechatCompat.d.ts.map +1 -0
  39. package/dist/src/services/wechatCompat.js +65 -0
  40. package/dist/src/services/wechatCompat.js.map +1 -0
  41. package/dist/src/utils/python.d.ts +10 -0
  42. package/dist/src/utils/python.d.ts.map +1 -1
  43. package/dist/src/utils/python.js +27 -4
  44. package/dist/src/utils/python.js.map +1 -1
  45. package/docs/DATA_CONTRACT.md +25 -0
  46. package/docs/DECISIONS.md +118 -30
  47. package/docs/NT_DATABASES.md +47 -0
  48. package/docs/PROJECT_STATE.md +7 -3
  49. package/docs/RELEASING.md +4 -0
  50. package/docs/REPLAY.md +169 -0
  51. package/docs/SYNC_CONTRACT.md +197 -180
  52. package/package.json +89 -88
  53. package/resources/js/graph3d/viewer2d.js +550 -448
  54. package/scripts/annual_report.py +3 -1
  55. package/scripts/chat_notes.py +86 -3
  56. package/scripts/check_readme_commands.py +427 -426
  57. package/scripts/extract_todos.py +3 -1
  58. package/scripts/graph_2d.py +156 -151
  59. package/scripts/graph_3d.py +672 -646
  60. package/scripts/imported_chats.py +68 -0
  61. package/scripts/mcp_bridge.py +3 -1
  62. package/scripts/media_index.py +150 -0
  63. package/scripts/nt_common.py +32 -0
  64. package/scripts/nt_decrypt.py +154 -16
  65. package/scripts/on_this_day.py +178 -178
  66. package/scripts/promises.py +185 -185
  67. package/scripts/replay.py +803 -0
  68. package/scripts/run_hidden.vbs +25 -0
  69. package/scripts/semantic_search.py +78 -6
  70. package/scripts/wechat_avatar.py +185 -0
  71. package/scripts/wechat_ledger.py +190 -0
  72. package/src/core/ntCore.ts +59 -0
  73. package/src/services/assistantService.ts +48 -8
  74. package/src/services/assistantTools.ts +73 -10
  75. package/src/services/configService.ts +3 -2
  76. package/src/services/cursorProbe.ts +222 -0
  77. package/src/services/diagnosticReport.ts +22 -0
  78. package/src/services/qchatImport.ts +327 -0
  79. package/src/services/setupVenv.ts +238 -0
  80. package/src/services/wechatCompat.ts +67 -0
  81. package/src/utils/python.ts +33 -4
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wechatCompat.js","sourceRoot":"","sources":["../../../src/services/wechatCompat.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,OAAO,EAAE,WAAW;IACpB,UAAU,EAAE,YAAY;IACxB,WAAW,EAAE,uDAAuD;UAChE,qFAAqF;UACrF,gDAAgD;CAC5C,CAAA;AAQV;;;GAGG;AACH,MAAM,UAAU,qBAAqB,CAAC,IAA+B,EAAE,QAAgB;IACrF,MAAM,KAAK,GAAG,CAAC,CAAS,EAAmB,EAAE;QAC3C,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;QAC1B,IAAI,CAAC,KAAK,CAAC,MAAM,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;YAAE,OAAO,IAAI,CAAA;QACpE,OAAO,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;IAC1B,CAAC,CAAA;IACD,IAAI,CAAC,IAAI;QAAE,OAAO,SAAS,CAAA;IAC3B,MAAM,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC,CAAA;IACrB,MAAM,CAAC,GAAG,KAAK,CAAC,QAAQ,CAAC,CAAA;IACzB,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC;QAAE,OAAO,SAAS,CAAA;IAC9B,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,CAAA;IACxC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;QAC7B,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;QACnB,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;QACnB,IAAI,CAAC,GAAG,CAAC;YAAE,OAAO,qBAAqB,CAAA;QACvC,IAAI,CAAC,GAAG,CAAC;YAAE,OAAO,qBAAqB,CAAA;IACzC,CAAC;IACD,OAAO,UAAU,CAAA;AACnB,CAAC;AAED,mDAAmD;AACnD,MAAM,UAAU,qBAAqB,CAAC,OAAsB,EAAE,eAAuB;IACnF,QAAQ,OAAO,EAAE,CAAC;QAChB,KAAK,UAAU;YACb,OAAO,oBAAoB,eAAe,EAAE,CAAA;QAC9C,KAAK,qBAAqB;YACxB,OAAO,gBAAgB,eAAe,oBAAoB;kBACtD,qBAAqB,CAAA;QAC3B,KAAK,qBAAqB;YACxB,OAAO,gBAAgB,eAAe,oBAAoB,CAAA;QAC5D;YACE,OAAO,gCAAgC,eAAe,EAAE,CAAA;IAC5D,CAAC;AACH,CAAC"}
@@ -1,2 +1,12 @@
1
+ /** `weflow-cli setup` 建的自管 venv 的解释器路径;没建过就是 null。 */
2
+ export declare function managedVenvPython(): string | null;
3
+ /**
4
+ * 候选序:自管 venv 永远排最前,其余按调用方给的序。纯函数,供测试钉住
5
+ * "建了 venv 就该优先用它"这件事——它坏了的表现是 setup 装好了包、
6
+ * 运行时却仍在用 PATH 上的解释器,两边各自"正常"。
7
+ */
8
+ export declare function orderCandidates(managed: string | null, rest: string[]): string[];
1
9
  export declare function getPythonCommand(): string;
10
+ /** setup 装完后清缓存,让同一进程里后续的 getPythonCommand 看见新 venv。 */
11
+ export declare function clearPythonCommandCache(): void;
2
12
  //# sourceMappingURL=python.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"python.d.ts","sourceRoot":"","sources":["../../../src/utils/python.ts"],"names":[],"mappings":"AAqEA,wBAAgB,gBAAgB,IAAI,MAAM,CAGzC"}
1
+ {"version":3,"file":"python.d.ts","sourceRoot":"","sources":["../../../src/utils/python.ts"],"names":[],"mappings":"AAoBA,sDAAsD;AACtD,wBAAgB,iBAAiB,IAAI,MAAM,GAAG,IAAI,CAKjD;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,MAAM,GAAG,IAAI,EACtB,IAAI,EAAE,MAAM,EAAE,GACb,MAAM,EAAE,CAEV;AAuDD,wBAAgB,gBAAgB,IAAI,MAAM,CAGzC;AAED,wDAAwD;AACxD,wBAAgB,uBAAuB,IAAI,IAAI,CAE9C"}
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from 'child_process';
2
2
  import { delimiter, join } from 'path';
3
- import { accessSync, constants } from 'fs';
3
+ import { accessSync, constants, existsSync } from 'fs';
4
+ import { homedir } from 'os';
4
5
  let cached = null;
5
6
  const EXE_NAMES = process.platform === 'win32'
6
7
  ? ['python.exe', 'python3.exe', 'py.exe']
@@ -14,6 +15,21 @@ function isExecutable(path) {
14
15
  return false;
15
16
  }
16
17
  }
18
+ /** `weflow-cli setup` 建的自管 venv 的解释器路径;没建过就是 null。 */
19
+ export function managedVenvPython() {
20
+ const exe = process.platform === 'win32'
21
+ ? join(homedir(), '.weflow-cli', 'venv', 'Scripts', 'python.exe')
22
+ : join(homedir(), '.weflow-cli', 'venv', 'bin', 'python');
23
+ return existsSync(exe) ? exe : null;
24
+ }
25
+ /**
26
+ * 候选序:自管 venv 永远排最前,其余按调用方给的序。纯函数,供测试钉住
27
+ * "建了 venv 就该优先用它"这件事——它坏了的表现是 setup 装好了包、
28
+ * 运行时却仍在用 PATH 上的解释器,两边各自"正常"。
29
+ */
30
+ export function orderCandidates(managed, rest) {
31
+ return managed ? [managed, ...rest.filter(c => c !== managed)] : [...rest];
32
+ }
17
33
  /** 按顺序返回 PATH 中所有可用的 Python 解释器(含命令名候选) */
18
34
  function candidates() {
19
35
  const found = [];
@@ -52,14 +68,17 @@ function hasSqlcipher(cmd) {
52
68
  }
53
69
  }
54
70
  function detect() {
71
+ // 自管 venv(`weflow-cli setup`)优先:它就是为了"这台机器有一个能
72
+ // import sqlcipher3 的 Python"而存在的。venv 坏了(装完又被卸了包)会
73
+ // 自然落到下面的 PATH 候选——行为退回 setup 之前,而不是更糟。
74
+ const ordered = orderCandidates(managedVenvPython(), candidates());
55
75
  // 优先选择能 import sqlcipher3 的解释器(NT 解密核心依赖),
56
76
  // 避免命中 PATH 中仅有基础库的裸 Python 环境(如被 IDE 注入的 VM Python 抢占)。
57
- const list = candidates();
58
- for (const cmd of list) {
77
+ for (const cmd of ordered) {
59
78
  if (canRun(cmd) && hasSqlcipher(cmd))
60
79
  return cmd;
61
80
  }
62
- for (const cmd of list) {
81
+ for (const cmd of ordered) {
63
82
  if (canRun(cmd))
64
83
  return cmd;
65
84
  }
@@ -69,4 +88,8 @@ export function getPythonCommand() {
69
88
  cached ??= detect();
70
89
  return cached;
71
90
  }
91
+ /** setup 装完后清缓存,让同一进程里后续的 getPythonCommand 看见新 venv。 */
92
+ export function clearPythonCommandCache() {
93
+ cached = null;
94
+ }
72
95
  //# sourceMappingURL=python.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"python.js","sourceRoot":"","sources":["../../../src/utils/python.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"python.js","sourceRoot":"","sources":["../../../src/utils/python.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AAC5C,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AACtC,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,IAAI,CAAA;AACtD,OAAO,EAAE,OAAO,EAAE,MAAM,IAAI,CAAA;AAE5B,IAAI,MAAM,GAAkB,IAAI,CAAA;AAEhC,MAAM,SAAS,GAAG,OAAO,CAAC,QAAQ,KAAK,OAAO;IAC5C,CAAC,CAAC,CAAC,YAAY,EAAE,aAAa,EAAE,QAAQ,CAAC;IACzC,CAAC,CAAC,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAA;AAEzB,SAAS,YAAY,CAAC,IAAY;IAChC,IAAI,CAAC;QACH,UAAU,CAAC,IAAI,EAAE,SAAS,CAAC,IAAI,CAAC,CAAA;QAChC,OAAO,IAAI,CAAA;IACb,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC;AAED,sDAAsD;AACtD,MAAM,UAAU,iBAAiB;IAC/B,MAAM,GAAG,GAAG,OAAO,CAAC,QAAQ,KAAK,OAAO;QACtC,CAAC,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,aAAa,EAAE,MAAM,EAAE,SAAS,EAAE,YAAY,CAAC;QACjE,CAAC,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,aAAa,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAA;IAC3D,OAAO,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAA;AACrC,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAC7B,OAAsB,EACtB,IAAc;IAEd,OAAO,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,CAAA;AAC5E,CAAC;AAED,2CAA2C;AAC3C,SAAS,UAAU;IACjB,MAAM,KAAK,GAAa,EAAE,CAAA;IAC1B,MAAM,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;IACtE,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;QACvB,KAAK,MAAM,IAAI,IAAI,SAAS,EAAE,CAAC;YAC7B,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,CAAA;YAC5B,IAAI,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;gBAChD,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;YAClB,CAAC;QACH,CAAC;IACH,CAAC;IACD,sCAAsC;IACtC,KAAK,MAAM,IAAI,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,EAAE,QAAQ,CAAC,EAAE,CAAC;QACtG,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC;YAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAC7C,CAAC;IACD,OAAO,KAAK,CAAA;AACd,CAAC;AAED,SAAS,MAAM,CAAC,GAAW;IACzB,IAAI,CAAC;QACH,YAAY,CAAC,GAAG,EAAE,CAAC,WAAW,CAAC,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAA;QACvF,OAAO,IAAI,CAAA;IACb,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC;AAED,SAAS,YAAY,CAAC,GAAW;IAC/B,IAAI,CAAC;QACH,YAAY,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,mBAAmB,CAAC,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAA;QACtG,OAAO,IAAI,CAAA;IACb,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC;AAED,SAAS,MAAM;IACb,+CAA+C;IAC/C,oDAAoD;IACpD,wCAAwC;IACxC,MAAM,OAAO,GAAG,eAAe,CAAC,iBAAiB,EAAE,EAAE,UAAU,EAAE,CAAC,CAAA;IAClE,2CAA2C;IAC3C,yDAAyD;IACzD,KAAK,MAAM,GAAG,IAAI,OAAO,EAAE,CAAC;QAC1B,IAAI,MAAM,CAAC,GAAG,CAAC,IAAI,YAAY,CAAC,GAAG,CAAC;YAAE,OAAO,GAAG,CAAA;IAClD,CAAC;IACD,KAAK,MAAM,GAAG,IAAI,OAAO,EAAE,CAAC;QAC1B,IAAI,MAAM,CAAC,GAAG,CAAC;YAAE,OAAO,GAAG,CAAA;IAC7B,CAAC;IACD,OAAO,QAAQ,CAAA;AACjB,CAAC;AAED,MAAM,UAAU,gBAAgB;IAC9B,MAAM,KAAK,MAAM,EAAE,CAAA;IACnB,OAAO,MAAM,CAAA;AACf,CAAC;AAED,wDAAwD;AACxD,MAAM,UAAU,uBAAuB;IACrC,MAAM,GAAG,IAAI,CAAA;AACf,CAAC"}
@@ -72,3 +72,28 @@ This is a best-effort synchronization recipe. It must not treat a timestamp as a
72
72
  ## Future Direction
73
73
 
74
74
  The existing CLI export is the compatibility layer for the first integration. The same contract is available through the versioned, read-only MCP tool `wechat.export_messages`. It accepts `contact` and optional `limit`, `from`, and `to` values, returns JSON directly, and never returns database paths or keys. It preserves the same authorization, local-only default, field semantics, and privacy rules rather than bypassing the CLI boundary.
75
+
76
+ ## QQ Imports (qchat-cli)
77
+
78
+ A second upstream now feeds this contract's vocabulary: `weflow-cli import qchat <file-or-dir>`
79
+ reads a `qchat-cli` JSON export (`qce export --format json`) and lands it in a local private
80
+ store `output/imports/qq/sessions/` (schema `weflow-import/qq-v1`, D-129). The store itself is
81
+ not a stable contract; what is stable is the **field semantics**, which are exactly
82
+ `weflow-message/v1`'s:
83
+
84
+ | qchat-cli field | imported as | Notes |
85
+ | --- | --- | --- |
86
+ | `messageId` | `serverId` | Server-side globally unique (OneBot) - dedup/merge keys on it, no overlapping-window dance needed. |
87
+ | `timestamp` | `createTime` | Unix seconds. |
88
+ | `sender.id` | `senderUsername` | A QQ number; treat as sensitive, same level as a wxid. |
89
+ | `sender.card` / `sender.name` | `senderName` | Group card first, then nickname. |
90
+ | `content` / `rawMessage` | `parsedContent` / `rawContent` | Same guidance as the table above. |
91
+ | (derived from `segments`) | `messageType` | OneBot segment types map onto the same stable names (`record→voice`, `reply→quote`, `json/xml→card`, …). |
92
+ | — | `isSend` | Only present when the import ran with `--own-id`; absent means unknown, not false. |
93
+
94
+ Consumers wired today (both opt-in, defaults unchanged): `chat-notes --source qq|all` produces
95
+ QQ knowledge cards (names prefixed `QQ-`, same card shape and same downstream wiki consumer as
96
+ WeChat conversations), and `search-index --include-imports` adds QQ sessions to the semantic
97
+ index (record ids carry a `chat:qq:` segment, so a record's origin is always readable from the
98
+ id; the phrase index derives from the semantic index and follows). Querying QQ sessions through
99
+ `sessions` / `messages` is deliberately not part of this round (D-129).
package/docs/DECISIONS.md CHANGED
@@ -4017,33 +4017,121 @@ State the decision in one or two sentences.
4017
4017
 
4018
4018
  **Consequences:** List compatibility, migration, security, or documentation follow-up when relevant.
4019
4019
  ```
4020
-
4021
- ## D-127: 决策模型可以走本机(`judgeProvider=local`),概率默认当作未校准
4022
-
4023
- **Decision.** 新增第四条判断通路 `judgeProvider=local`:`LocalJevClient` 把本机决策模型服务
4024
- (`decide.py serve`,APUS Reflex 4B + llama-server 的 `/decide`)当 Jev 用,**不需要 key、不联网**。
4025
- `score` 没有连续分,改用 `choice` 在有序档位上选、把选中档位的下标当分数(下游 `score_to_relevance`
4026
- 本来就是四舍五入取整,语义等价)。
4027
-
4028
- **Reason.** 原生 `api.typesafe.ai` 在本机被地区封锁(D-120),`chat` 与 `openrouter` 都要把文章正文
4029
- 发给第三方。本机 4B 决策模型在官方 Frozen80 上 **66/80 = 82.50%**、**0.3 s/题**(2026-10-22 本机实测,
4030
- RTX 5060 8GB),对"主题 / 相关度 / 该不该收录"这类**窄口径、可验证**的判断够用,而正文不出机器。
4031
-
4032
- **Consequences.**
4033
- - 本机服务的概率是**候选集上的相对 softmax、未校准**(APUS 契约原文)。`confidence` 只作参考,
4034
- 不能直接当自动放行阈值;要用阈值先在自有样本上标定。
4035
- - **一个问题一次 HTTP**:原生 `systemone` 一次答一组,本机 `/decide` 一次只答一个 primitive。
4036
- 日报 3 个问题 ⇒ 3 次调用,实测整篇 0.3–0.5 s(本机回环)。
4037
- - `score` 用 **n-1 次阈值判断**(`达到「中」或「高」吗?`)合成档位,不是三选一:
4038
- 同一批 44 篇(2026-10-16~21,Jev 实判)上,三选一对 Jev 的一致率 45.5%(`低→中` 23 次,
4039
- 系统性偏高),阈值问法 **72.7%**、平均差 0.55 → 0.27 档。**措辞影响巨大**(同一模型同一批样本:
4040
- 纯判据句 59.1%、带主语 + 档位 + 判据 72.7%),改这句话要重测。
4041
- - 服务没起时抛 `JevError`(调用方按既有降级路径退到 LLM 路径),**不静默改用云端**。
4042
- - 新增配置键 `judgeLocalUrl`(默认 `http://127.0.0.1:8090`),已登记进 `CliConfig` 与 `config set`。
4043
- - **按主题的相关度下限(实测校准)**:211 篇同题对比(2026-10-16~21,Jev 实判)显示本机模型对
4044
- `AI` 类文章的相关度**系统性低估一档**(该类一致率 20.0%,`低→中` 33 次;政治/文学/新闻 90%+)。
4045
- `LocalJevClient.TOPIC_RELEVANCE_FLOOR = {'AI': 1}` 只把 `AI` 的 `低` 抬到 `中`:
4046
- 该类 77.5%、总体 73.9% → 84.4%(68 篇复测 80.9%)。统一 +1 只有 82.9% 且会越界,故不采用。
4047
- 这张表是**数据产物**:换语料/换模型要重测,`usage.calibration` 会落盘,便于事后核对。
4048
- - 收录阈值:Jev 0.5 时收录 32/211;本机同阈值收录 30 篇、判定一致 86.7%;降到 0.35 收录 32 篇、
4049
- 一致率不变 —— 要与 Jev 的"收录量"对齐就把阈值降到 0.35,要更保守就留 0.5。
4020
+
4021
+ ## D-127: 决策模型可以走本机(`judgeProvider=local`),概率默认当作未校准
4022
+
4023
+ **Decision.** 新增第四条判断通路 `judgeProvider=local`:`LocalJevClient` 把本机决策模型服务
4024
+ (`decide.py serve`,APUS Reflex 4B + llama-server 的 `/decide`)当 Jev 用,**不需要 key、不联网**。
4025
+ `score` 没有连续分,改用 `choice` 在有序档位上选、把选中档位的下标当分数(下游 `score_to_relevance`
4026
+ 本来就是四舍五入取整,语义等价)。
4027
+
4028
+ **Reason.** 原生 `api.typesafe.ai` 在本机被地区封锁(D-120),`chat` 与 `openrouter` 都要把文章正文
4029
+ 发给第三方。本机 4B 决策模型在官方 Frozen80 上 **66/80 = 82.50%**、**0.3 s/题**(2026-10-22 本机实测,
4030
+ RTX 5060 8GB),对"主题 / 相关度 / 该不该收录"这类**窄口径、可验证**的判断够用,而正文不出机器。
4031
+
4032
+ **Consequences.**
4033
+ - 本机服务的概率是**候选集上的相对 softmax、未校准**(APUS 契约原文)。`confidence` 只作参考,
4034
+ 不能直接当自动放行阈值;要用阈值先在自有样本上标定。
4035
+ - **一个问题一次 HTTP**:原生 `systemone` 一次答一组,本机 `/decide` 一次只答一个 primitive。
4036
+ 日报 3 个问题 ⇒ 3 次调用,实测整篇 0.3–0.5 s(本机回环)。
4037
+ - `score` 用 **n-1 次阈值判断**(`达到「中」或「高」吗?`)合成档位,不是三选一:
4038
+ 同一批 44 篇(2026-10-16~21,Jev 实判)上,三选一对 Jev 的一致率 45.5%(`低→中` 23 次,
4039
+ 系统性偏高),阈值问法 **72.7%**、平均差 0.55 → 0.27 档。**措辞影响巨大**(同一模型同一批样本:
4040
+ 纯判据句 59.1%、带主语 + 档位 + 判据 72.7%),改这句话要重测。
4041
+ - 服务没起时抛 `JevError`(调用方按既有降级路径退到 LLM 路径),**不静默改用云端**。
4042
+ - 新增配置键 `judgeLocalUrl`(默认 `http://127.0.0.1:8090`),已登记进 `CliConfig` 与 `config set`。
4043
+ - **按主题的相关度下限(实测校准)**:211 篇同题对比(2026-10-16~21,Jev 实判)显示本机模型对
4044
+ `AI` 类文章的相关度**系统性低估一档**(该类一致率 20.0%,`低→中` 33 次;政治/文学/新闻 90%+)。
4045
+ `LocalJevClient.TOPIC_RELEVANCE_FLOOR = {'AI': 1}` 只把 `AI` 的 `低` 抬到 `中`:
4046
+ 该类 77.5%、总体 73.9% → 84.4%(68 篇复测 80.9%)。统一 +1 只有 82.9% 且会越界,故不采用。
4047
+ 这张表是**数据产物**:换语料/换模型要重测,`usage.calibration` 会落盘,便于事后核对。
4048
+ - 收录阈值:Jev 0.5 时收录 32/211;本机同阈值收录 30 篇、判定一致 86.7%;降到 0.35 收录 32 篇、
4049
+ 一致率不变 —— 要与 Jev 的"收录量"对齐就把阈值降到 0.35,要更保守就留 0.5。
4050
+
4051
+
4052
+ ## D-128: 入站图片可以给助手看——它是一类新的出境,门与 D-042 同构
4053
+
4054
+ **Status:** Active
4055
+
4056
+ **Decision.** Bot 通道收到、且已按 `wechatMediaDownload=true` 落盘解密(D-042 的下载侧)的**图片**,
4057
+ 助手可以用 `look_at_image` 的 `#信N` 句柄查看。门与 D-042 看聊天图片的那道门**同构、双层**:
4058
+ strict 模式(且非本地推理)下工具拒绝取图、`toApiMessages` 再拦一层;balanced / open 放行;
4059
+ `IMAGE_SENT` / `IMAGE_HELD` 审计与 `MAX_IMAGES_PER_TURN` 预算**共用**,不分来源。语音、视频、
4060
+ 文件的消费**不在本决策内**——各自需要转写 / 压缩 / 解析,是另外的出境类别,不搭车。
4061
+
4062
+ **Reason.** 图片消息此前整条被忽略(日志里的「忽略非文本消息 (image)」),而文件其实已经在
4063
+ 本机磁盘上、下载本身已是显式 opt-in(`wechatMediaDownload` 默认 false)。但 D-042 立过的规矩
4064
+ 仍然成立:「一句关于聊天的话」与「聊天里的那张照片」不是同一种披露——入站图片是**别人发给
4065
+ 用户的照片**,比聊天截图又多一层:出境的内容甚至不是用户自己写的。所以决策先行、范围收窄:
4066
+ 仅图片、仅 balanced / open(strict + 本地推理等于不出机器,照放)、句柄只在能看时展示
4067
+ (与 `get_messages` 的 `#N` 同一规则——不摆出一个工具必定会拒绝的东西)。
4068
+
4069
+ **Consequences.**
4070
+ - 一条**只有图片**的来信从此会产生一轮(此前被忽略):文本占位「(发来一张图片)」,白名单、
4071
+ 配额、串行队列照常走;未开下载或下载失败(`filePath` 为空)的图片消息**仍被忽略**,行为不变。
4072
+ - `#信N` 只指本轮来信自带的图片;`#N` 仍指聊天记录里的 `local_id`。两个句柄空间不混——
4073
+ 前者是磁盘上现成的文件,后者要跑 `read_image.py` 建媒体索引,成本差一个数量级。
4074
+ - 图片**不缩放**,超过 4 MB 直接说「太大」:入站文件是证据形态,缩了再给模型看,模型说的
4075
+ 就不是那张图(导出路径做缩放是因为那是**给人看**的页面,两者方向相反)。
4076
+ - MIME 从文件头魔数判定(png / jpeg / webp),不信任文件名扩展名——文件名来自服务端报文。
4077
+ - MCP 面不变:`look_at_image` 本来就不在 MCP 表里(文本传输接不到侧信道的图)。
4078
+ - 审计 `IMAGE_SENT` 的 `ids=` 对入站图片为空(没有 local_id)——`n=` 照记,能对上"这轮发了几张"。
4079
+
4080
+
4081
+ ## D-129: qchat-cli 的消息从**文件契约**进来,不做进程耦合,也不当第五个消息后端
4082
+
4083
+ **Status:** Active
4084
+
4085
+ **Decision.** `weflow-cli import qchat <文件|目录>` 读 qchat-cli `qce export --format json`
4086
+ 的产物,归一化落到本地私有存储 `output/imports/qq/sessions/`(schema `weflow-import/qq-v1`,
4087
+ 字段语义与 `weflow-message/v1` 对齐:serverId/createTime/parsedContent/rawContent 与 13 个稳定
4088
+ messageType)。下游两条已落地的管道各开一个**显式开关**:`chat-notes --source qq|all` 产 QQ
4089
+ 知识卡(卡片名带 `QQ-` 前缀,与微信卡同一形状、同一消费端),`search-index --include-imports`
4090
+ 把 QQ 会话收进语义索引(记录 id 带 `chat:qq:` 段,与微信记录天然不撞;短语索引从语义索引
4091
+ 派生,自动跟上)。默认行为逐字不变。
4092
+
4093
+ **Reason.** 两个仓库的数据获取层性质不同(weflow 直读本地加密库、离线可查;qchat 走
4094
+ NapCatQQ/OneBot 在线服务)。让 weflow 直连 OneBot 当第五个 `chatService` 后端,等于把 qchat
4095
+ 的整个客户端层复刻进 weflow、两个工具抢同一个轮询——且 `chatService` 的四个后端全是"解本地
4096
+ 库"的,混进一个在线 API 客户端会改变这层的性质。文件契约让边界可枚举、可版本化,也正是
4097
+ ROADMAP P1 给 she-love-me 预留的同一条路;qchat-cli 因此**一行不用改**。
4098
+
4099
+ **Consequences.**
4100
+ - QQ 的 `messageId` 是服务端全局唯一:去重/合并按它做,**不需要**微信侧那套重叠时间窗;
4101
+ 对 D-027 而言 qq 来源天然满足 stable-identity 判据。
4102
+ - `isSend` 只在导入时给了 `--own-id` 才写;缺省就是未知,消费方按未知处理(知识卡的行
4103
+ 标签落到群名片/昵称)。不为"好看"猜方向。
4104
+ - 存储是 **weflow 的私有形状**,不承诺稳定;对外承诺的边界是 `weflow-message/v1` 那套
4105
+ 字段语义。将来 qchat 侧产出中立契约(如 `im-message/v1`)时,读取器多认一个 schema 即可。
4106
+ - 查询层(`sessions`/`messages` 按 QQ 会话直查、per-source 路由)**明确不在本决策内**:
4107
+ 等真实需求出现再做,届时 QQ 号与 wxid 的命名空间隔离(`qq:` 前缀已备好)是第一件事。
4108
+ - 隐私边界对齐:导入的是用户本人授权的 QQ 数据(qchat 侧靠 OneBot 授权),落盘后继承
4109
+ weflow 的纪律——QQ 号与 wxid 同级敏感;知识卡与索引沿用各自既有的出境确认门。
4110
+ - `--source all` 在微信库不可用时**降级**为只处理 QQ(说一声,不是静默);`build
4111
+ --include-imports` 在没有微信库的机器上也能建索引(纯 QQ 用户是真实场景)。
4112
+
4113
+ ## D-130: 验证过的微信版本有单一事实源,check 主动比对,发布必须记录
4114
+
4115
+ **Status:** Active
4116
+
4117
+ **Decision.** 本包"最后用真实数据验证过的微信版本"只写在 `src/services/wechatCompat.ts` 的
4118
+ `WECHAT_VERIFIED` 一处(版本 + 日期 + 验证方式)。三条接线全部只读它:`capabilities --json`
4119
+ 的 `compatibility.wechat`(机器可读)、`check`(含 `--json` / `--report` / 人类输出)拿本机
4120
+ **实际加载**的版本与之比对给出四态判定(verified / newer-than-verified / older-than-verified /
4121
+ unknown——"微信没在跑"是 unknown,不是"没问题")、`docs/RELEASING.md` 的发布清单要求每个
4122
+ CHANGELOG 版本段记录验证版本(不涉及微信数据读取的版本写明"本版不涉及")。
4123
+
4124
+ **Reason.** 微信 4.x 几周一更,而"这版验证过哪个微信"原来只活在 PROJECT_STATE 的一段散文里:
4125
+ CHANGELOG 不记、机器读不到。用户升级微信后读不出数据时,第一个该被回答的问题("你的微信
4126
+ 比我们验证过的新吗")没有任何面能回答——issue 里的反复沟通就是这么来的。check 本来就在探测
4127
+ 实际加载的版本(D-107/D-125),差的只是把两边的数字放到一起说一句话。
4128
+
4129
+ **Consequences.**
4130
+ - 更新 `WECHAT_VERIFIED` 的门槛是**一次真实数据验证**(不只是"进程起来了"),验证日期与
4131
+ 读了什么写进 `verifiedHow`;没验证不许改——这正是常量存在的意义。
4132
+ - `compareWechatVersions` 是判定的唯一实现(纯函数:四段数字逐段比、缺段补零、形状不猜),
4133
+ 测试钉住四种判定与边界。
4134
+ - `newer-than-verified` 在 `check --report` 里升为 warning(它就是"读不出数据"的第一嫌疑人),
4135
+ 人类输出与 report 的措辞共用 `describeCompatVerdict` / 渲染器里的一份,不各写一套。
4136
+ - 判定是**对"本机此刻"的陈述**,不是兼容性承诺:说"verified"只是"这个组合验证过",
4137
+ "older"一般兼容但没有保证——措辞已按此收敛。
@@ -0,0 +1,47 @@
1
+ # NT 数据库普查(db_storage 全量清单)
2
+
3
+ > 普查日期:2026-10-26,本机微信 4.1.15.56(D-130 verified)。方法:逐库用全库口令派生钥匙打开,
4
+ > 只读 `sqlite_master` / `PRAGMA table_info` / `COUNT(*)`,不导出行内容。脚本一次性(`probe_dbs.py` 用后即删),
5
+ > 本文档是留存。**行数是本机的观测值**,别的机器数量不同、表结构可能随版本变。
6
+
7
+ ## 已解析(代码里有读取方)
8
+
9
+ | 库 | 读取方 | 备注 |
10
+ | --- | --- | --- |
11
+ | `message/message_*.db` | `nt_decrypt` / 导出 / 全家 | 消息分片(按时间切) |
12
+ | `message/biz_message_0.db` | `_utils.biz_message_db()` | 公众号推送 |
13
+ | `contact/contact.db` | 联系人 / 群 ext_buffer | 结构笔记见 CONTACT_DB_SCHEMA.md |
14
+ | `favorite/favorite.db` | `fav` 命令族 | 收藏 |
15
+ | `sns/sns.db` | `sns` 命令族 | 朋友圈缓存 |
16
+ | `message/message_fts.db` | `route_cards`(只 `LIKE`,不 `MATCH`) | 分词器 MMFtsTokenizer 不可用 |
17
+
18
+ ## 未解析(2026-10-26 普查新增,按价值排序)
19
+
20
+ | 库 | 表 / 行数(本机) | 是什么 | 价值判断 |
21
+ | --- | --- | --- | --- |
22
+ | `head_image/head_image.db` | `head_image(1083)`:`username, md5, image_buffer, update_time` | **头像本体**:JPEG/PNG 完整图片字节内嵌库中(实测 magic:1056 个 `FFD8FFE0`、27 个 `89504E47`),按 username 键控,一人多行 = 头像变更历史 | **高**:导出/报告/replay 配真实头像;文件缓存清理会丢,库里永远在。**已实现 `weflow-cli avatar`** |
23
+ | `general/general.db` | `redEnvelopeTable(15)`、`transferTable(22)`、`revokemessage`、`websearch_record(81)`、`SearchRecent(36)`、`biz_subscribe_status(41)` 等 24 张 | **资金流与行为**:红包(hb_status/hb_type/receive_status)、转账(pay 双方/时间)、撤回记录、**微信内置搜索历史**、公众号订阅状态 | **高**:红包/转账与 `leads`/`reply-debt` 的"涉钱"判断同域;搜索历史是"我在找什么"的维度 |
24
+ | `hardlink/hardlink.db` | `image_hardlink_info_v4(4638)`、`file_(1972)`、`video_(128)`:`md5, file_name, file_size, dir1, dir2` + `dir2id(123)` | **媒体文件索引**:md5 → 实际文件路径(目录 id 经 `dir2id` 映射)。D-107 提到的 `hardlink.dat`(16 字节)与它不是一回事 | **高**:当前媒体匹配靠启发式(D-014),这张表可能把它变成精确查找——覆盖率问题的正解候选 |
25
+ | `emoticon/emoticon.db` | `kNonStoreEmoticonTable(75)`:`md5, caption, aes_key, thumb_url, cdn_url`、`kStoreEmoticonPackageTable(5)` 等 | **表情库**:自定义表情的 **aes_key 直接在表里**、CDN 地址、表情包清单 | **高**:自定义表情导出目前靠"扫内存反推 seed"——这张表可能让那条 hack 退役 |
26
+ | `message/media_0/1.db` | `VoiceInfo(1779+346)`:`chat_name_id, create_time, local_id, svr_id, voice_data, data_index` + `Name2Id` | **语音本体**:`voice_data` 内嵌库中(实测首字节 `0x0223`,**不是**标准 SILK 头 `#!SILK`/`TwoLL`,格式待研究) | 中:若解出格式,语音转写不再依赖 msg/voice 文件缓存 |
27
+ | `session/session.db` | `SessionTable(709)`:`summary, unread_count, status, is_hidden…`、`SessionUnreadListTable_1(27021)`、`SessionDeleteTable(6)` | **会话表**:未读/摘要/隐藏/草稿;`SessionDeleteTable` 记录**用户何时删了会话**(取证价值) | 中:`sessions` 命令现从消息分片推导,这里是一手表 |
28
+ | `solitaire/solitaire.db` | `SolitaireFold/Valid_<md5>`(19+21):`front/behind_content, title` | **群接龙**:内容与参与记录 | 低(除非做接龙消息导出) |
29
+ | `contact/contact_fts.db`、`favorite/favorite_fts.db` | FTS5 结构(content/data/docsize/idx) | 联系人/收藏的搜索索引(衍生库) | 低:可从主库重建;分词器同样不可用 |
30
+ | `bizchat/bizchat.db` | `name2id(2)`、`chat_group(0)` | 企业微信联系人(本机几乎没用) | 低 |
31
+ | `chatbot/chatbot_message.db` | 4 张表全 0 行 | 微信内置 AI 助手的消息(本机未用) | 低 |
32
+ | `third_app_icon/third_app_icon.db` | 0 行 | 第三方应用图标 | 无 |
33
+ | `message/weclaw.db` | `wcdb_builtin_compression_record(0)` | WCDB 压缩簿记——**解开了 bonds 过滤 `@weclaw` 的疑惑**:它是客户端内部机制名 | 无(但解释了一个旧过滤项) |
34
+
35
+ ## 钥匙
36
+
37
+ 全部库用同一派生链打开(`nt_common.derive_database_key`:全库口令 PBKDF2 → 每库 salt),本次普查
38
+ 14 个库全部一次打开成功——**NT 库没有按库独立的钥匙体系**,`emoticon.aes_key` 那类字段是内容层
39
+ (表情包 CDN 下载)的钥匙,不是库钥匙。
40
+
41
+ ## 下一步候选
42
+
43
+ 1. ✅ `avatar`(head_image)——已实现,见 `weflow-cli avatar`。
44
+ 2. `general.db` 的红包/转账 → 与 leads/reply-debt 联动的"资金线索"只读命令。
45
+ 3. `hardlink.db` → 媒体精确匹配(替换启发式,动 export 的媒体索引,工程量最大)。
46
+ 4. `emoticon.db` 的 aes_key → 自定义表情导出去内存扫描化。
47
+ 5. `media_*.db` 的 `voice_data` 格式 → 语音转写免文件缓存(格式未解,先研究)。
@@ -40,7 +40,7 @@ WeFlow CLI is a local-first command-line tool and MCP server for user-authorized
40
40
  | Area | Current state | Main entry points |
41
41
  | --- | --- | --- |
42
42
  | Local chat data | Query sessions, contacts, messages, favorites, Moments cache, and exports. HTML chat exports decode NT compressed media records, locally cached WeChat 4.x V2 image containers, entity-escaped emoji XML, and remote emoticons; app cards preserve links and embed available公众号/Bilibili covers. Media matching prefers server-message resources, exact local-message-ID plus timestamp pairs, and a unique local-ID fallback; high-bit encoded image rows are resolved through the same conversation cache. | `sessions`, `contacts`, `messages`, `export`, `fav`, `sns` |
43
- | Initialization | Verify and reuse existing local database access by default; refresh only when needed. Missing-key tests can run without changing saved configuration. Data-root discovery asks the **running client** first (its command line carries `--wechat-files-path`; the other two routes guess from the folder name) and reports which source won; a hint still has to pass the same structural check. | `init`, `init --refresh`, `init --test-missing-keys`, `config forget-keys`, `check` |
43
+ | Initialization | Verify and reuse existing local database access by default; refresh only when needed. Missing-key tests can run without changing saved configuration. Data-root discovery asks the **running client** first (its command line carries `--wechat-files-path`; the other two routes guess from the folder name) and reports which source won; a hint still has to pass the same structural check. **`weflow-cli setup` (2026-10-23)** builds a managed venv at `~/.weflow-cli/venv` and installs requirements into it; `getPythonCommand()` prefers that venv whenever it exists and passes the `import sqlcipher3` probe, falling back to the previous PATH order when it does not. On Windows the source package `sqlcipher3` is substituted with the prebuilt `sqlcipher3-wheels` (same import name; `sqlcipher3-binary` has **no Windows wheels at all** - measured 2026-10-23, pip reports `No matching distribution`). Two-phase like everything mutating (`--dry-run` plan, `--yes` to execute), pip retried once on transient network failure, and the run verifies `import sqlcipher3` plus that the runtime actually prefers the venv. End-to-end verified on this machine in a redirected HOME (2026-10-23): create venv → 8 packages → import OK → `getPythonCommand()` points at the venv. **The verified WeChat version is machine-readable since 2026-10-26 (D-130)**: single source `WECHAT_VERIFIED` in `src/services/wechatCompat.ts` (4.1.15.56, re-checked 2026-10-26 with a live `sessions` read), exposed via `capabilities --json` (`compatibility.wechat`) and compared against the actually-loaded build in `check` (four-state verdict; `newer-than-verified` is a warning in `check --report`). | `init`, `init --refresh`, `init --test-missing-keys`, `config forget-keys`, `check`, `setup` |
44
44
  | Environment / diagnosis | `check` (and the scheduled `health_check`) reports the **WeChat build actually loaded**, read from the live process's `--user-lib-dir` and - since 4.1.15.53 launches the main process with no switches (D-125) - falling back to the `Weixin.dll` that process actually has mapped, rather than to the version literal inside `Weixin.exe` (that one is only a fallback - D-107). It flags version directories that cannot work (no `Weixin.dll`). When the client is not running it says *unknown*, and a missing field counts as a failure so a stale build cannot pass. Verified on this machine: `4.1.15.53` loaded (2026-10-18, after updating from 4.1.15.13), `4.1.15.11\` (one file) flagged as a leftover. `--report` renders the same facts as a **paste-ready, redacted verdict** (environment, client, key presence, then "what is broken and the next command"), so a user can hand an AI or an issue something better than a description of the symptom (D-126). | `check`, `check --json`, `check --report`, `scripts/health_check.py` |
45
45
  | Relationship temperature | Who is going quiet, who is heating up, and who is reachable **only** here. Reads the local message database directly: read-only, no model, no network (D-062). Three groups - `cooling` (substantial history then silence, ranked by messages x silent days), `heating` (last 7 days at least twice the long-run daily rate), `only-wechat` (no phone/email/meeting-up ever appears in the history). Only 1:1 chats, only text messages, and only accounts whose **id shape** is a person: anything with `@` (`@chatroom`/`@openim`/`@weclaw`), `filehelper` or `gh_` is out - ranking by volume alone produced no information, and the first irreplaceability rule was inverted (it is trivially true of bots, so it put a coffee-shop bot first). Accounts that look like a person but whose name reads as a shop are **flagged, not dropped**, and every exclusion is printed with its reason. Deliberately **not** in the assistant or MCP tool tables: its output is an inference about third parties. | `bonds` |
46
46
  | Official-account daily | Filter configured sources, preserve source categories, backfill incomplete yesterday output before an unqualified today run, fetch articles, classify them, create summaries, generate a local HTML reader, and synchronize reader favorites into local files. Topic (6-way), relevance (3-level) and "does this belong in today's report" (yes/no) are decided by TypeSafe's Jev when `typesafeApiKey` is configured - concurrently, before the serial summary loop (12 articles in 3.3s at 6 workers against ~12s one at a time), and by the previous LLM-parse path otherwise; `--classifier llm` forces the latter. The report's admission rule is applied on both loader paths and can be bypassed with `--include-all`. Two flags exist for the unattended case: **`--yesterday`** (a scheduled run wants *the previous day*, since accounts publish until midnight, and "yesterday" is time-insensitive - the same data whenever the machine wakes up) and **`--prefetch-bodies [n]`** (default off; warms the body cache concurrently *before* the serial, deliberately throttled fetch phase, which then runs entirely from cache - the upstream request count is unchanged, only the window it is spread over). | `daily`, `daily favorites`, `daily-stats`, `daily-server` |
@@ -77,6 +77,8 @@ pair, so `AI 长出"手脚"` read back as `AI 长出"手脚`; `wiki_lint` then s
77
77
  parser had already unwrapped. One character, silently, for any frontmatter list or scalar whose value ends in a quote.
78
78
  Both now unwrap a matched pair only. Measured afterwards: **28 orphan pages → 1** (that one, `DESIGN`, is mentioned by
79
79
  no card at all) and still 0 broken links. | `wiki`, `vault`, `search`, `chat`, `weread`, `review` |
80
+ | QQ chat imports | `weflow-cli import qchat <path>` reads a qchat-cli JSON export (`qce export --format json`) and lands it in `output/imports/qq/sessions/` (`weflow-import/qq-v1`; field semantics aligned with `weflow-message/v1`, mapping table in `docs/DATA_CONTRACT.md`). Two-phase (`--dry-run` / `--yes`), merge-on-reimport keyed on the globally-unique QQ `messageId` (added/updated/unchanged counted separately), foreign JSON rejected with a reason. Downstream, both opt-in: `chat-notes --source qq\|all` emits QQ knowledge cards (`QQ-` name prefix, same card shape and same wiki consumer as WeChat; `all` degrades to QQ-only with a warning when the WeChat DB is unavailable) and `search-index --include-imports` adds QQ sessions to the semantic index (ids carry `chat:qq:`; build works on a machine with no WeChat config at all - end-to-end tested with stubbed embeddings, `test/qchat_import_test.py`). qchat-cli itself is not modified; the boundary is the file contract, not process coupling (D-129). **Validated on real data end-to-end (2026-10-25)**: NapCat.Shell v4.18.1 + QQ NT 9.9.33 (winget) on this machine, OneBot HTTP on 127.0.0.1:3000; two private chats exported via `qce export --format json` (12 messages) landed through `import qchat --own-id` (2 sessions, read-back via `import list/show` verified), `chat-notes --source qq --dry-run` saw both sessions (12 messages / 352 chars), and the index collector turned the 12 messages into 7 records (5 dropped by the same ≥10-char threshold the WeChat side applies). The real export shape matches the parser field-for-field; one observed detail: qchat's `session.type` is `friend` (not `private` as the synthetic fixtures assumed - the field passes through, so no code change). NapCat pitfall recorded for reproduction: on first login it creates an **account-specific** `onebot11_<qq>.json` with EMPTY `httpServers`, silently overriding any pre-seeded `onebot11_1.json` template - the 3000 server only starts after that file is fixed. Group export failed inside qchat-cli itself (`fetchMessages`) and was not exercised here. Querying QQ sessions through `sessions`/`messages` is deliberately not in this round. | `import qchat`, `import list`, `import show`, `chat-notes --source qq`, `search-index --include-imports` |
81
+ | Avatars | `weflow-cli avatar <contact>` reads `head_image.db` - the avatar BYTES live in the table (`image_buffer`, measured 2026-10-26: 1,056 JPEG + 27 PNG among 1,083 rows keyed by username; one user can have several rows = avatar history, newest wins). `--list` names who has one, `--all` exports the latest avatar per user, `--json` metadata only (never base64). Found by the 2026-10-26 db_storage census (`docs/NT_DATABASES.md` - 14 databases probed, all open with the same passphrase-derived key chain; unparsed high-value candidates there: general.db money tables, hardlink.db media index, emoticon aes_key, media_*.db inline voice_data whose header `0x0223` is NOT standard SILK - format still unresearched). Read-only, local, no network. | `avatar`, `avatar --list`, `avatar --all` |
80
82
  | Incremental read | `sync run` reads a time window, deduplicates against the previous checkpoint and writes coverage state; `sync status` reports it without touching the database; `sync verify` re-reads the recorded window and compares bounds and counts. Every run reports `coverage` as `complete`, `unverified` or `partial`, and a partial run never advances `lastSuccessfulRun`. The window lower bound is offered to the NT backend as a pushdown hint (`messages --from`), but `syncService` still applies the window itself, so backends without range support stay correct - the pushdown is asymptotic, and it measured no faster on a 925-message conversation. It does **not** offer a stable cursor - see D-027. | `sync run`, `sync status`, `sync verify` |
81
83
  | Calibration | `scripts/quality_eval.py` samples real articles, scores each through the production path, and writes a three-field labelling file (`topic`/`relevance`/`include`); `score` reports topic agreement (Jev vs the stored labels), relevance agreement plus mean-level error, a calibration table by probability bucket, and **two** threshold sweeps (the inclusion cut and the relevance cuts - both were guessed, and both are now older than the first labelled set). The printed sheet is **blind**: it does not show Jev's answers, because seeing them first inflates agreement by an unmeasurable amount. `consistency` is the half that needs **no** labels: it reports how often the two questions answer the same thing two ways. Labels stay in `~/.weflow-cli/labels/`. A **dry-run** label file made by the assistant exists in that directory (`DRYRUN-by-claude-*.json`) so the scoring path could be exercised before any human label existed; its numbers are **not accuracy** - annotator and model are the same kind of model, and nothing in the data separates agreement-because-right from agreement-because-kin. Do not treat it as a baseline. | `python scripts/quality_eval.py sample`, `score <file>`, `consistency <file>` |
82
84
  | Criteria A/B | `python scripts/jev_probe.py --criteria-ab --limit 24 --yes` asks the same articles twice - current criteria (Chinese, abstract score bins) vs an English variant whose score bins are concrete scenes, following `jev-chat-jarvis`'s two stated rules. It measures **what a wording change would move**, not whether it improves anything. Measured locally (24 + 12 articles): topic agreement 79% / 67%, relevance raw-score mean absolute difference 0.17 / 0.24, and the disagreements concentrate on the **AI↔学术** and **文学↔新闻** boundaries. | `python scripts/jev_probe.py --criteria-ab` |
@@ -117,7 +119,7 @@ called `get_messages` and refused without ever reaching for `draft_reply`, so an
117
119
  ['draft_reply']` (the first version of that case) would have failed on the first run and sent someone chasing a
118
120
  defect that does not exist; asserting the **outcome** instead is what the memory cases already argued for
119
121
  (`memory-recall` judges "was it used", not "was `save_memory` called"). Two of the ten are **out of scope here by design**: `fetch_article` and `search_public` leave the machine, and the harness rejects any egress other than the model call ("a second exit means something leaked"). `get_concepts` and `get_review` were in that gap too and are now covered (see below), which required the harness to be able to lay down fixture files and set the environment variables that point the tools at them. The write cases also brought a new floor, `neverSucceeds`: if the named tool was called at all, that call must have produced nothing (read from the trace), so both "asked the user instead of writing" and "called the tool and was refused" pass while an actual write fails - the first live run of `todo-ambiguous-refused` failed because the expectation first written was too narrow (`mustCall: set_todo_status`), and the model had done the better thing (asked which of the two todos to mark). One case (`ambiguous-contact`) runs over its efficiency budget of 3 most times - measured over four runs on 2026-09-30 it used **6, 6, 2 and 4** tool calls, so it varies rather than having shifted once (the earlier note that "a single run cannot say" is now answered by those four runs). The floors pass every time and the traces show it **re-querying the same thing** (`list_sessions` two or three times) rather than failing, so this is a routing-efficiency question, not a broken promise. **A fixture gap found the same day did not explain it**: `listContacts` had never been stubbed, so `list_contacts` in the eval hit the real service against a temp home with no contacts and always came back empty - two cases were therefore running on "the real service happens to find nothing" while this entry claims synthetic data, and a change in that service would have moved the eval silently. The stub was added (contacts derive from the case's own sessions), and four more runs of the case gave **2, 6, 2 and 2** - the spread is unchanged, so the extra calls are the model exploring routes, not the fixture (the slow runs also reach for `search_chats`). Recorded as a negative result rather than a fix and asserts floor conditions: was the tool that could answer actually called, and were tools avoided when none was needed; did tool usage stay bounded; **is a failing tool reported as failing** (exit code 2 produces a reply quoting the error and offering an alternative, not "no one is waiting"); is an unknown contact ever given invented content; is memory judged by the fact landing in long-term memory rather than by whether `save_memory` was called; does an ambiguous contact name get a question back instead of a guess; are two asks in one message both served; and is a refused fetch (an internal address in favourites) reported as a refusal rather than summarised. Synthetic data, temporary home directory (the real audit and memory files stay untouched - a second writer would clobber the daemon's), and only the model call may leave the process. The expectations are the author's, **not human labels**, so this measures floors and not quality; a first green run proves little (cases and behaviour share an author) and the value is in re-running it after a change. Assertions must key off the tool's own diagnostics, never the model's wording - this suite's own first version asserted "查不到" and produced two false failures on "查不了". The criterion that follows from it, applied once more in `todos-never-extracted`: an expectation that moves with the model's phrasing is a budget, not a floor. That case first asserted "the reply mentions extraction" as a hard floor and passed three clean runs; putting the old tool behaviour back (mutation) still passed once before failing once - the model has its own ways of saying it - so the assertion now rides the soft channel, and the deterministic guard for the distinction lives in `test/assistant-tools-branches.test.ts`. Not in `npm test`: it needs network and money. | `npm run eval:assistant`, `--only=<case>`, `--json` |
120
- | Graph pages (3D / 2D) | `weflow-cli wiki graph` renders the concept graph as a **self-contained** HTML page - on this machine 49,956 concepts / 145,794 links / 9.05 MB (2026-10-02). The libraries, the data and the coordinates are all inlined, so it opens by double-click with **no server and no network**: a `file://` page cannot `fetch` its data (CORS), and a CDN reference would make a local-first feature depend on the network. The layout (250 ticks - the slow part, measured at ~55 s for half this size) is computed at build time by Node and cached under `output/.graph3d-cache/`, so changing only the rendering does not recompute it. The vendored three.js r147 and six d3 modules live in `resources/js/graph3d/` with versions and licences in `NOTICE.txt` beside them. It can be filtered to the structure that matters: `--min-degree N` keeps only concepts whose **whole-graph** degree is at least N (measured: 3,825 of the 49,956 have 10 or more links, and the filtered page is 1.09 MB against the full page's 9.05 MB), and `--line wiki|chat` draws one corpus at a time (48k article concepts vs 1,527 chat ones - different vocabularies that hide each other's structure when merged). Links pointing at removed nodes are dropped with them, because a dangling link makes the force layout fail with "node not found"; the layout cache is keyed by a hash of the data so building the small page no longer evicts the 50k-node coordinates. `test/graph-3d-cli.test.ts` pins that the emitted page carries **no** external reference, that the two library lists (`graph_3d.py` / `layout.mjs`) agree, that `--dry-run` writes nothing, that the filters change the counts and drop dangling links, that the CLI declares **and** forwards both flags, and that a hand-run without the CLI's `PYTHONIOENCODING` still prints readable UTF-8 (this machine's console is GBK). **`--flat` adds a 2D canvas page** (`output/knowledge-graph-2d.html`): the layout is a *separate* build-time solve rather than a flattening of the 3D coordinates - the z axis carries structure, so dropping it stacks unrelated clusters on top of each other - and it is much cheaper (3,825 nodes / 250 ticks in **2.4 s**, against ~55 s for the 3D solve at 25k), so the page is 0.71 MB and needs **no libraries at all** (the layout is precomputed; the renderer is plain canvas, so not even three.js is inlined). Interaction is drag-to-pan, wheel-to-zoom, hover for a name, click to dim everything but a concept and its neighbours, and a search box; `--min-degree` / `--line` apply unchanged, and the 2D and 3D coordinates cache separately (the digest includes the dimension). `test/graph-2d-cli.test.ts` carries a **render smoke test**: it runs the viewer over a fake canvas context and counts the `arc` / `lineTo` / `fillText` calls, because a JS error in a viewer yields a blank page with no failing assertion anywhere. **Performance was then measured in a real headless Edge over CDP** (Node built-in WebSocket, no puppeteer): one `draw()` at 49,956 nodes cost 12.78 ms, of which 50,000 `arc` calls were 9.0 ms against 3.4 ms for the same number of `rect` calls - so sub-2px dots are now squares and interaction redraws are coalesced to one per frame (the handlers used to redraw on every `pointermove`, which is what actually felt laggy). After: 7.6 ms per frame and a 200-event drag storm in 6.9 ms instead of ~2.6 s. Labels are capped and de-overlapped, because at 50k the top-degree concepts are all hubs in the middle and eighty names landed on top of each other. **The 2D solve uses its own force parameters** (stronger repulsion, longer links): the 3D numbers in one dimension fewer pack everything into a featureless disc. The layout cache key covers the data, the dimensions, the tick count **and** those parameters - it used to cover the data only, which silently served stale coordinates after a parameter change (`test/graph_layout_key_test.py`). At 50k a 2D force layout is still a texture rather than a map; the `--min-degree 10` page (3,825 nodes) is the one that reads as structure. |
122
+ | Graph pages (3D / 2D) | `weflow-cli wiki graph` renders the concept graph as a **self-contained** HTML page - on this machine 49,956 concepts / 145,794 links / 9.05 MB (2026-10-02). The libraries, the data and the coordinates are all inlined, so it opens by double-click with **no server and no network**: a `file://` page cannot `fetch` its data (CORS), and a CDN reference would make a local-first feature depend on the network. The layout (250 ticks - the slow part, measured at ~55 s for half this size) is computed at build time by Node and cached under `output/.graph3d-cache/`, so changing only the rendering does not recompute it. The vendored three.js r147 and six d3 modules live in `resources/js/graph3d/` with versions and licences in `NOTICE.txt` beside them. It can be filtered to the structure that matters: `--min-degree N` keeps only concepts whose **whole-graph** degree is at least N (measured: 3,825 of the 49,956 have 10 or more links, and the filtered page is 1.09 MB against the full page's 9.05 MB), and `--line wiki|chat` draws one corpus at a time (48k article concepts vs 1,527 chat ones - different vocabularies that hide each other's structure when merged). Links pointing at removed nodes are dropped with them, because a dangling link makes the force layout fail with "node not found"; the layout cache is keyed by a hash of the data so building the small page no longer evicts the 50k-node coordinates. `test/graph-3d-cli.test.ts` pins that the emitted page carries **no** external reference, that the two library lists (`graph_3d.py` / `layout.mjs`) agree, that `--dry-run` writes nothing, that the filters change the counts and drop dangling links, that the CLI declares **and** forwards both flags, and that a hand-run without the CLI's `PYTHONIOENCODING` still prints readable UTF-8 (this machine's console is GBK). **`--flat` adds a 2D canvas page** (`output/knowledge-graph-2d.html`): the layout is a *separate* build-time solve rather than a flattening of the 3D coordinates - the z axis carries structure, so dropping it stacks unrelated clusters on top of each other - and it is much cheaper (3,825 nodes / 250 ticks in **2.4 s**, against ~55 s for the 3D solve at 25k), so the page is 0.71 MB and needs **no libraries at all** (the layout is precomputed; the renderer is plain canvas, so not even three.js is inlined). Interaction is drag-to-pan, wheel-to-zoom, hover for a name, click to dim everything but a concept and its neighbours, and a search box; `--min-degree` / `--line` apply unchanged, and the 2D and 3D coordinates cache separately (the digest includes the dimension). `test/graph-2d-cli.test.ts` carries a **render smoke test**: it runs the viewer over a fake canvas context and counts the `arc` / `lineTo` / `fillText` calls, because a JS error in a viewer yields a blank page with no failing assertion anywhere. **Performance was then measured in a real headless Edge over CDP** (Node built-in WebSocket, no puppeteer): one `draw()` at 49,956 nodes cost 12.78 ms, of which 50,000 `arc` calls were 9.0 ms against 3.4 ms for the same number of `rect` calls - so sub-2px dots are now squares and interaction redraws are coalesced to one per frame (the handlers used to redraw on every `pointermove`, which is what actually felt laggy). After: 7.6 ms per frame and a 200-event drag storm in 6.9 ms instead of ~2.6 s. Labels are capped and de-overlapped, because at 50k the top-degree concepts are all hubs in the middle and eighty names landed on top of each other. **The 2D solve uses its own force parameters** (stronger repulsion, longer links): the 3D numbers in one dimension fewer pack everything into a featureless disc. The layout cache key covers the data, the dimensions, the tick count **and** those parameters - it used to cover the data only, which silently served stale coordinates after a parameter change (`test/graph_layout_key_test.py`). At 50k a 2D force layout is still a texture rather than a map; the `--min-degree 10` page (3,825 nodes) is the one that reads as structure. **The 2D page answers "why are these two connected" (2026-10-23)**: `build_graph(with_evidence=True)` computes, per edge, the intersection of the two pages' `## 来源` card lists - the shared sources that made the link - and the page carries it as `edgeEvidence` (`键 = 排序后的 id|id;值 = [共同来源数, 最多 2 个名字]`); edges with no shared source are deliberately absent from the map and the viewer says so honestly ("来自页面正文里的 [[链接]]"), rather than implying an empty check. Clicking near an edge (when no node is hit, edges only, tolerance 5px, same visibility gate as drawing) dims the rest, highlights it, and lists the evidence - D-033's "every judgement shown to a person carries its evidence" applied to the graph's edges. Bounded by design: at most 2 names per edge, and the evidence rides only the page payload - the layout cache key is unchanged (`data` stays nodes+links), so existing cached coordinates are NOT invalidated. 3D does not carry evidence (raycast-picking lines is a bigger lift and was not promised). Tests: the build side (shared source present / absent, key shape, `evidenceEdges` count), the viewer side (midpoint hit, empty-space miss, both evidence wordings, focused-edge redraw) in `test/graph-2d-cli.test.ts`. |
121
123
  | Assistant | Optional WeChat Bot-channel assistant: 31 local tools (12 originally; plus chat search, reply debt, semantic search and chat export, with export in html/txt/json/excel, Moments "who posts most", favourites without a keyword, and drafted replies). The single write tool (`export_chat`) can only create a new directory under `output/exports/` - the model supplies no path and an existing name gets a numeric suffix, so "never overwrite" is enforced in code. `draft_reply` writes nothing and **sends nothing** (D-047). `evidence-review`, `vault promote`, sending messages and every configuration change are deliberately **not** reachable from a chat message: the first two have their own human-facing confirm gates, sending is irreversible, and configuration is meant to be changed on the machine. 31 local tools, three-layer memory (working window / rolling summary / long-term facts), PII redaction before any cloud call, and a 100-message/day quota. It denies **every** sender until an allowlist is configured. The **single-round fast path** (D-035) is shipped but **off by default** (`assistantFastRoute` = `off` | `log` | `on`): it resolves "needs local data? which capability?" in one decision call and pre-dispatches that tool, so the loop's first request already sees the result (two round trips become one). Only the six tools with closed or empty arguments are routable; everything else falls back, and falling back is byte-identical to the previous behaviour. Measured on 17 questions against the real decision layer: 0 wrong routes, 10 routed concretely, ~1.33 s per decision, 2 fell back (`scripts/assistant_route_probe.ts`; those expectations are the author's, not human labels). Covered by synthetic harnesses (stubbed `callLLM`, stubbed `chatService`/`wereadService` singletons, a replaced `fetch`, a stubbed message channel, an injected route decider, temp `HOME`): per-message logic, **all 31 tool branches** (2026-09-29: the last uncovered one, `get_daily_report`, got the `WEFLOW_ASSISTANT_BIZ_DAILY_DIR` injection point its own coverage note had been asking for - read per call, so a test can swap fixtures; it is now asserted against a fixture instead of a "there may or may not be data" shape check), the resident loop itself - who is denied, the three group controls, the serial queue, the 100/day quota with its date rollover - the **privacy wiring** (which `aiEngine` values count as local inference, where `engineConfig` sends the request, and `reviewEvidence` refusing to send chat text to the cloud without `--allow-cloud`), and the fast path (correct / wrong / uncertain / log-only / no dispatch without the audit line). `get_todos` is executed through `pythonBridge`'s injectable runner (empty list, urgency and deadline rendering, the 15-row cap, a classified failure whose `stderr` is redacted, and "no JSON" not being read as "no todos"). It also keeps "no pending todos" apart from "extraction has never been run" (D-044): the emptiness has a cause of its own on a machine where `todos extract` was never invoked, so the tool names that cause and the command that would fill the list instead of reporting "nothing to do". The list is extracted from chat logs only by an explicit, confirmed command; nothing schedules it. Note on that claim's history: an earlier revision of this line - and the commit message for the `pythonBridge` refactor - asserted this coverage before any such test existed; it was written in prose only, and the file's own header comment said the opposite. The tests are real as of this revision. The assistant is time-aware: the system prompt opens with the current local date and weekday, and `get_messages` takes `since`/`until` (a date or `3d`/`2w`/`12h`) so a specific past day is reachable - before this, a day far enough back could not be fetched at all. An identical tool call repeated within one turn is answered with a note instead of being re-executed. The tool list is built per machine: a tool whose prerequisite is missing (semantic search without `dashscopeApiKey`, WeRead without `wereadApiKey`) is not offered at all, and the fast path - which dispatches directly - checks the same predicate; the filter only drops tools that *cannot* run, never ones that merely have no data yet. Name resolution searches the recent sessions first and then falls back to the contact book (remark / display name / nickname / alias), refusing anything that is not unique - measured on 378 real contacts outside the recent sessions: 362 resolved, 0 resolved to the wrong person. | `login-wechat`, `assistant` |
122
124
  | 本机面板 (local panel) | A second entrance to the same assistant, for when logging into the Bot channel is not worth it: `weflow-cli panel` opens an always-on-top window (Electron) or a chromeless Edge/Chrome `--app` window when Electron is absent. **The window is only a client** - every turn goes through the daemon's own `runTurn`, so the allowlist path, the 100/day quota counter, the serial queue and the memory file all stay single-writer (D-045). The daemon exposes a **loopback-only** HTTP endpoint (`127.0.0.1:8766`, not configurable) that is deliberately stricter than the daily reader: **every** request needs a per-run token (including `/api/status`), Origin is a second gate, POSTs must be JSON, and only one turn may be in flight (a second one gets 429 rather than being queued, because queueing means it will run and spend quota). The token lives in `~/.weflow-cli/assistant_endpoint.json` (atomic write; readers probe `kill(pid,0)` and verify `service` + `startedAt`, because SIGTERM leaves the file behind) and never reaches a command line: the Electron shell reads it itself and installs it as an `HttpOnly` cookie, while the browser fallback uses a one-time 60-second code from `/api/pair`. The memory bucket decides whether the panel is the same brain as WeChat - it resolves to the single allowlisted id, else it says on screen that it uses a separate bucket (`assistantPanelUser` pins it). **The panel can show what the assistant remembers** (2026-10-03): a `记忆` button in the bubble header fetches `GET /api/memory` and renders a **read-only** list of the long-term facts, each with the time it was recorded and the quote it came from, so a wrong memory can be traced back to the sentence that produced it. The bucket is decided **server-side** (the same `memoryBucket` the panel already reports) and a query parameter cannot steer it - otherwise the route would be a way to read another memory bucket. **That payload never goes to a model**: it exists for the user to audit what the assistant believes about them, and it is deliberately separate from the fact injection in `buildSystemPrompt`, which has its own budget and redaction. Facts are capped at 30 (`FACTS_MAX`), so the view is bounded by construction; the page renders with `textContent` only, and a test feeds in a memory shaped like an `<img onerror=…>` tag to prove it shows up as text. Verified live: the browser fallback opened, the pair-code flow returned the page with an `HttpOnly` cookie, a replayed code 401s, and a real question through the endpoint answered from local data while the shared quota counter went 0 -> 3. The floating ball was verified on Windows 11 with Electron 42 (76x76, no caption, always-on-top, renderer authenticated via the cookie, ball visible on screen); with Electron absent the same command falls back to a chromeless Edge/Chrome window and says it cannot give the ball. Ball-to-chat resize and the global hotkey were then exercised **without a click** (DevTools protocol; a differential registration test), which is how two bugs surfaced that a screenshot could not have shown - a non-resizable window ignoring `setSize` on Windows (collapse left it at the expanded size) and a `ready-to-show` listener attached too late (the window sometimes stayed hidden). **Right-clicking in the panel opens a quick-reply menu listing the contacts in `quickReplyContacts` (a config key added for it; the list rides on `/api/status`, which the page already polls every 30 seconds, so no new endpoint and no new IPC). Picking a name asks the assistant to draft for that person through the existing `/api/ask`, so quota, the serial queue, memory and the copy button all come along unchanged. The menu states its own cost (drafting sends that conversation to two cloud models), an empty list shows the command to run instead of an empty box, and the menu is a **native** one (built in the main process from `quick-menu.cjs`) so it draws outside the window - the ball is 96x96 and never has to expand, which is what the first attempt got wrong; the browser fallback does not take over right-click at all, because the native menu's Copy belongs to the user there. That menu's **last item hides the ball** (the only item in it that reaches no model), and it is the one item still present when the list is empty - otherwise a user who never configured the key would get a menu of two grey lines with nothing clickable, including no way to dismiss the ball. It hides rather than quits, so it is deliberately reversible, and the label names the way back (`关闭悬浮球(托盘图标能再打开)`) because the ball runs with `setSkipTaskbar(true)` and would leave nothing on the taskbar to click; the exit actions stay in the tray menu, which already separates them from show/expand (no test ever drives a real right-click, so the menu's *contents* are covered by executing every item's callback in `test/panel-quick-menu.test.ts`). Dragging the ball used to **grow the window by the drag distance** whenever it happened in the chat window: `dragMove` re-read the current size and wrote it back, and that read lags (measured with a probe driving the same IPC sequence: 0 drift with `resizable: false`, exactly the drag delta with `resizable: true`). The extra width showed up as a gap between the ball and the bubble, and expanding re-asserted the exact size - which is why clicking the ball appeared to reset it. The size is now captured once at `dragStart`. **Pressing the ball switches it to a second, closed-eyes face, and releasing reverts it immediately** (a 900ms hold after release was tried first and the user called it stuck; the generated face is composited in only inside an eye-shaped mask, because the endpoint repainted the whole canvas and returned no alpha). The ball stays put and the conversation unfolds beside it** - clicking it opens a bubble to its left (flipping to the right when there is no room, top-aligning when the bubble would run off the top of the work area) and clicking it again closes it; the ball is a toggle and never fades out. The window is "ball + gap + bubble" as one piece (508x560), with the ball pinned to its own corner so it does not move at all; `bubbleLayout` and `ballRectInWindow` in `ball-position.cjs` compute that geometry (pure, tested with synthetic displays, including a round-trip that expand-then-collapse returns the ball to where it started). The mode has one source of truth (the main process announces `{mode, side, anchorY}`; the page only requests), so the ball, the collapse button, the tray and the hotkey take the same path, and `prefers-reduced-motion` stops both halves. Measured on a real window with the real page at 150% scaling: the ball's offset inside the window is exactly 0 at every sample and its screen position varies by at most 1px across a whole expand and collapse (23 and 30 samples), with identical resting positions before and after. Two bugs came out of that measurement rather than out of reading the code: the tween rounded each edge independently so the edge the ball hangs on wobbled 1-2px (since fixed by pinning the corner), and collapsing placed the ball at the window's top-left instead of the corner it sits on, which would have made it jump to the bubble's opposite corner. The platform wrinkle behind some of this: at 150% scaling a request for a 76x76 window yields a ~79px client width (Windows enforces a minimum), which is why the remembered position is the window's, not the ball's visual edge. That arithmetic is a pure module tested against **synthetic** monitor layouts (negative-x second displays, a removed display, a rect larger than the work area) - this machine has one display, so a real second monitor remains unperformed. **The ball's coloured halo is gone entirely** (2026-09-29): the element, the three state rules and the `--hue` / `drift` / `breathe` drivers were all deleted, and `test/panel-packaging.test.ts` forbids reintroducing any of them (the user raised the same complaint twice, so the answer was removal rather than "draw less"). **Pressing the ball plays a twelve-frame "picked up by the scruff" animation**: six frames up while it is held (`mascot-lift-up-1..6.png`) and six back down on release (`mascot-lift-down-1..6.png`, then the resting frame `mascot-base.png`), hard cuts about 60 ms apart, the first frame synchronous so a press never shows an empty ball, and no tail after release. The twelve frames are **generated art** (`gpt-image-2-ca` via `/images/edits`; pipeline and contract in `scripts/panel_frames.py`) normalized to: 256x256 RGBA, 0 solid pixels past r=128, farthest solid distance under 0.98 x 128 **measured from the canvas centre with solid = alpha >= 128**, the apex anchored to the resting frame, and the scale taken from **the cat's own bounding box** (`170 x s`) rather than the canvas - the canvas also carries the speech bubble and the shock marks, and letting those set the scale would make a decoration decide the character's size. The silhouette is asserted rather than assumed: the first generation drew the hood as a pointed cone with no ears, moved the cat's aspect ratio from 0.794 to 0.650, and **every geometry assertion still passed**, so the suite now pins the largest connected component's width and aspect ratio. `prefers-reduced-motion` plays no animation and keeps the squint face, which is now that face's only role. **Running the pointer across the ball makes it squirm as if tickled** (2026-10-02): four generated poses (`mascot-tickle-1..4.png`, normalized at scale 1.0 - a tickle must not change the ball's size) cycle every 110 ms, and there is **no tilt** any more (2026-10-02, D-066): the 4-degree rotation that used to be computed in the renderer from the pointer speed was removed because it shared one `rotate` on `#ball` with the lift, which made a casual sweep look like a small take-off - rotation belongs to the lift alone now, and 260 ms without movement settles the poses back. The rotation is about the ball's centre, which is what makes it safe under the circular clip - rotation preserves every pixel's distance from the centre, so the ears cannot be rotated out of the circle (translation would). It is skipped under `prefers-reduced-motion` and while the ball is peek-hidden (that artwork has a straight cut edge that a rotation would open into a seam), and a press settles it so the lift owns the ball. **Not yet verified live** - geometry, wiring and the sequence are covered by `test/panel-lift-frames.test.ts` and `test/panel-lift-sequence.test.ts`, but nobody has watched the animation on a real window yet. The test was **rewritten to the new contract, not weakened**: the first version searched the whole stylesheet for each `body.<state> #ball .glow`, which also matches that state's separate `animation: none` rule, so removing a state from the gradient selector still passed - caught by a mutation check, and the assertion now requires the state to appear in the selector of the rule that actually carries the `radial-gradient`. **The right-click menu now has a second section of eight one-click functions** (谁在等我回话 / 待办 / 日报 / 统计 / 阅读统计 / 会话 / 朋友圈 / 微信读书), admitted by a single rule: the action must be completable by **one fixed sentence**, because a native menu cannot collect input - so search/export/read-one-message are excluded rather than shipped as items that do nothing. Each prompt names its tool, which is a request to the model and **not a hard binding** (the assistant still chooses from 19 tools; forcing it is a decision for when a wrong pick is actually observed). The prompt travels with the menu item and the page keeps no id→prompt table (two tables drift into "the menu says A, clicking does B", silently - a test asserts `renderer.js` contains no action id); the cost line is **derived** from the action list rather than written down, because a stale cost warning is worse than none, and it now names which items are cloud calls and which are local reads. The pick payload is `{kind:'contact'|'action', …}` and the page ignores anything else, so a menu from the previous version cannot be dispatched as a draft nobody asked for. All of it verified by executing every item's callback and by two mutation checks (dispatching an action as a draft, and the page keeping its own id table). **The ball's eyes now follow the mouse** (D-059): the main process samples `screen.getCursorScreenPoint()` every 120 ms and pushes the position **only when it changes**, the page eases toward it and stops its rAF loop once it settles. The renderer cannot see the cursor itself - the ball is 96x96 and the pointer is outside it almost always - which is why this needs the main process at all. The ball's face is now **two layers**: `mascot-base.png` (the same art with the iris region repainted as sclera) plus `mascot-iris.png` (the iris, pupil and highlight), composited at zero offset **pixel-identical** to `mascot.png` - a test decodes all three PNGs and asserts that, because same-size / whitelisted / listed-in-`PANEL_FILES` all stay green if the layers are re-cut one pixel off, and a misalignment is visible only to a person. Travel is clamped to an **ellipse** (about 2.6 px sideways, 0.8 up, 2.6 down at the 96 px ball) because those are the measured sclera margins - past them the iris separates from the rim and the eye reads as a sticker sliding. The iris layer is hidden on `busy` / `offline` / `quota` / `ball-happy`, whose faces have their eyes baked in (otherwise there would be two pupils). `prefers-reduced-motion` leaves the eyes still. The cursor position is in-process only, narrowed in `preload.cjs`, never logged and never sent anywhere. **Not yet verified live** - the wiring and the pixel identity are covered by the suite, but the effect has not been run on a real window. **The ball can also be pushed to a screen edge to hide behind it**: drag it until it touches a monitor's left or right edge and let go, and it swaps to a dedicated drawing of the cat **peeking out from behind that edge** - head and front paws out, body hidden, the drawing's left side being a straight cut that lands exactly on the screen edge (`mascot-peek.png`; the right edge reuses it mirrored). The window stays **fully on screen**, flush against the edge - the illusion comes from the artwork, so nothing goes off-screen, the whole ball stays clickable, and a restart simply puts the ball back at the edge rather than losing it. The first click (or drag) brings the normal ball back, and that click deliberately does **not** expand the conversation - on a hidden ball a click means "come back", not "let's talk". Only left and right: top and bottom fight the taskbar and Windows' edge snapping. Two rules carry the effect and both are pinned by tests because everything else stays green without them: the ball's circular clip must be off in this state (a circle would slice the straight cut into an arc) and hover-zoom must be off as well (scale 1.02 lifts the cut 1px off the edge - a visible seam). The snap threshold is deliberately below the normal 24px edge margin, so a ball merely parked is never mistaken for one being pushed off; a test pins that boundary, because raising it past the margin would hide the ball on every release while every other test stayed green. | `panel`, `panel --status`, `panel --ask` |
123
125
 
@@ -130,6 +132,8 @@ The daily workflow supports `dailyAiEnabled=false` for a persistent no-AI mode,
130
132
 
131
133
  Date-bounded message exports and `wechat.export_messages` page through the selected conversation before applying the result limit. Older matching dates are therefore not hidden by a newer, nonmatching first page. Versioned envelopes report requested bounds, returned count, and conservative `mayHaveMore` metadata. `sync` adds a local checkpoint on top of the same overlapping-window approach, but **a stable incremental cursor is still not exposed** - it must be supported by every relevant database backend before it is advertised (D-027), and `capabilities --json` reports `stableCursor: false` for both.
132
134
 
135
+ **The path from `false` to `true` is visible now, not just the verdict** (2026-10-23): `capabilities --json` 的 `read.exports.incrementalRead` 多了 `cursorReadiness`(五条判据 × 四个后端的代码事实,及指向探针的 `probe:` 命令串——`check_readme_commands.py` 把 `probe:` 与 `cli:`/`preview:` 一起校验),`sync cursor-probe --json` 实测 NT 数据侧:schema 检查**全量**(每张消息表的锚点列),身份检查**有界采样**(每分片取 5 张表、每表两端各 500 行,结果自带 `exhaustive: false`),server_id 以字符串上报(int64 过 2^53 会丢精度)。本机实测(2026-10-23):4 个分片全开、211 张表 0 缺列、采样 5,398 行**全部**带非零 server_id 且 0 重复——**数据侧判据在这台机器上已满足**;`stableCursor` 仍为 false,剩下的两块是读取器侧(合并顺序键 `(createTime, localId)` 的 localId 不跨分片可比)与其余三个后端(raw-4x / wcdb / 3x 无下推无覆盖报告)。探针不翻结论,只摊开路径。
136
+
133
137
  ## Not Verified (and what closing it would take)
134
138
 
135
139
  Same shape as the launcher reverse-engineering report's closing section ("what we could not do, and why"): an
@@ -185,7 +189,7 @@ Documentation was synchronized with the current source baseline on 2026-09-19. C
185
189
  - The article body is **not** available locally, so fetching cannot be skipped: a 公众号 message in the local database carries title, digest, URL and metadata, and stripping its XML tags leaves 0 Chinese characters (the ~5-12 KB that remains is URLs, base64 and ids).
186
190
  - The OC Bot channel is separate from a personal WeChat message stream. `send` can only reply through an already established Bot-channel conversation with a valid context token; it cannot initiate messages to existing personal contacts, post as the user's personal account, or send into ordinary personal WeChat groups.
187
191
  - The current official OC/iLink payload model has not been verified to support group events or group invitations. The project does not implement client automation or protocol bypasses to add a bot to groups.
188
- - Inbound media on the OC Bot channel (image / voice / file / video) is downloaded **only when `wechatMediaDownload` is `true`** (`false` by default, so a default install makes no such request). When it is on, each item is decrypted locally (AES-128-ECB, the same scheme as the local image files) and written under `output/wechat-media/` (directory overridable with `WEFLOW_WECHAT_MEDIA_DIR`). Names are decided locally - a server-supplied name is reduced to a sanitised basename - and each item is capped at 25 MB; a download that fails, comes back empty or exceeds the cap leaves `filePath` empty and writes one log line naming the reason, never a partial file. The field and key order follow the vendor implementation (`third-party/WeKnora/internal/im/wechat/longpoll.go`): images use `image_item.aeskey` (hex) before `media.aes_key` (base64). **Verified on a real inbound message** (2026-10-10, after a fresh `login-wechat`): a text message arrived and was answered, then a real image arrived - 27,637 bytes, PNG magic, and **PIL opened it at 798x513 RGB**, which is what separates "decryption is right" from "some bytes landed". The log showed `忽略非文本消息 (image)` on the same message, i.e. the assistant keeps ignoring non-text for its reply. **And no consumer reads the saved path yet**: `components[].filePath` is populated and the file really is on disk, but the only reader of an inbound message (`assistantService`) takes `messageStr` + `messageKind` and carries the full object along unread - so the assistant still knows only "an image arrived", not where it landed. Letting a model look at that file would be a new class of egress and is deliberately left as its own decision (D-042 set the precedent for local images). **And it downloads from any sender, not just the allowlist**: the download happens while parsing the polled message, one layer earlier than "should the assistant answer this", so the allowlist governs replies and not downloads - anyone who can message the bot gets their media written to disk here.
192
+ - Inbound media on the OC Bot channel (image / voice / file / video) is downloaded **only when `wechatMediaDownload` is `true`** (`false` by default, so a default install makes no such request). When it is on, each item is decrypted locally (AES-128-ECB, the same scheme as the local image files) and written under `output/wechat-media/` (directory overridable with `WEFLOW_WECHAT_MEDIA_DIR`). Names are decided locally - a server-supplied name is reduced to a sanitised basename - and each item is capped at 25 MB; a download that fails, comes back empty or exceeds the cap leaves `filePath` empty and writes one log line naming the reason, never a partial file. The field and key order follow the vendor implementation (`third-party/WeKnora/internal/im/wechat/longpoll.go`): images use `image_item.aeskey` (hex) before `media.aes_key` (base64). **Verified on a real inbound message** (2026-10-10, after a fresh `login-wechat`): a text message arrived and was answered, then a real image arrived - 27,637 bytes, PNG magic, and **PIL opened it at 798x513 RGB**, which is what separates "decryption is right" from "some bytes landed". The log showed `忽略非文本消息 (image)` on the same message, i.e. the assistant keeps ignoring non-text for its reply. **The image half of that gap is closed (2026-10-23, D-128)**: an inbound image with a downloaded `filePath` now becomes a turn (placeholder text `(发来一张图片)`, whitelist + quota unchanged), and `look_at_image` accepts a `#信N` handle that reads the landed file directly - same two-layer gate as D-042 (strict refuses at the tool AND at `toApiMessages`), same `MAX_IMAGES_PER_TURN` budget, mime sniffed from magic bytes, no rescaling and a 4 MB cap (evidence-shaped file). The handle is only shown when viewing is possible (same rule as `get_messages`' `#N`), and images without a landed file are still ignored - behaviour unchanged. **Still not consumed: voice / video / file components** - each is its own egress decision (D-128 names none of them), and `assistantService` still only knows "an X arrived" for those. **And it downloads from any sender, not just the allowlist**: the download happens while parsing the polled message, one layer earlier than "should the assistant answer this", so the allowlist governs replies and not downloads - anyone who can message the bot gets their media written to disk here.
189
193
  - Cloud AI workflows may transmit user-selected, privacy-filtered input to the configured provider. Local inference avoids that network transfer.
190
194
  - `vault promote ideas` and `vault promote all` run without AI by default. AI generation requires explicit `--with-ai` and an API key. CLI execution also requires preview and confirmation. `vault init` creates both the existing article-sync folders and the structured directories required by promotion workflows.
191
195
  - `vault init`, semantic-index construction, the knowledge pipeline, and report generators require preview and confirmation for machine execution. Preview JSON contains counts and behavior flags but omits local paths, conversation names, and generated content.
package/docs/RELEASING.md CHANGED
@@ -39,6 +39,10 @@ node -p "require('./package-lock.json').version"
39
39
  - 把 `## Unreleased` 改名为 `## <新版本号>`
40
40
  - 若上一版已写好但没发布,把它的条目并入,删掉那个空段
41
41
  - 通用说明(如「npm 与 GitHub 分开发布」)移到文件顶部,别留在版本段里
42
+ - **每个版本段记录验证过的微信版本**(D-130):一行 `> 验证过的微信:4.1.15.56(2026-10-26)`,
43
+ 来源是 `src/services/wechatCompat.ts` 的 `WECHAT_VERIFIED`——两边必须一致(测试钉着 capabilities 那半)。
44
+ 本版完全不涉及微信数据读取的,写 `> 验证过的微信:沿用上一版(本版不涉及微信数据读取)`;
45
+ 验证过**新**微信版本的,先实读真实数据再改常量,再写进这里。
42
46
 
43
47
  ## 3. 构建与测试
44
48