weflow-cli 1.7.0 → 1.8.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 (194) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +1949 -1
  3. package/OPERATIONS.md +774 -1
  4. package/README.en.md +93 -7
  5. package/README.md +31 -6
  6. package/bin/weflow-cli.ts +934 -27
  7. package/dist/bin/weflow-cli.js +952 -27
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.js +39 -13
  10. package/dist/mcp-server/index.js.map +1 -1
  11. package/dist/src/core/appMsgFormat.d.ts +37 -0
  12. package/dist/src/core/appMsgFormat.d.ts.map +1 -0
  13. package/dist/src/core/appMsgFormat.js +106 -0
  14. package/dist/src/core/appMsgFormat.js.map +1 -0
  15. package/dist/src/core/ntCore.d.ts +19 -2
  16. package/dist/src/core/ntCore.d.ts.map +1 -1
  17. package/dist/src/core/ntCore.js +11 -1
  18. package/dist/src/core/ntCore.js.map +1 -1
  19. package/dist/src/core/sqlcipherCore.d.ts +0 -12
  20. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  21. package/dist/src/core/sqlcipherCore.js +5 -64
  22. package/dist/src/core/sqlcipherCore.js.map +1 -1
  23. package/dist/src/core/wcdbCore.d.ts +1 -1
  24. package/dist/src/core/wcdbCore.d.ts.map +1 -1
  25. package/dist/src/core/wcdbCore.js +5 -1
  26. package/dist/src/core/wcdbCore.js.map +1 -1
  27. package/dist/src/panel/client.d.ts +25 -0
  28. package/dist/src/panel/client.d.ts.map +1 -0
  29. package/dist/src/panel/client.js +34 -0
  30. package/dist/src/panel/client.js.map +1 -0
  31. package/dist/src/panel/endpoint.d.ts +45 -0
  32. package/dist/src/panel/endpoint.d.ts.map +1 -0
  33. package/dist/src/panel/endpoint.js +132 -0
  34. package/dist/src/panel/endpoint.js.map +1 -0
  35. package/dist/src/panel/launch.d.ts +50 -0
  36. package/dist/src/panel/launch.d.ts.map +1 -0
  37. package/dist/src/panel/launch.js +74 -0
  38. package/dist/src/panel/launch.js.map +1 -0
  39. package/dist/src/panel/server.d.ts +23 -0
  40. package/dist/src/panel/server.d.ts.map +1 -0
  41. package/dist/src/panel/server.js +413 -0
  42. package/dist/src/panel/server.js.map +1 -0
  43. package/dist/src/panel/userId.d.ts +37 -0
  44. package/dist/src/panel/userId.d.ts.map +1 -0
  45. package/dist/src/panel/userId.js +56 -0
  46. package/dist/src/panel/userId.js.map +1 -0
  47. package/dist/src/services/assistantDaemon.d.ts +20 -3
  48. package/dist/src/services/assistantDaemon.d.ts.map +1 -1
  49. package/dist/src/services/assistantDaemon.js +46 -9
  50. package/dist/src/services/assistantDaemon.js.map +1 -1
  51. package/dist/src/services/assistantEval.d.ts +141 -0
  52. package/dist/src/services/assistantEval.d.ts.map +1 -0
  53. package/dist/src/services/assistantEval.js +497 -0
  54. package/dist/src/services/assistantEval.js.map +1 -0
  55. package/dist/src/services/assistantMemory.d.ts +106 -4
  56. package/dist/src/services/assistantMemory.d.ts.map +1 -1
  57. package/dist/src/services/assistantMemory.js +396 -61
  58. package/dist/src/services/assistantMemory.js.map +1 -1
  59. package/dist/src/services/assistantPrivacy.d.ts +15 -2
  60. package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
  61. package/dist/src/services/assistantPrivacy.js +18 -4
  62. package/dist/src/services/assistantPrivacy.js.map +1 -1
  63. package/dist/src/services/assistantRouter.d.ts +41 -0
  64. package/dist/src/services/assistantRouter.d.ts.map +1 -0
  65. package/dist/src/services/assistantRouter.js +151 -0
  66. package/dist/src/services/assistantRouter.js.map +1 -0
  67. package/dist/src/services/assistantService.d.ts +209 -3
  68. package/dist/src/services/assistantService.d.ts.map +1 -1
  69. package/dist/src/services/assistantService.js +590 -75
  70. package/dist/src/services/assistantService.js.map +1 -1
  71. package/dist/src/services/assistantTools.d.ts +205 -1
  72. package/dist/src/services/assistantTools.d.ts.map +1 -1
  73. package/dist/src/services/assistantTools.js +1089 -58
  74. package/dist/src/services/assistantTools.js.map +1 -1
  75. package/dist/src/services/assistantTrace.d.ts +47 -0
  76. package/dist/src/services/assistantTrace.d.ts.map +1 -0
  77. package/dist/src/services/assistantTrace.js +150 -0
  78. package/dist/src/services/assistantTrace.js.map +1 -0
  79. package/dist/src/services/chatService.d.ts +1 -1
  80. package/dist/src/services/chatService.d.ts.map +1 -1
  81. package/dist/src/services/chatService.js +5 -2
  82. package/dist/src/services/chatService.js.map +1 -1
  83. package/dist/src/services/configService.d.ts +9 -0
  84. package/dist/src/services/configService.d.ts.map +1 -1
  85. package/dist/src/services/configService.js +15 -8
  86. package/dist/src/services/configService.js.map +1 -1
  87. package/dist/src/services/pythonBridge.d.ts +41 -0
  88. package/dist/src/services/pythonBridge.d.ts.map +1 -0
  89. package/dist/src/services/pythonBridge.js +119 -0
  90. package/dist/src/services/pythonBridge.js.map +1 -0
  91. package/dist/src/services/syncService.d.ts +8 -2
  92. package/dist/src/services/syncService.d.ts.map +1 -1
  93. package/dist/src/services/syncService.js +26 -7
  94. package/dist/src/services/syncService.js.map +1 -1
  95. package/dist/src/services/wechatMessageService.d.ts +25 -0
  96. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  97. package/dist/src/services/wechatMessageService.js +69 -7
  98. package/dist/src/services/wechatMessageService.js.map +1 -1
  99. package/dist/src/utils/atomicWrite.d.ts +4 -0
  100. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  101. package/dist/src/utils/atomicWrite.js +60 -0
  102. package/dist/src/utils/atomicWrite.js.map +1 -0
  103. package/dist/src/utils/dateRange.d.ts +15 -0
  104. package/dist/src/utils/dateRange.d.ts.map +1 -1
  105. package/dist/src/utils/dateRange.js +47 -0
  106. package/dist/src/utils/dateRange.js.map +1 -1
  107. package/dist/src/utils/text.d.ts +18 -0
  108. package/dist/src/utils/text.d.ts.map +1 -0
  109. package/dist/src/utils/text.js +20 -0
  110. package/dist/src/utils/text.js.map +1 -0
  111. package/docs/AI_INTERFACE.md +2 -0
  112. package/docs/DECISIONS.md +1424 -0
  113. package/docs/EXTENDING.md +187 -0
  114. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  115. package/docs/MCP.md +23 -4
  116. package/docs/PROJECT_STATE.md +299 -98
  117. package/docs/SETUP.md +22 -0
  118. package/docs/SYNC_CONTRACT.md +31 -3
  119. package/mcp-server/index.ts +681 -657
  120. package/package.json +84 -83
  121. package/resources/panel/ball-position.cjs +153 -0
  122. package/resources/panel/index.html +46 -0
  123. package/resources/panel/main.cjs +447 -0
  124. package/resources/panel/mascot-focus.png +0 -0
  125. package/resources/panel/mascot-happy.png +0 -0
  126. package/resources/panel/mascot-sorry.png +0 -0
  127. package/resources/panel/mascot-tired.png +0 -0
  128. package/resources/panel/mascot.png +0 -0
  129. package/resources/panel/package.json +7 -0
  130. package/resources/panel/panel.css +407 -0
  131. package/resources/panel/preload.cjs +72 -0
  132. package/resources/panel/quick-menu.cjs +197 -0
  133. package/resources/panel/renderer.js +441 -0
  134. package/resources/panel/tray-menu.cjs +40 -0
  135. package/resources/panel/tray.png +0 -0
  136. package/scripts/_utils.py +403 -54
  137. package/scripts/annual_report.py +4 -2
  138. package/scripts/article_notes.py +423 -0
  139. package/scripts/backfill_articles.py +438 -0
  140. package/scripts/biz_daily.py +590 -105
  141. package/scripts/chat_notes.py +470 -0
  142. package/scripts/classify_daily.py +10 -18
  143. package/scripts/compile_wiki.py +1059 -46
  144. package/scripts/create_reading_notes.py +199 -14
  145. package/scripts/decide.py +253 -0
  146. package/scripts/draft_reply.py +671 -0
  147. package/scripts/export_chat_html.py +35 -33
  148. package/scripts/extract_todos.py +31 -5
  149. package/scripts/fav_notes.py +318 -0
  150. package/scripts/fix_topics.py +2 -1
  151. package/scripts/generate_ai_report.py +214 -9
  152. package/scripts/generate_html.py +18 -3
  153. package/scripts/generate_review.py +2 -2
  154. package/scripts/jev_client.py +294 -0
  155. package/scripts/jev_probe.py +440 -0
  156. package/scripts/nt_common.py +82 -0
  157. package/scripts/nt_decrypt.py +1755 -1460
  158. package/scripts/pipeline.py +27 -2
  159. package/scripts/promote_ideas.py +19 -4
  160. package/scripts/quality_eval.py +707 -0
  161. package/scripts/rag_chat.py +4 -4
  162. package/scripts/read_image.py +187 -0
  163. package/scripts/reply_debt.py +488 -0
  164. package/scripts/route_cards.py +453 -0
  165. package/scripts/semantic_search.py +208 -49
  166. package/scripts/user_notes.py +315 -0
  167. package/scripts/vault_rag.py +13 -7
  168. package/scripts/vault_search.py +21 -4
  169. package/scripts/wiki_lint.py +397 -0
  170. package/src/core/appMsgFormat.ts +117 -0
  171. package/src/core/ntCore.ts +27 -2
  172. package/src/core/sqlcipherCore.ts +5 -61
  173. package/src/core/wcdbCore.ts +6 -2
  174. package/src/panel/client.ts +49 -0
  175. package/src/panel/endpoint.ts +144 -0
  176. package/src/panel/launch.ts +101 -0
  177. package/src/panel/server.ts +424 -0
  178. package/src/panel/userId.ts +72 -0
  179. package/src/services/assistantDaemon.ts +57 -9
  180. package/src/services/assistantEval.ts +611 -0
  181. package/src/services/assistantMemory.ts +413 -57
  182. package/src/services/assistantPrivacy.ts +18 -4
  183. package/src/services/assistantRouter.ts +191 -0
  184. package/src/services/assistantService.ts +671 -74
  185. package/src/services/assistantTools.ts +1118 -57
  186. package/src/services/assistantTrace.ts +175 -0
  187. package/src/services/chatService.ts +6 -2
  188. package/src/services/configService.ts +403 -385
  189. package/src/services/pythonBridge.ts +139 -0
  190. package/src/services/syncService.ts +37 -9
  191. package/src/services/wechatMessageService.ts +73 -7
  192. package/src/utils/atomicWrite.ts +56 -0
  193. package/src/utils/dateRange.ts +46 -0
  194. package/src/utils/text.ts +20 -0
@@ -0,0 +1,187 @@
1
+ # Extending WeFlow CLI
2
+
3
+ **Status: this is a checklist, not a plugin system.** There is no plugin loader, no third-party import surface,
4
+ and no `exports` field in `package.json` (`bin` is the only entry point). So "extending" this project today means
5
+ one of two things, and they are not equivalent:
6
+
7
+ 1. **In-repo** - add the capability to this source tree (fork + PR). Every recipe below is about this path, and each
8
+ one lists the places you must touch **and the test that will catch you if you miss one**.
9
+ 2. **Out-of-process** - call the project from a program you keep elsewhere, through **MCP** (`mcp-server/`, 27 tools)
10
+ or the CLI's `--json` surface. This is the only boundary that exists for code that does not live in this repository.
11
+
12
+ ## Why there is no plugin loader
13
+
14
+ A plugin that runs inside this process would get, by construction, the same reach as the code it joins: the decrypted
15
+ database path, the config (including every API key), the filesystem, and the message channel. The project's standing
16
+ constraint is that **sending, assistant, MCP and cloud AI all keep an explicit permission boundary** - so the boundary
17
+ that must be crossed to extend this thing is a process boundary, not a function call. MCP is that boundary: it is
18
+ enumerable (`capabilities --json` lists what is exposed), scoped (per-tool confirmation for anything that leaves the
19
+ machine), and can be turned off without touching this repository.
20
+
21
+ A plugin/adapter mechanism is on the roadmap on purpose, with its constraint written down: third-party extensions must
22
+ not read configuration, databases or arbitrary files directly, and must be disableable individually
23
+ (`docs/ROADMAP.md`, "插件/适配器机制"). It is not implemented.
24
+
25
+ ## Recipe A - a new assistant tool
26
+
27
+ The most common extension. All of it is in `src/services/assistantTools.ts` unless noted.
28
+
29
+ | Step | Where | If you skip it |
30
+ | --- | --- | --- |
31
+ | 1. Declare it | `TOOL_DEFS` | The model never sees it - dead code. A test fails. |
32
+ | 2. Implement it | `executeTool`, one `case '<name>':` | The model sees it, calls it, and gets `(未知工具: <name>)`. A test fails. |
33
+ | 3. Config prerequisite (only if it cannot run without a key) | `TOOL_REQUIREMENTS` - one entry, `{ key, why }` | The tool is offered on machines where it cannot work. That is the failure this project already measured once: a model tried both search tools, both errored, and the answer said "both search tools are broken". |
34
+ | 4. Cloud egress (only if user data leaves the machine) | In the handler: `if (ctx.requiresConfirm && args.confirm !== true) return confirmPreview(...)`; then claim it in `bin/weflow-cli.ts` under `safety.mcpSurface.callsCloudModels` **and** `requiresConfirm` | An external MCP caller sends the user's data with no preview. Tests fail - the assertion counts `ctx.requiresConfirm` occurrences in the source, so it cannot drift from the field. |
35
+ | 5. Keep it off MCP (only if it cannot do its job over that transport) | `MCP_EXCLUDED` - one entry whose **value is the reason** | It is advertised on a transport where it silently cannot work (`look_at_image` is the worked example). |
36
+ | 6. Coverage note | The header of `test/assistant-tools-branches.test.ts` states how many tools are executed; update it | The coverage claim becomes false while still reading as true. |
37
+
38
+ Notes that are easy to get wrong:
39
+
40
+ - **The description is the interface.** The model decides whether to call the tool from the description alone - keep it
41
+ a sentence about *when* to use it, not a restatement of the name.
42
+ - **`parameters` must be a JSON-schema `object`.** An empty `properties` is fine for a no-argument tool.
43
+ - **Failures are `(...)`**, successes are not. `producedContent()` in the same file reads that convention, and the
44
+ trace uses it. A success message that starts with a bracket has to be added to `PAREN_SUCCESS_PREFIXES` or it will be
45
+ reported as "no content".
46
+ - **MCP needs no change.** The MCP surface is *derived* from `TOOL_DEFS` (`MCP_TOOL_DEFS`) - adding a tool adds it to
47
+ both paths at once. That is deliberate, and it is also why the surface is not read-only: see `docs/MCP.md`.
48
+
49
+ ## Recipe B - a new config key
50
+
51
+ A key must appear in **five** places, and each one fails differently:
52
+
53
+ | Where | Failure if missing |
54
+ | --- | --- |
55
+ | `src/services/configService.ts` - the `CliConfig` interface | Type errors elsewhere; the compiler catches this one. |
56
+ | same file - the default literal | - |
57
+ | same file - the `load()` mapping | The value silently disappears on the next start. The user sees "I set that already". |
58
+ | same file - the `clear()` reset | `config reset` **loses** the old value and `getAll()` then reports it as never set. |
59
+ | `bin/weflow-cli.ts` - `configurableKeys` | `config set` refuses the key outright. This one happened: `dashscopeApiKey`. |
60
+
61
+ `test/config-keys.test.ts` reads all five and fails if a key is missing from any of them. It is a static text
62
+ assertion, so changing the *format* of those literals turns it red - which is the intent, not a nuisance.
63
+
64
+ Keys that hold secrets belong in `ENCRYPTED_KEYS` in the same file (they are stored encrypted at rest; reading the raw
65
+ file shows ciphertext, not the value).
66
+
67
+ ## Recipe C - a new CLI command
68
+
69
+ Two places in `bin/weflow-cli.ts`: the `program.command('...')` registration, and - if it should appear in the
70
+ interactive menu - `showInteractiveMenu()` plus the `switch (action)` that maps a menu choice back to the command.
71
+
72
+ `test/cli-menu.test.ts` keeps those in sync: menu entries and `switch` cases must match **in both directions**, and
73
+ every `runCmd('x')` / `runSubCmd('p','c')` the menu calls must name a command that actually exists (that test asks the
74
+ CLI's own `--help`, because the source cannot tell top-level commands from subcommands - 99 `.command('...')` call sites
75
+ as of 2026-09-28, against however many real commands `--help` reports). This matters because both failure modes are silent: a menu entry with no `case` does nothing
76
+ when picked, and `runCmd` is written as `if (cmd) await ...`, so a renamed command makes the entry do nothing too.
77
+
78
+ Two conventions that hold across the command surface: every command that a script or an AI might drive has a `--json`
79
+ mode, and anything destructive is two-phase (`--dry-run` preview, then `--yes`).
80
+
81
+ ## Recipe D - a new hand-written MCP tool
82
+
83
+ Three places: the `ListTools` table in `mcp-server/index.ts`, a `case` in its `CallTool` switch, and the table in
84
+ `docs/MCP.md`. `test/tool-registry.test.ts` compares the doc table against the actually-served set (hand-written +
85
+ derived) and fails on a missing row, an extra row, or a duplicate - all three have happened or nearly happened.
86
+
87
+ Prefer a **derived** tool (Recipe A) unless it needs something the assistant layer cannot provide. The hand-written
88
+ list exists for tools with no assistant counterpart (article fetching, the versioned `weflow-message/v1` export).
89
+
90
+ ## Recipe E - a new Python workflow
91
+
92
+ Scripts live in `scripts/` and are invoked through `src/services/pythonBridge.ts` (`runPythonJson` when the script
93
+ prints JSON). Three rules that this project has been bitten by:
94
+
95
+ - **Encoding: the bridge handles it, your hand-run does not.** Every script spawned through the bridge gets
96
+ `PYTHONIOENCODING=utf-8` (`src/utils/pythonProcessEnv.ts`), so stdout is UTF-8 there and `json.dumps(...,
97
+ ensure_ascii=False)` - what the scripts use - round-trips correctly. Running the same script by hand on this machine
98
+ gives a **GBK** stdout: Chinese comes out as GBK bytes (mojibake if you were expecting UTF-8) and a character
99
+ outside GBK (`✓`, an emoji) raises `UnicodeEncodeError` and kills the run. 34 of the 55 scripts call
100
+ `sys.stdout.reconfigure(encoding='utf-8', errors='replace')` in `main()` so both paths behave the same; if you add a
101
+ script you intend to run by hand, do the same. A probe that forgets to set that env var will show you a decoding bug
102
+ that does not exist in the real path.
103
+ - **Fail loudly.** Exit non-zero, or return a JSON object whose `success` is false with an `error` string. "No JSON on
104
+ stdout" must never be read as "no results" - that conflation is why `get_todos` distinguishes "no pending todos" from
105
+ "extraction has never been run".
106
+ - **State goes in over stdin, not argv.** Command lines are visible in the process list; the drafting tool passes the
107
+ conversation over stdin for exactly that reason, and `semantic_search.py` takes the query through the environment.
108
+
109
+ Tests for Python live in `test/*_test.py` and run on the same filesystem as everything else (`npm test` covers
110
+ TypeScript; `python -m unittest discover -s test -p '*_test.py'` covers these).
111
+
112
+ ## Recipe F - a new knowledge source
113
+
114
+ The knowledge base is fed by "material that carries `[[wikilinks]]`": `compile_wiki` scans a source directory
115
+ (`--source`, already a flag - no code change needed to add a source), aggregates every wikilink into concepts, and
116
+ writes concept pages into the Vault. There are four producers today, all writing the same card shape into
117
+ `output/*-notes/`: `article_notes.py` (concepts extracted from the Vault's article notes - it exists because
118
+ those notes, measured over the whole corpus, carry **no** concept links), `chat_notes.py` (conversation cards),
119
+ `fav_notes.py` (WeChat favourites, whose article bodies come from the daily line's cached fetcher rather than a
120
+ second downloader) and `user_notes.py` (the user's own hand-written notes, read-only, whose summaries are marked
121
+ as the model's reading). Adding a fifth means writing one producer that satisfies the table below; the
122
+ aggregator, the lint and the assistant-facing search do not change - the lint finds card directories with an
123
+ `output/*-notes` glob for exactly that reason.
124
+
125
+ To add another, the producer must satisfy the consumer's contract exactly:
126
+
127
+ | The consumer (`scan_articles`) needs | Notes |
128
+ | --- | --- |
129
+ | `*.md` under the source dir, with frontmatter | `title`, `source`, `topic`, `tags` are read; `README.md` is skipped |
130
+ | A `## AI 摘要` (or `## 深度解析`) section | the paragraph after it becomes the note's summary |
131
+ | `[[name]] — description` lines | anywhere in the body; the description is what gets fed to page generation, so keep it clean (no trailing labels) |
132
+
133
+ **Two rules that are not obvious from that table.** Links should be rendered by your code, not written by a model -
134
+ the consumer collects *every* wikilink in a body and cannot tell who wrote it, so a stray one invents a concept
135
+ (this is why `chat_notes.plain()` strips `[[...]]` from model-authored free text). And a wikilink whose description
136
+ is polluted - a trailing `(话题)`, a stray marker - silently degrades every concept page it feeds, because that
137
+ description is the material for the page.
138
+
139
+ **A new source also has to pick a line.** Since 2026-09-27 the concept pages live in two directories -
140
+ `Wiki/Concepts` for the article line, `Chat/Concepts` for the conversation line - so a source that writes into a
141
+ directory nobody reads is invisible, and `compile_wiki` writes whichever line you point `--output` at (one line per
142
+ run, by design). If your source belongs to an existing line, you are done; if it deserves its own, it goes into
143
+ `_utils.CONCEPT_DIRS` and into every other declaration that `test/concept-dirs-agreement.test.ts` pins (that test
144
+ counts the sites itself rather than trusting a number written here) - that test
145
+ fails until every reader of concept pages knows about the new directory, which is the point.
146
+
147
+ The guard to copy: `test/chat_notes_test.py` writes a card and then runs the **real consumer**
148
+ (`compile_wiki.scan_articles`) over it, asserting the links and descriptions come back. That is the shape of every
149
+ producer/consumer pair in this project - do not assert on the markdown text, assert that the other side reads it.
150
+
151
+ ## What will catch you
152
+
153
+ | Invariant | Test |
154
+ | --- | --- |
155
+ | Declaration table and `executeTool` cases match, **both directions**, names unique, every declaration usable | `test/tool-registry.test.ts` |
156
+ | Availability rules and the MCP exclusion table name real tools | `test/tool-registry.test.ts` |
157
+ | `docs/MCP.md`'s table equals the served MCP surface, no duplicates | `test/tool-registry.test.ts` |
158
+ | Every inbound-egress tool carries the `confirm` gate, and `capabilities --json` agrees | `test/assistant-tools.test.ts` |
159
+ | A config key exists in all five places | `test/config-keys.test.ts` |
160
+ | The MCP subset contains no write/send/publish tool | `test/assistant-tools.test.ts` |
161
+ | Panel IPC surface is exactly the declared method list | `test/panel-packaging.test.ts` |
162
+ | Interactive-menu entries match `switch` cases, and the commands they call exist | `test/cli-menu.test.ts` |
163
+ | A knowledge source's output is readable by the wiki aggregator, links and descriptions intact | `test/chat_notes_test.py`, `test/compile_wiki_test.py` |
164
+ | Every declaration of the concept directories agrees, and nothing that imports the constant stops iterating it | `test/concept-dirs-agreement.test.ts` |
165
+
166
+ ## Red lines
167
+
168
+ These hold for any extension, and the full statements live in `AGENTS.md` and `CLAUDE.md`:
169
+
170
+ - Local data is the user's. Never put database paths, keys, tokens, account identifiers, chat content, exports or real
171
+ local paths into source, docs, fixtures, issues, commits or command output.
172
+ - **Sending is structurally unreachable from a model-driven path.** No extension may add a path that sends a message,
173
+ fills an input box, or drives another application's UI on the model's behalf.
174
+ - Anything that leaves the machine goes through `privacyGate`. Redaction, not discretion.
175
+ - Do not widen an assertion or a test to make a number look better. A static check that has to be relaxed is usually
176
+ reporting a real change.
177
+ - Do not record a guess as a verified fact. When something has not been measured, say so where the claim is made.
178
+
179
+ ## What does not exist yet
180
+
181
+ Named here so that the gaps are not mistaken for omissions:
182
+
183
+ - **A plugin loader / adapter interface.** Roadmap, not implemented (see above).
184
+ - **A public import surface.** No `exports`, `main` or `types`; the published package is a CLI, and `src/` is shipped
185
+ without a stability promise.
186
+ - **A versioned extension contract.** The versioned contracts that exist are data contracts (`weflow-message/v1`,
187
+ `weflow-sync/v1`), not code contracts.
@@ -0,0 +1,310 @@
1
+ # Jev 在 WeFlow CLI 的应用 · 施工文档
2
+
3
+ > 用途:交给代码 Agent(或开发者)接手 WeFlow CLI 中 Jev(TypeSafe 决策模型)相关的后续工作。
4
+ > 项目:WeFlow CLI 1.7.0
5
+ > 本仓库可能存在尚未提交的维护者改动。**施工前必须先检查工作区,不得覆盖或重置已有修改。**
6
+
7
+ Jev 已经接入并落地在六个地方。这份文档回答三个问题:**它是什么(契约)**、**现在怎么用的(现状)**、**还欠什么(待办)**。目标是接手人只读这一份就能判断该动哪里、不该动哪里。
8
+
9
+ ---
10
+
11
+ ## 1. 施工目标
12
+
13
+ 按优先级:
14
+
15
+ 1. **把质量闭环做完(P0,唯一真正欠着的)**。所有概率目前都没有金标准——现有的一切依据都是"与另一个不完美的基准比"。`scripts/quality_eval.py` 已经把这件事压到"照着编号填 50 行",但**这一步需要人读文章**,见 §7 的红线。
16
+ 2. **按定标结果校准阈值(P0 的第二半)**。相关度切点 0.5 / 1.5、收录切点 `INCLUDE_THRESHOLD = 0.5` 全部是**暂定值**,没有任何金标准支撑。定标完成后如果最优切点离现用值很远,该改的是常数,不是模型。
17
+ 3. **在新增判断点时正确使用 Jev(P1)**。契约、失败取向、边界见 §3 与 §6。
18
+ 4. **不要重做已经定了的事**。有几件看起来像"改进"的事已经被量过并明确否掉或推迟,见 §5。
19
+
20
+ 长期路线见 [ROADMAP.md](ROADMAP.md);安全与架构取舍见 [DECISIONS.md](DECISIONS.md)(Jev 相关决策编号见 §4 末尾)。**本文件是施工执行书。**
21
+
22
+ ---
23
+
24
+ ## 2. 开工前检查
25
+
26
+ 在任何编辑前执行并记录结果:
27
+
28
+ ```powershell
29
+ git status --short
30
+ git log --oneline -12
31
+ npm run build
32
+ npm test
33
+ python -m unittest discover -s test -p '*_test.py' -q
34
+ ```
35
+
36
+ 如果已有测试失败,先记录失败测试和原因,**不能把既有失败归到本次施工,也不能为了变绿而删除或放宽测试**。尤其不要放宽 `test/jev_client_test.py` 里的契约断言——那是刻意拧紧的。
37
+
38
+ 重点阅读:
39
+
40
+ - `AGENTS.md`:仓库级约束、敏感数据与占位符要求。
41
+ - `docs/PROJECT_STATE.md`:当前真实能力、限制与验证状态。
42
+ - `docs/DECISIONS.md`:Jev 相关的**定过的事和不做的事**(编号见 §4)。
43
+ - `scripts/jev_client.py`:契约的唯一实现,注释里写明了每一条为什么。
44
+ - `scripts/quality_eval.py`:本轮的施工入口。
45
+ - `SECURITY.md`:AI 出境、密钥与本地服务边界。
46
+
47
+ ---
48
+
49
+ ## 3. Jev 的契约(施工前必须掌握)
50
+
51
+ ### 3.1 它是什么
52
+
53
+ TypeSafe 的 Jev(System One)是一个**只做判断、不做生成**的模型:输入一段 state 和一批**类型化问题**,返回**带概率的答案**。它不吐散文,所以没有"用正则从自然语言里抠回结构"这一层——那正是它被引入的原因。
54
+
55
+ 对比旧路径(`docs/DECISIONS.md` 有测量):日报原来把"主题 + 相关度"塞进同一个散文 prompt 让 LLM 顺手答,再用两条正则抠回来。结果在 2201 篇存量文章里,`relevance` 有 **2199 篇**都是默认值「中」,`tags` 全是 `[主题名]`。
56
+
57
+ **Jev 只替代"判断",不替代"生成"。** 摘要、标签、概念仍然是 LLM 生成的——Jev 根本不会生成。
58
+
59
+ ### 3.2 接口
60
+
61
+ 唯一实现:`scripts/jev_client.py`(别处不要另写一份客户端)。
62
+
63
+ ```
64
+ POST https://api.typesafe.ai/v1/systemone Authorization: Bearer <key>
65
+ request {model, state, questions}
66
+ response {model, answers, usage}
67
+ ```
68
+
69
+ - `state`:字符串 / JSON 对象 / 数组,**共享给该请求里所有问题**。
70
+ - `questions`:`{名字: {type, instructions, criteria}}`,一次请求可以问很多个。
71
+ - 契约来源不是转述:`GET https://api.typesafe.ai/openapi.json` 加上实测。
72
+ - 别名只有两个:`jev-latest`(默认)、`jev-preview`。
73
+ - key 的取值顺序:显式传入 → `TYPESAFE_API_KEY` → 配置(走 `_utils.decrypt_lock` 自动解密)。配置命令:`weflow-cli config set typesafeApiKey "..."`。key 不落盘、不打印。
74
+
75
+ ### 3.3 三种问题类型
76
+
77
+ | type | `criteria` 的形状 | 答案字段 | 备注 |
78
+ | --- | --- | --- | --- |
79
+ | `choice` | **对象** `{选项名: 何时选它}`(判据文本会被模型读到) | `choice` / `confidence` / `probabilities` | 选项集合就是 criteria 本身——**少一个选项,模型就永远选不到它** |
80
+ | `score` | **有序数组**,**位置即分值,从 0 开始**(零索引) | `score` / `legend`(+ `confidence`) | `score` 落在 `[0, len-1]` 区间 |
81
+ | `noul` | 不需要(可选传 `{true, false}`) | `noul`(一个浮点) | **不返回 `confidence`** |
82
+
83
+ 零索引是硬语义:`scripts/jev_client.py` 的 `RELEVANCE_LEVELS` 与 `_utils.RELEVANCE_NAMES` **按下标一一对应,两者都不能重排**(有测试钉住)。
84
+
85
+ ### 3.4 两个容易漏掉的契约细节
86
+
87
+ - **`instructions` 可以是结构化对象**(`{"goal": ..., "rules": [...]}`),不只是字符串。已对着线上 API 验过。
88
+ - **每个响应都带 `model` 字段,是实际服务的版本**(实测 `jev-1.13.0`),不是你请求的别名(`jev-latest`)。别名会漂而结果看起来都正常,所以它被记进 `usage['model']`、`JevClient.last_model`,以及日报的 `.articles.json` 的 `decisionModel` 键。
89
+
90
+ ### 3.5 四条硬校验(`jev_client.py:_check_choice`)
91
+
92
+ 每个 `choice` 答案在使用前都要过四关,不过就抛 `JevError`:
93
+
94
+ 1. `probabilities` 的**键集等于 criteria 的键集**;
95
+ 2. 所有取值在 `[0, 1]`;
96
+ 3. 概率和 ≈ 1(容差 0.02);
97
+ 4. `choice` **就是概率的 argmax**。
98
+
99
+ 第 4 条最要紧:**契约漂移的失效方式不是报错,而是"选错了但看起来正常"**。这个仓库踩过一个同型的坑——把 `noul`(浮点)当布尔读,每个问题都读成"否",而输出还把责任推给了模型。
100
+
101
+ ### 3.6 成本模型(决定了该怎么用它)
102
+
103
+ - **多问几个问题的边际成本≈0**:实测 2 个问题 0.84s / 2083 输入 token,12 个问题 0.91s / 2238。**`state` 才是开销大头。**
104
+ - 结论:**Jev 的使用单位是「一个 state → 一批判断」,不是一个问题。** 与其为每个条目单独调一次,不如把 N 个条目装进同一份 state 一次问完(`decide --over` 就是干这个的)。
105
+ - 输入 token 单价按 `INPUT_USD_PER_TOKEN`(`0.042 / 1M`)折算,输出在推广期内不计费。客户端只拿得到 token 数、**拿不到账单金额**,所以成本始终是估算。
106
+ - 实测数量级:40 篇约 $0.0037、60 篇约 $0.0057,整库 2201 篇约 $0.21。
107
+
108
+ ### 3.7 时效(会过期,别照着旧文抄)
109
+
110
+ **决策模型的免费期到 2026-09-25 结束**,之后输入 token 开始计费(推广期内输出免费)。凡是重测代价高的事情,要么赶在免费期内做完,要么先假定重跑的钱。这一条同时写在 `docs/PROJECT_STATE.md`。
111
+
112
+ ---
113
+
114
+ ## 4. 已落地的六个应用
115
+
116
+ 每个触点都写清了:入口在哪、问什么、失败怎么退、踩过什么坑。
117
+
118
+ ### 4.1 日报分类 —— 主题 / 相关度 / 收录
119
+
120
+ 文章的主题(6 类 choice)、相关度(3 档 score)、以及"该不该收录"(noul)由 Jev 判断;**摘要、标签、概念仍然由 LLM 生成**。
121
+
122
+ - 调用点:`scripts/biz_daily.py:810-821`(`create_client(config=config) if args.classifier in ('auto','jev')`,逐篇走 `_classify_with_jev` `213-226` → `decide_article`)。
123
+ - `--classifier {auto,llm,jev}` 定义在 `biz_daily.py:632-634`,**默认 `auto`** = 有 key 就用 Jev,否则走老路。这是**一条命令回滚**,也让任何人能把同一天两种路径各跑一遍。`jev` 但无 key 时打 WARN 并退回 LLM 路径(`815-816`)。
124
+ - **`--no-ai`(或 `dailyAiEnabled=false`)时整块 Jev 代码都不执行**——总闸在 `biz_daily.py:807`。
125
+ - **LLM 的 prompt 是故意不动的**:它仍然被要求输出 `【主题】`/`【相关度】`,而这两个值现在被忽略。这样回退路径与旧行为**逐字节一致**,代价是每篇浪费约 20 个输出 token。瘦身是后续项,不是本次。
126
+ - 相关度映射用 `int(score + 0.5)` 取最近档,**不是 `round()`**——Python 的 round 是银行家舍入,`round(0.5)` 得 0、`round(1.5)` 得 2,边界会跳档。
127
+ - 原始分一律落盘(`relevanceScore` / `includeScore` / `topicConfidence`,在 `_serializable_article` `biz_daily.py:142-165`,**非 None 才写**、`round(float,3)`),所以改阈值不用重跑语料。**`decisionModel` 是 `.articles.json` 的顶层键**(`biz_daily.py:977`);md frontmatter 侧在 `1020-1025`。
128
+ - **收录门 `INCLUDE_THRESHOLD = 0.5` 在 `scripts/generate_ai_report.py:95`**,唯一使用者是谓词 `admits()`(`205-227`),**两条装载路径都调它**(`.articles.json` `241` / md 兜底 `278`);焦点主题永远收(`218`)。
129
+ - **`generate_ai_report.py` 自己完全不调 Jev**(该文件里 `create_client` 出现 0 次),它只读已落盘的概率。所以"改收录口径"和"改判断口径"是**两个不同的文件**。
130
+
131
+ ### 4.2 `decide` —— 本机判断层
132
+
133
+ `python scripts/decide.py`:一个 state + 一批类型化问题、一次调用,返回类型化答案 + 概率 + 这次的花费。
134
+
135
+ - 三种输入:`--request <file>`(或 stdin)、批量模式 `--over <glob> --ask <text>`、`--dry-run` 只校验不调用。
136
+ - 请求形状**本地先校验**(`validate_request`):形状不对就在本机报错,不换一个来自服务端的 422——那种错误只说得出"哪个字段不合法",说不出"你本来想干什么"。
137
+ - 批量模式的问题名是 `f<序号>|q<序号>`,**不是把文件名编进去**(文件名里有 `|`、空格、中文标点);序号→文件的映射在返回的 `batch.files` / `batch.asks` 里。
138
+ - `--max-chars`(默认 1500)决定每个文件喂多少,**并且回显**——因为它决定上限:同一批问题,只喂前 14 行与基准一致率 77.3%,喂前 1500 字符则 88.6%。**证据量决定上限。**
139
+ - **刻意不暴露成 MCP 工具**。`bin/weflow-cli.ts:332-343` 的 `capabilities` 声明原文:`readsLocalData: { request: false, over: true }`、`invokesAI: true`、`sendsCallerProvidedState: true`、`confirmationRequired: true`、**`mcpExposed: false`**、`whyNotMcp: 'an MCP client must not be able to drive local outbound calls'`。命令注册在 `4974-5067`;`--json` 而没有 `--yes` 会返回 `CONFIRMATION_REQUIRED`(`5028-5036`);子进程超时 120s(`5051-5054`)。**MCP 工具清单里确认没有 decide**(`5812-5828`)。
140
+ - 声明粒度必须与行为粒度一致:`--over` **会读本地文件**,所以 `readsLocalData` 按两条输入路径**分开**声明,不能一刀切成 `false`。
141
+
142
+ ### 4.3 `quality_eval` —— 定标(本轮入口)
143
+
144
+ ```powershell
145
+ python scripts/quality_eval.py sample --n 50 # 抽样 + 现场用生产路径打分
146
+ # 人工填 ~/.weflow-cli/labels/<时间戳>.json 里每条的 label
147
+ python scripts/quality_eval.py score <该文件> # 准确率 + 校准曲线 + 阈值扫描
148
+ ```
149
+
150
+ - **必须现场打分,不能读存档**:磁盘上没有任何一篇文章带 `includeScore`(现存那批生成于该字段引入之前),而且存档的 topic 本身就是要被检验的东西。样本写进 `~/.weflow-cli/labels/`,**不进仓库**(里面是文章标题和人的判断)。
151
+ - 取样是**按概率档位分层**的。只按主题取样会退化成"标了 49 条、买到的信息量等于标了 4 条"(这是脚本 docstring 里的原始测量,与当前那份 50 篇样本不是同一批):49 篇里 45 篇的收录分都在 0.2 以下,边界上只有 4 篇。分层取样后样本**不再是自然分布**,所以档位在全池里的占比单独报(`prevalence`)——它回答的是另一个问题:"阈值一动,每天会多收或少收多少篇"。
152
+ - `label` 里没把握的留 `null`,算"未标注"而不是"标错"。
153
+ - 固定 seed,同一条命令两次跑抽到同一批——否则"我标了一半"之后重跑就全错位。
154
+ - `score` 打印三样:主题准确率(**Jev 与历史存档标签并列**)、**校准表**(每个概率桶里人工说"该收"的比例——斜着往上走才有分辨力,一条平线就是乱猜)、**阈值扫描**(最优点在哪,离 0.50 很远就该改常数)。
155
+
156
+ ### 4.4 `reply_debt` —— 谁在等我回话
157
+
158
+ 按会话逐个判,一个会话一份 state、一次请求问 **5** 个问题,所以**每条判断都知道它属于哪个会话**。
159
+
160
+ > ⚠️ 模块 docstring(`reply_debt.py:9-12`)写的是"6 个问题",**代码是 5 个**(`build_questions` `155-174`),测试也钉的是 5 个(`test/reply_debt_test.py:142-147`)。这是 docstring 的笔误,不是被删掉了一个——`reply_debt.py` 最初那一版同样是 5 个。**不要为了对齐 docstring 去加第六个问题。**
161
+
162
+ 五个问题是:`waiting`(noul,对方是否在等我回应)、`urgency`(score,4 档)、`commitment`(noul,我承诺过而没下文的)、`money`(noul,硬承诺)、`kind`(choice,`KINDS` `54-61`)。
163
+
164
+ 这个方法与 `extract_todos.py` 不是同一件事:那个把 4000 条消息一次交给模型,问的是"有没有承诺被提到","是谁在等"在那种输出里根本没有立足之地。
165
+
166
+ - `--min-prob` 可以把概率当阈值用;**输出是提示,不是事实**,这点写在脚本 docstring 里,接手人不要把它升级成结论。
167
+ - 不落盘任何数据文件:只有 `--html` 时才写一个文件(`399-410`),`--json` 走 stdout。
168
+ - `--html <path>` 输出一个**不含聊天内容**的自包含单页,所以可分享(渲染 `render_html` `89-152`,docstring `90-93` 明写这是刻意约束;页脚 `148-149` 自述"本页不含任何聊天内容")。只透传固定字段(name/days/kind/waiting/urgencyScore/证据字数),有测试钉住:往行数据里塞消息正文,页面里不许出现(`test/reply_debt_test.py:185`)。
169
+ - 阈值默认:`--days` 14、`--limit` 40、`--min-prob` 0.5;"拿不太准"的下限 0.3 是**硬编码**、不是参数(`397`)。
170
+
171
+ ### 4.5 `route_cards` —— 查询词选择(**排序不是它做的**)
172
+
173
+ 把"搜我的聊天"变成"先选会话、再精确检索"。**分工是实测出来的,不是设计出来的**:
174
+
175
+ - 最初想让 Jev 排序会话 → **实测不成立**:用 noul 问"这个会话更值得打开吗",候选全落 0.50~0.51(领券群与会务群同档);换成 score 问"有多相关",20 个会话全给 1.74~1.76,**与命中数毫无关系**。同一批数据里纯本地按命中数排序却很准。
176
+ - 原因:模型看不到会话内容,卡片上只有"类型/条数/时间/名称/命中数",它无从判断,只能给中庸分。
177
+ - **所以排序由本地命中数决定,Jev 只做一件它确实做得到的事:从候选词里挑出真正的查询词**(`pick_terms` `256-273`,每个候选词一个 `noul` 问题 `t0`/`t1`/…,阈值 `NOUL_THRESHOLD = 0.5` 在 `48`)。第一版把 n-gram 碎片一起递过去,它稳定地留下「会议」、否掉其余七个——这是在**有限的选项里做选择**,正是它的本职。
178
+ - **不要把排序"优化"回去。** 这一段是可复用的教训:先证明它排得了,再让它排。无 key 时 `SystemExit('没有 TypeSafe key;改用 --keyword 手动给查询词')`(`359-360`);Jev 全否时用 `kept or terms` 兜底(`376`);缺字段单独告警"契约不符,不要当成模型判为否"(`363-367`)。
179
+ - **这条实测结论目前只写在注释和文档里**(`route_cards.py:5-14` 记了 0.50~0.51 与 1.74~1.76 两组数字、`344`、D-036),**没有断言测试**——也就是说没有东西会拦住下一个人把它改回去。要加固的话,加一条"排序不得依赖 Jev 分数"的测试是合理的施工项。
180
+
181
+ ### 4.6 `semantic_search` —— rerank
182
+
183
+ 语义搜索是两段式:先按向量相似度取候选,再用决策模型对**前 20 条**候选做 rerank(`RERANK_CANDIDATES = 20` `semantic_search.py:392`,摘录 `RERANK_EXCERPT_CHARS = 300` `393`)。
184
+
185
+ - 喂什么:state 是 `问题:…\n\n候选:…`,每行 `【候选i】标题|来源` + 该条前 300 字符(`423-429`)。**正文来源可能是聊天消息**。
186
+ - 问什么:每个候选一个 `noul`「是否直接回答了上面的问题,或含有回答它所需的关键信息?仅话题相近不算。」(`396-408`),**外加一个 `best` 单选做自检**(`433-438`)。自检与逐条判断不一致时**只 WARN,不重排**(`461-465`)。
187
+ - 怎么重排:`sort(key=lambda t: (t[0] is None, -(t[0] or 0.0), t[1]))`(`460`)——**没拿到分的按原序垫到最后,不当 0 分**。新分写进 `rerankScore`(仅非 None 时),原始 `score` 保留。
188
+ - 回退全是 fail-soft(这是设计,活体跑出过 `HTTP 529`):没有客户端或候选 <2 → 原样返回(`420-421`);调用异常 → WARN + 原顺序(`442-444`);全部返回 None → WARN + 原顺序(`455-457`)。
189
+ - **它唯一的真实风险是候选编号错位**:模型拿 `c5` 的内容回答 `c3` 时,最该被选中的那条会**无声下沉**,没有任何报错。`docs/PROJECT_STATE.md:83` 记了这一条。
190
+ - ⚠️ **`--no-rerank` 只在 Python 侧存在**:CLI 的 `search` 组参(`bin/weflow-cli.ts:4719`)没给它通道,要回退得直接跑 Python 脚本。
191
+
192
+ ### 4.7 通用原则(六个触点共同的形状)
193
+
194
+ - **客户端 fail-loud,调用方 fail-soft**:`jev_client` 一律抛 `JevError`(带 HTTP 状态与 400 字符截断的响应体,**从不含 key**);调用方逐条决定退到哪条老路(打印 WARN 并回退)。一篇文章判不出来,不该让一整天的日报崩掉;但客户端**不许把失败伪装成一个看起来正常的答案**。
195
+ - **瞬时故障要重试**:429/5xx/529 重试(次数与退避刻意短,最坏约 2.4s)。服务 2026-09-15 才发布,被打满时返回 `529 system_overloaded` 是常态;调用方是逐篇分类的,长退避只会把一整天的日报拖成几十分钟——而那正是它该退回老路的时候。
196
+ - **并发刻意保持 6**:服务还新,提高扇出买到的是重试而不是吞吐。
197
+
198
+ **Jev 相关的决策编号**(改任何 Jev 行为前先读这几条——里面记着"为什么不那样做"):
199
+
200
+ | 编号 | 位置 | 内容 |
201
+ | --- | --- | --- |
202
+ | D-031 | `DECISIONS.md:351` | 判断交给决策模型,生成留给 LLM(含 `--classifier` 回滚、60 篇对照**不是**准确率) |
203
+ | D-032 | `:506` | 决策模型做 rerank:一次请求、取 top 20 |
204
+ | D-033 | `:549` | 给人看的机器判断都要带证据(`reply_debt` 的五问 + 两条通用规则) |
205
+ | D-034 | `:646` | 把决策模型作为本地原语暴露,但**不**经 MCP |
206
+ | D-035 | `:697` | 助手的单轮快路:**设计过、故意不发**(Status: Deferred) |
207
+ | D-036 | `:759` | 检索用决策模型选查询词,**不**用它排会话 |
208
+
209
+ ---
210
+
211
+ ## 5. 待办清单
212
+
213
+ ### P0 — 定标与校准(本轮的主要工作)
214
+
215
+ 1. 用 `quality_eval.py sample` 生成待标注样本。**磁盘上已经有一份现成的**:`~/.weflow-cli/labels/labels-20260921-203232.json`,**50 篇,`include` 与 `topic` 两栏都还是空的(已标注 0 条)**。先用它,不必重跑抽样。
216
+ 2. **由人**读完这 50 篇并填 `{"topic": ..., "include": true|false}`。**这一条代码 Agent 不能代做**,见 §7。
217
+ 3. 跑 `quality_eval.py score`,按结果判断:
218
+ - **校准表**是否斜着往上走(是 → 概率有信息量;平线 → 它只是在乱猜,这本身是一个必须如实报告的结论);
219
+ - **阈值扫描**的最优点离现用的 0.50 有多远(远 → 改常数,不改模型);
220
+ - 主题准确率里 **Jev 与历史存档标签并列**的那两行——存档标签从来不是金标准,这次才第一次有对照。
221
+ 4. 若结论支持改阈值:改 `INCLUDE_THRESHOLD` 与相关度切点,并**在 `DECISIONS.md` 追加一条**记录改的理由与新值;`PROJECT_STATE.md` 里"暂定"的措辞同步更新。
222
+
223
+ ### P1 — 待决的收尾项
224
+
225
+ - **LLM prompt 瘦身**:把日报 prompt 里现在已经没人读的 `【主题】`/`【相关度】` 要求去掉,省下每篇约 20 个输出 token。`DECISIONS.md` 明确把它记为"后续项"而不是本次——**要做就把回退路径的变化一起测了**,别让它悄悄变成"新的、更差的老路"。
226
+ - `relevance` 的兜底值:未分类文章目前记为「中」,而"中"读起来是个正面评价。这是**待决策**,不是 bug(已具名 `DEFAULT_RELEVANCE`)。
227
+ - 语料层面的缺口(`relevance` 全库 2199/2201 都是「中」这类)已经量完并记录,**不要在没有新证据时重开**。
228
+
229
+ ### 明确不做
230
+
231
+ - **D-035(助手单轮快路)**:设计已记录、代码未写。它**只能靠活着的微信通道端到端验证**,普通环境复现不了,而失效方式是"模型拿着不匹配的工具结果自信地答圆"——比慢更糟。落地条件写在 D-035 里,**不要在没有那些条件时实现它**。
232
+ - **用 Jev 排序会话**(`route_cards` 已量过,见 §4.5)。
233
+ - **把 `decide` 暴露成 MCP 工具**。
234
+
235
+ ### 交接时容易踩的四处不一致(都已核实,属于事实)
236
+
237
+ 1. `reply_debt.py` 的 docstring 说"6 个问题",**代码与测试都是 5 个**(§4.4)。不要为了对齐 docstring 去加第六问。
238
+ 2. **`--html`(`reply_debt`)与 `--no-rerank`(`semantic_search`)只在 Python 侧存在**,CLI 没给通道(`awaiting` 参数表 `bin/weflow-cli.ts:5075-5080`、`search` 组参 `4719`)。写脚本或文档时别假设 CLI 能传这两个参数。
239
+ 3. **`docs/SETUP.md` 缺 `config set typesafeApiKey` 的命令原文**——实际在 `README.md:189-190` 与 `OPERATIONS.md:437-438`(后者含 `config set typesafeApiKey ""`,清空即回退到 LLM 解析路径)。用户是按 SETUP 走的,这一条补在 SETUP 里更合适。
240
+ 4. `route_cards` 的"不让 Jev 排序"只有注释与文档,**没有断言测试**(§4.5)。
241
+
242
+ ---
243
+
244
+ ## 6. 不可违反的边界
245
+
246
+ - **新增数据出境必须写进决策记录。** Jev 的 state 会发到 `api.typesafe.ai`——文章标题与正文现在也在出境范围内。任何**新增**一类内容出境(新的字段、新的脚本、新的判断点)都要在 `DECISIONS.md` 留痕,并确认它符合 `SECURITY.md` 的"显式 AI opt-in / 只上传你选择的内容"。
247
+ - **key 不落盘、不打印、不进命令行、不进日志。** 走 `_utils.decrypt_lock` / `config set`。错误信息只带 HTTP 状态与截断响应体。
248
+ - **不要把 Jev 的判断表述成事实。** 所有输出都是"带概率的提示";用户可见文本里不许写成结论、认定或保证。法律相关只能整理线索。
249
+ - **客户端 fail-loud、调用方 fail-soft**:不要为了"稳"而让客户端把一个失败包装成正常答案(§4.7)。
250
+ - **判据表只有一份,且不可重排。** `TOPICS` / `TOPIC_CRITERIA` / `RELEVANCE_NAMES` 都在 `_utils`,提示词路径与决策路径读同一张表;相关度档位与 `RELEVANCE_LEVELS` 按下标对应。**回退时不能换标准。**
251
+ - **不要把"答案的味道"放进 state。** `decide_article` 只放标题和正文,不放日期、来源,也不放 frontmatter 里的 `topic`/`relevance`——那些是被判断的对象本身。
252
+ - **不要新增一条只有一条路径生效的输入。** 这个仓库反复证明"只在一条路径上生效"是最常见的缺陷形状(json/md 两个装载器、两份分片发现、argv 与环境变量、序列化与批量分支)。新增输入路径**必须配一条测试**。
253
+ - 敏感数据红线照 `AGENTS.md`:数据库、导出、wxid、key、token、路径、日志、聊天内容一律不进代码/文档/夹具/提交/公开输出;示例用 `联系人A`、`示例群`、`YOUR_API_KEY`、`YYYY-MM-DD`。
254
+
255
+ ---
256
+
257
+ ## 7. 红线:哪些事代码 Agent 不许做
258
+
259
+ 1. **不许自己编人工标签。** `quality_eval.py` 的 `label` 字段是**人读完文章后的判断**,是这一整轮唯一能提供金标准的东西。Agent 自己填(无论用哪个模型)会让校准曲线退化成"两个模型互相比",比现在的状态更糟——因为它看起来有了金标准。**做不了就如实报告"需要人工标注",并停在那里。**
260
+ 2. **不许在没有金标准时把校准结论写成结论。** 标注少于 20 条时脚本自己会打印"这些数字还不稳";报告结论时要连这句话一起带出来。
261
+ 3. **不许为了让指标好看而放宽断言或测试**(尤其 `_check_choice` 的四条)。
262
+ 4. **不许把"概率看起来合理"当成"验证过"。** 校准表是平线时,正确的报告是"没有分辨力",不是换一个更漂亮的阈值。
263
+
264
+ ---
265
+
266
+ ## 8. 必须补齐的测试
267
+
268
+ - **契约**:`_check_choice` 的四条断言各有反例(键集不符、越界、和不为 1、`choice` 不是 argmax)——**这四条都必须有用例**,且必须能在离线状态下跑。
269
+ - **序列化路径**:`parse_frontmatter` 返回的是**字符串**标量,所以 md 兜底路径上 `includeScore` 会是 `'0.8'` 这样的字符串。只认 `int/float` 的实现会让新字段在那条路径被**静默忽略**——必须有测试钉住两条装载路径(`.articles.json` 与 md 兜底)行为一致。
270
+ - **回退**:无 key、`JevError`、超时、`529` 各一条,验证日报不崩且降级路径可观察。
271
+ - **批量模式**:`--over` 的展开顺序稳定、问题名↔文件映射正确、`--max-chars` 真的生效并回显。(历史教训:批量模式最初没写测试就加了,一跑就崩——删除补丁在 `main()` 里留下死代码,批量分支落进去引用未定义变量。)
272
+ - **隐私**:`reply_debt` 的 `--html` 页面**不含**任何消息正文(往行数据里塞正文,页面里不许出现)。
273
+ - **全部测试必须离线**,不得调用付费 API。(现有测试确实全部离线:触碰 Jev 的一律 `patch.object(jev.urllib.request,'urlopen')` 或传桩客户端,没有任何测试真的付钱调 `api.typesafe.ai`。)
274
+
275
+ **当前完全没有测试的输入路径**(都是"真调用付费服务"或 CLI 接线那一层,现有测试只覆盖纯函数)——这些是施工时可以补的地方:
276
+
277
+ - `quality_eval.py:169-175` `cmd_sample` 里真正 `create_client()` 并打分的那条路;
278
+ - `semantic_search.search()` 的重排接线(`498`/`507` 的 `create_client()` 与 pool 扩张 `491`/`504`);
279
+ - `route_cards.py` 的 `main()` 全链路(`307-406`:`--yes` 门、`kept or terms` 回退、命中计数、取消息);
280
+ - `reply_debt.py` 的 `main()` 端到端(`274-467`:参数默认值、`--min-prob` 过滤、`--html` 落文件、退出码);
281
+ - `biz_daily.py` 的 `--classifier` 参数接线与 `_apply_decision`(`271-286`)——**全仓库没有任何测试引用 `_apply_decision`**。
282
+
283
+ ---
284
+
285
+ ## 9. 验收标准
286
+
287
+ 只有全部满足才能报告完成:
288
+
289
+ - 改动前后工作区差异清晰,**未覆盖维护者已有修改**。
290
+ - `npm run build` 通过;`npm test` 通过;Python 相关专项测试通过;`git diff --check` 通过。
291
+ - 定标部分:如实报告已标注条数,以及"够不够形成结论"(<20 条时必须说明不稳)。
292
+ - 任何阈值改动都有 `DECISIONS.md` 条目与理由;`PROJECT_STATE.md` 的措辞同步。
293
+ - 检查差异中没有真实隐私数据、密钥、令牌、账号标识、数据库、日志或导出文件。
294
+ - **无法验证的事,明确报告"无法验证"及阻断原因**,不得把推测写成完成。宁可难看的占位,也不要通顺但可能错的结论。
295
+
296
+ ---
297
+
298
+ ## 10. 提交规则
299
+
300
+ 施工 Agent 默认只修改代码、测试和必要文档,**不自动提交或推送**。提交前输出:
301
+
302
+ 1. 改动文件清单;2. 每个改动的目的;3. 测试命令和结果;4. 仍存在的限制;5. 敏感信息检查结果。
303
+
304
+ 提交信息简短、准确、说明用户可见结果,例如:
305
+
306
+ ```text
307
+ fix: apply the calibrated inclusion threshold
308
+ ```
309
+
310
+ 不得在提交信息、Issue、PR 或公开文档中写入真实本地数据、文章标题或调试输出。
package/docs/MCP.md CHANGED
@@ -74,8 +74,19 @@ Copy this entry into your MCP client's configuration and ensure `cwd` points to
74
74
  | `wechat.get_todos` | List todos extracted from chat history. | `scripts/extract_todos.py`, Python |
75
75
  | `wechat.search_knowledge` | Fuzzy-search concept pages in the knowledge base. | Vault Wiki output |
76
76
  | `wechat.search_memory` | Search long-term assistant memory. | `~/.weflow-cli/assistant_memory.json` |
77
-
78
- The chat-data tools share service code with the optional `weflow-cli assistant` WeChat bot, but access is still controlled by the MCP client's permissions and local configuration. The bot and MCP transport are not interchangeable message channels.
77
+ | `wechat.search_chats` | Find which conversation discussed something (literal words). | Decrypted WeChat database, **words leave the machine - `confirm: true` required** |
78
+ | `wechat.search_semantic` | Find chat messages by meaning, not literal words. | `dashscopeApiKey` config, network - **query and hits leave the machine, `confirm: true` required** |
79
+ | `wechat.who_owes_reply` | Rank the conversations waiting on a reply. | Decrypted WeChat database, **text leaves the machine - `confirm: true` required** |
80
+ | `wechat.draft_reply` | Draft candidate replies. | Decrypted WeChat database, **text leaves the machine - `confirm: true` required** |
81
+ | `wechat.export_chat` | Export one conversation to HTML, txt, json or excel. | Decrypted WeChat database, **writes under `output/exports/`** |
82
+ | `wechat.export_messages` | Return a conversation's local messages as `weflow-message/v1` JSON. Read-only; returns no database path, key or configuration. | Decrypted WeChat database |
83
+ | `wechat.get_reading_stats` | Which sources push most and what the daily processes. | `output/biz-daily/` |
84
+
85
+ This table lists the tools shipped today; `weflow-cli mcp-config` prints the authoritative list for the
86
+ installed version. **`wechat.look_at_image` is deliberately not on it**: that tool works by handing the image to
87
+ the assistant's own model through a side channel, and MCP returns only the tool's *text* - the image would go
88
+ nowhere while the reply still said "you can see it now". A tool that cannot do its job on a transport is not
89
+ offered on it (the same rule `unavailableToolReason` applies elsewhere). The chat-data tools share service code with the optional `weflow-cli assistant` WeChat bot, but access is still controlled by the MCP client's permissions and local configuration. The bot and MCP transport are not interchangeable message channels.
79
90
 
80
91
  ## Safety boundary
81
92
 
@@ -83,8 +94,16 @@ The chat-data tools share service code with the optional `weflow-cli assistant`
83
94
  - Article and knowledge-base tools read files under the current project directory. Keep `cwd` scoped to your intended WeFlow CLI checkout.
84
95
  - Chat-data tools (`list_sessions`, `get_messages`, `search_favorites`, `get_sns`, `get_todos`) read your locally decrypted WeChat database. Only run this MCP server on machines where that is acceptable, and never expose the stdio server over a network.
85
96
  - `read_favorite`, `fetch_article`, and `search_public` make network requests; `read_favorite` rejects private/loopback URLs.
86
- - The default MCP surface is read-only. It does not publish drafts, save assistant memory, send messages, mutate todos, or change configuration. Side effects remain in explicit CLI or assistant workflows with their own confirmation boundary.
87
- - Do not place API keys in `.mcp.json`. Use environment variables where a client supports them.
97
+ - The MCP surface is **derived from the assistant's tool table minus `save_memory`**, not a hand-written read-only list - so it is *not* read-only, and this line used to claim it was. What it actually contains, declared in `capabilities --json` under `safety.mcpSurface`: one tool that writes (`export_chat`, new directories under `output/exports/` only) and four that send user data to cloud models (`who_owes_reply` and `search_chats` send chat text to the decision model, `search_semantic` sends the query for embedding and the hits for reranking, `draft_reply` sends a conversation to two models - and **all four** require `confirm: true`, not just `draft_reply`; that is what `safety.mcpSurface.requiresConfirm` lists, and it is now checked against the source rather than restated). Publishing, sending messages, mutating todos, changing configuration and writing assistant memory stay out.
98
+ - **Four tools on this surface send user data to cloud models, and each of them requires an explicit
99
+ confirmation from the machine caller.** They are `who_owes_reply` (every conversation's text, one request
100
+ each), `search_chats` (your question plus words extracted from the chats - not the message bodies),
101
+ `search_semantic` (the query for embedding, then the hits for reranking) and `draft_reply` (a whole
102
+ conversation). An MCP call that does not pass `confirm: true` returns a **preview only** - what would be
103
+ sent and to whom, with character counts where the script can produce them cheaply - and nothing leaves the
104
+ machine. `capabilities --json` lists them under `safety.mcpSurface.requiresConfirm`, and the tool
105
+ descriptions say so, because a calling model that does not know cannot ask its user. The CLI keeps its own
106
+ equivalent boundary for the same work (`--dry-run` / `--yes`), and none of these tools ever sends a message.
88
107
 
89
108
  ## Troubleshooting
90
109