weflow-cli 1.6.4 → 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 (205) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +1966 -0
  3. package/OPERATIONS.md +808 -0
  4. package/README.en.md +93 -7
  5. package/README.md +31 -6
  6. package/bin/weflow-cli.ts +1245 -26
  7. package/dist/bin/weflow-cli.js +1349 -72
  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 +43 -0
  16. package/dist/src/core/ntCore.d.ts.map +1 -1
  17. package/dist/src/core/ntCore.js +31 -0
  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 +13 -0
  80. package/dist/src/services/chatService.d.ts.map +1 -1
  81. package/dist/src/services/chatService.js +23 -0
  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/messageQuery.d.ts +35 -0
  88. package/dist/src/services/messageQuery.d.ts.map +1 -1
  89. package/dist/src/services/messageQuery.js +61 -0
  90. package/dist/src/services/messageQuery.js.map +1 -1
  91. package/dist/src/services/pythonBridge.d.ts +41 -0
  92. package/dist/src/services/pythonBridge.d.ts.map +1 -0
  93. package/dist/src/services/pythonBridge.js +119 -0
  94. package/dist/src/services/pythonBridge.js.map +1 -0
  95. package/dist/src/services/syncService.d.ts +127 -0
  96. package/dist/src/services/syncService.d.ts.map +1 -0
  97. package/dist/src/services/syncService.js +263 -0
  98. package/dist/src/services/syncService.js.map +1 -0
  99. package/dist/src/services/syncState.d.ts +113 -0
  100. package/dist/src/services/syncState.d.ts.map +1 -0
  101. package/dist/src/services/syncState.js +214 -0
  102. package/dist/src/services/syncState.js.map +1 -0
  103. package/dist/src/services/wechatMessageService.d.ts +25 -0
  104. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  105. package/dist/src/services/wechatMessageService.js +69 -7
  106. package/dist/src/services/wechatMessageService.js.map +1 -1
  107. package/dist/src/utils/atomicWrite.d.ts +4 -0
  108. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  109. package/dist/src/utils/atomicWrite.js +60 -0
  110. package/dist/src/utils/atomicWrite.js.map +1 -0
  111. package/dist/src/utils/dateRange.d.ts +15 -0
  112. package/dist/src/utils/dateRange.d.ts.map +1 -1
  113. package/dist/src/utils/dateRange.js +47 -0
  114. package/dist/src/utils/dateRange.js.map +1 -1
  115. package/dist/src/utils/text.d.ts +18 -0
  116. package/dist/src/utils/text.d.ts.map +1 -0
  117. package/dist/src/utils/text.js +20 -0
  118. package/dist/src/utils/text.js.map +1 -0
  119. package/docs/AI_INTERFACE.md +2 -0
  120. package/docs/DECISIONS.md +1484 -20
  121. package/docs/EXTENDING.md +187 -0
  122. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +338 -0
  123. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  124. package/docs/MCP.md +23 -4
  125. package/docs/PROJECT_STATE.md +299 -96
  126. package/docs/SETUP.md +22 -0
  127. package/docs/SYNC_CONTRACT.md +180 -0
  128. package/mcp-server/index.ts +681 -657
  129. package/package.json +84 -83
  130. package/resources/panel/ball-position.cjs +153 -0
  131. package/resources/panel/index.html +46 -0
  132. package/resources/panel/main.cjs +447 -0
  133. package/resources/panel/mascot-focus.png +0 -0
  134. package/resources/panel/mascot-happy.png +0 -0
  135. package/resources/panel/mascot-sorry.png +0 -0
  136. package/resources/panel/mascot-tired.png +0 -0
  137. package/resources/panel/mascot.png +0 -0
  138. package/resources/panel/package.json +7 -0
  139. package/resources/panel/panel.css +407 -0
  140. package/resources/panel/preload.cjs +72 -0
  141. package/resources/panel/quick-menu.cjs +197 -0
  142. package/resources/panel/renderer.js +441 -0
  143. package/resources/panel/tray-menu.cjs +40 -0
  144. package/resources/panel/tray.png +0 -0
  145. package/scripts/_utils.py +403 -54
  146. package/scripts/annual_report.py +4 -2
  147. package/scripts/article_notes.py +423 -0
  148. package/scripts/backfill_articles.py +438 -0
  149. package/scripts/biz_daily.py +590 -105
  150. package/scripts/chat_notes.py +470 -0
  151. package/scripts/classify_daily.py +10 -18
  152. package/scripts/compile_wiki.py +1059 -46
  153. package/scripts/create_reading_notes.py +199 -14
  154. package/scripts/decide.py +253 -0
  155. package/scripts/draft_reply.py +671 -0
  156. package/scripts/export_chat_html.py +212 -46
  157. package/scripts/extract_todos.py +31 -5
  158. package/scripts/fav_notes.py +318 -0
  159. package/scripts/fix_topics.py +2 -1
  160. package/scripts/generate_ai_report.py +214 -9
  161. package/scripts/generate_html.py +18 -3
  162. package/scripts/generate_review.py +2 -2
  163. package/scripts/jev_client.py +294 -0
  164. package/scripts/jev_probe.py +440 -0
  165. package/scripts/nt_common.py +82 -0
  166. package/scripts/nt_decrypt.py +1755 -1352
  167. package/scripts/pipeline.py +27 -2
  168. package/scripts/promote_ideas.py +19 -4
  169. package/scripts/quality_eval.py +707 -0
  170. package/scripts/rag_chat.py +4 -4
  171. package/scripts/read_image.py +187 -0
  172. package/scripts/reply_debt.py +488 -0
  173. package/scripts/route_cards.py +453 -0
  174. package/scripts/semantic_search.py +208 -49
  175. package/scripts/user_notes.py +315 -0
  176. package/scripts/vault_rag.py +13 -7
  177. package/scripts/vault_search.py +21 -4
  178. package/scripts/wiki_lint.py +397 -0
  179. package/src/core/appMsgFormat.ts +117 -0
  180. package/src/core/ntCore.ts +67 -0
  181. package/src/core/sqlcipherCore.ts +5 -61
  182. package/src/core/wcdbCore.ts +6 -2
  183. package/src/panel/client.ts +49 -0
  184. package/src/panel/endpoint.ts +144 -0
  185. package/src/panel/launch.ts +101 -0
  186. package/src/panel/server.ts +424 -0
  187. package/src/panel/userId.ts +72 -0
  188. package/src/services/assistantDaemon.ts +57 -9
  189. package/src/services/assistantEval.ts +611 -0
  190. package/src/services/assistantMemory.ts +413 -57
  191. package/src/services/assistantPrivacy.ts +18 -4
  192. package/src/services/assistantRouter.ts +191 -0
  193. package/src/services/assistantService.ts +671 -74
  194. package/src/services/assistantTools.ts +1118 -57
  195. package/src/services/assistantTrace.ts +175 -0
  196. package/src/services/chatService.ts +30 -0
  197. package/src/services/configService.ts +403 -385
  198. package/src/services/messageQuery.ts +97 -0
  199. package/src/services/pythonBridge.ts +139 -0
  200. package/src/services/syncService.ts +363 -0
  201. package/src/services/syncState.ts +275 -0
  202. package/src/services/wechatMessageService.ts +73 -7
  203. package/src/utils/atomicWrite.ts +56 -0
  204. package/src/utils/dateRange.ts +46 -0
  205. package/src/utils/text.ts +20 -0
@@ -1,96 +1,299 @@
1
- # Project State
2
-
3
- > Last reviewed: 2026-09-19. This is the current maintenance snapshot, not a release note. Keep it factual and update it with meaningful project changes.
4
-
5
- ## Purpose
6
-
7
- WeFlow CLI is a local-first command-line tool and MCP server for user-authorized WeChat data workflows: local chat queries and exports, official-account daily reading, favorites, knowledge workflows, and an optional local assistant.
8
-
9
- ## Current Baseline
10
-
11
- - Source package version: take it from `package.json` -> `version` rather than from this file - a hardcoded copy drifts silently, and `package-lock.json` must always match it. The npm registry may remain on an earlier version until a maintainer publishes a release.
12
- - Runtime: Node.js 22.13+; Python 3.10+ is required for database and daily-reading workflows. The floor is set by `src/core/sqlcipherCore.ts`, which imports `node:sqlite` at module scope; that builtin exists from 22.5.0 and stops needing `--experimental-sqlite` at 22.13.0.
13
- - Main development command: `npm run dev -- <command>`.
14
- - Compiled/package command: `node cli.cjs <command>`; CLI, MCP, database, export, and assistant services resolve resources from the same package root in both source and compiled layouts.
15
- - Build check: `npm run build`.
16
- - Regression check: `npm test`.
17
- - Downstream data contract: local JSON export is the first compatibility boundary for `she-love-me` and future consumers; see `docs/DATA_CONTRACT.md`.
18
- - Supported focus: Windows WeChat 4.x; Linux WeChat 4.x has an NT database path; macOS requires user-provided local access credentials for initialization.
19
-
20
- ## Verified Capabilities
21
-
22
- | Area | Current state | Main entry points |
23
- | --- | --- | --- |
24
- | 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` |
25
- | Initialization | Verify and reuse existing local database access by default; refresh only when needed. Missing-key tests can run without changing saved configuration. | `init`, `init --refresh`, `init --test-missing-keys`, `config forget-keys`, `check` |
26
- | Official-account daily | Filter configured sources, preserve source categories, backfill incomplete yesterday output before an unqualified today run, fetch articles, create summaries, generate a local HTML reader, and synchronize reader favorites into local files. | `daily`, `daily favorites`, `daily-stats`, `daily-server` |
27
- | Knowledge workflows | Wiki compilation, semantic search, RAG, WeRead sync, reviews, reading notes, and staged Vault promotion. | `wiki`, `vault`, `search`, `chat`, `weread`, `review` |
28
- | MCP | Local stdio MCP server exposes bounded read and local transformation tools, including the `wechat.export_messages` versioned message contract. Publishing, messaging, memory writes, todo mutation, configuration changes, and deletion are excluded. | `mcp-config`, `mcp-server/index.ts` |
29
- | Assistant | Optional WeChat Bot-channel assistant with local memory and privacy gates. | `login-wechat`, `assistant` |
30
-
31
- The 67-test TypeScript regression suite covers home-path expansion, custom NT data-root discovery, bounded daily-favorites synchronization, confirmed no-AI daily generation, no-AI Vault promotion, Vault content mutation previews, Vault initialization and synchronization confirmation, MCP configuration write confirmation, source/compiled Python resource lookup, outbound PII redaction, local-inference bypass, strict message-body masking, MCP path/date/URL validation and live tool discovery, message-contract preservation, local-date export bounds, export result metadata, evidence-package safety, assistant routing and lifecycle confirmation, message-channel authentication confirmation, todo and access-control mutation confirmation, message-send preview/confirmation, local-reader port validation, redacted configuration status, access-list JSON, database-key reset confirmation, human-gated initialization and key capture, content-free account-scan and assistant-log diagnostics, confirmed Vault RAG, confirmed semantic search and RAG chat, structured todo reminders, strict knowledge limits, report previews, and cover-image signature validation. Core automation entry points now include `capabilities --json`, `config show --json`, `whitelist list --json`, `blacklist list --json`, `check --json`, `init --dry-run --json`, `scan --json`, `dbkey --dry-run --json`, `sessions --json`, `messages --json`, `contacts --json`, `export ... --json`, `fav export ... --json-result`, `sns ... --json`, `daily-stats --json`, `daily --no-ai --dry-run --json`, `daily-server --status --json`, `assistant status --json`, `assistant log --json`, `todos list --json`, `todos remind --json`, `evidence --json`, `evidence-review --dry-run --json`, Vault mutation previews, confirmed `vault rag`, confirmed `search` and `chat`, daily-favorite previews, `search-index --dry-run --json`, `pipeline run --dry-run --json`, report-generator previews, login/logout previews, and `mcp-config --output <file> --dry-run --json-result`. CI runs both the build and this suite on Node 20 with Python 3.10 available for path-discovery coverage.
32
-
33
- The daily workflow supports `dailyAiEnabled=false` for a persistent no-AI mode, or `daily --no-ai` for a single run. Both the CLI and direct Python entry points honor the setting. Fetching, HTML generation, and local indexes remain available in that mode. Machine execution requires `daily --yes --json`; it keeps progress logs on stderr and returns a machine-readable completion result on stdout after checking the required local artifacts.
34
-
35
- 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; a stable incremental cursor is not yet exposed.
36
-
37
- ## Security Baseline
38
-
39
- - Process only data the user owns or is explicitly authorized to access.
40
- - Sensitive configuration fields are encrypted at rest with machine- and user-bound AES-256-GCM.
41
- - The daily reader binds to `127.0.0.1`; its API rejects cross-origin mutations, validates local paths, and restricts image proxy requests.
42
- - The assistant denies all senders until `assistantWhitelist` is explicitly configured. Group routing is experimental and remains denied unless the upstream explicitly supplies group metadata, the group and sender are both allowlisted, and the bot is mentioned. New or incomplete configurations use `strict` privacy mode for cloud inference.
43
- - MCP path inputs are constrained to their expected data roots. MCP clients remain trusted local integrations and must be reviewed before configuration.
44
- - The default MCP tool list is read-only: it excludes publishing, messaging, assistant-memory writes, todo mutation, configuration changes, and deletion. Conversation display names must resolve uniquely before chat data is read.
45
- - Public reports and commits must not contain databases, keys, tokens, wxid values, real chat content, or unredacted logs.
46
- - Todo status changes and deletion use a preview/confirm protocol. Machine callers receive `CONFIRMATION_REQUIRED` unless the user-approved execution includes `--yes`.
47
- - AI and database credentials, selected-conversation identifiers, private queries and filters, export locations and display names, and NT scan roots are inherited by workers through environment variables and are not copied into child-process command arguments. Long-lived worker environments clear unrelated internal values before startup.
48
- - `config set-env` and `fav set-key --from-env` provide command-history-safe secret input. Their JSON workflows require a read-only preview followed by explicit `--yes`, and never return secret values or discovered database paths. Bulk clearing of configuration, access lists, or audit history requires interactive confirmation or explicit `--yes`.
49
- - Core JSON reads validate pagination before database access. `messages --start/--end` now applies its Unix-second time range before offset and limit instead of silently ignoring the options.
50
- - `scan --json` and `assistant log --json` return metadata only. They intentionally omit local paths, account identifiers, nicknames, and log content. `evidence-review` requires preview and confirmation, and its machine result omits both analysis text and the output path.
51
- - `init --dry-run --json` exposes a content-free plan. JSON execution returns `INTERACTIVE_REQUIRED`; database verification, directory discovery, key capture, and configuration writes remain an explicit terminal workflow.
52
- - `vault rag` requires a read-only preview and explicit confirmation before reading local knowledge or calling the configured AI provider. The question is inherited by the Python worker through the environment rather than copied into process arguments.
53
- - `search` and `chat` use the same preview/confirmation boundary. Queries, questions, and optional conversation restrictions are inherited through the worker environment instead of appearing in process arguments; machine callers cannot start interactive RAG chat.
54
- - `daily-server --status --json` remains read-only. Machine startup uses `daily-server --dry-run --json` followed by `daily-server --yes --json`; the confirmed process is detached and remains bound to loopback. The legacy `fav-server` compatibility entry enforces the same preview and confirmation rules.
55
-
56
- Documentation was synchronized with the current source baseline on 2026-09-19. Command behavior is defined by `bin/weflow-cli.ts`; detached reader startup waits for service readiness, and release packages can lag behind the GitHub source until published.
57
-
58
- ## Active Constraints
59
-
60
- - Issue #7 remains open pending reporter verification. HTML emoji export preserves the emoji label and matches resource records only by nonzero server-message identity; local IDs are not unique across shards or conversations. WeChat 4.x V2 image containers are decoded with an account-specific media key derived from local `kvcomm` data and verified against a real cached V2 header. Remote emoticons use AES-CBC with the message key as both key and IV. Entity-escaped emoji XML is normalized before extracting MD5 and media URLs; failed encrypted downloads fall back across available thumbnail/CDN fields. Signature-only default `[打脸]` messages use the bundled official Facepalm asset when no message-specific resource is available. Forwarded app cards remain structured links with available covers, and Bilibili share pages can resolve covers through their BV metadata endpoint when no `og:image` is present. Thirteen synthetic Python regression tests cover these paths; run `python -m unittest discover -s test -p '*_test.py' -v`. CI also runs these tests. This does not establish compatibility with every real WeChat media format.
61
-
62
- - WeChat platform behavior, database formats, account restrictions, and terms can change without notice. Local operation is not a legal, account-safety, or platform-compatibility guarantee.
63
- - The daily workflow can take substantial time when many configured sources publish on the same day. It fetches article bodies sequentially to reduce upstream pressure.
64
- - 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.
65
- - 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.
66
- - Cloud AI workflows may transmit user-selected, privacy-filtered input to the configured provider. Local inference avoids that network transfer.
67
- - `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.
68
- - `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.
69
- - Automatic data-directory discovery checks common user locations only. Cross-drive name search requires `init --search-drives`; structural disk search requires `init --full-scan`. Both can be slow on large, removable, or network-attached volumes.
70
- - Dependency audit findings must be reviewed before dependency upgrades; do not run breaking `npm audit fix --force` without validation.
71
- - The WCDB native query entry point has no parameter-binding ABI. Calls that supply parameters are rejected rather than falling back to interpolated SQL; existing internal callers are unchanged.
72
-
73
- ## Current Priorities
74
-
75
- 1. Keep initialization and database access compatible with supported WeChat 4.x variants, with reproducible issue diagnostics that contain no secrets.
76
- 2. Keep the daily reader reliable for configured sources and categories without widening access to local data.
77
- 3. Maintain security boundaries around local servers, assistant access, MCP tools, and cloud AI disclosure.
78
- 4. Evaluate external collaboration through read-only, synthetic-data, versioned interfaces before connecting real user data.
79
- 5. Make Bot-channel send capability and its limitations unambiguous in CLI naming and help text before extending messaging features.
80
-
81
- ## External Collaboration Position
82
-
83
- For projects such as Yance, WeFlow CLI remains an independent local data-access layer. Early collaboration may cover read-only schemas, anonymized fixtures, and controlled MCP adapters. It must not require sharing real databases, credentials, keys, full local directories, or direct database access by an external product.
84
-
85
- ## Related Documents
86
-
87
- - Architecture and data flow: [ARCHITECTURE.md](../ARCHITECTURE.md)
88
- - Setup and troubleshooting: [OPERATIONS.md](../OPERATIONS.md)
89
- - Security and reporting: [SECURITY.md](../SECURITY.md)
90
- - MCP surface: [MCP.md](MCP.md)
91
- - Design rationale: [DECISIONS.md](DECISIONS.md)
92
- - Partner integration record: [PARTNERS.md](PARTNERS.md)
93
- - AI integration surface: [AI_INTERFACE.md](AI_INTERFACE.md)
94
- - Overall improvement and expansion plan: [ROADMAP.md](ROADMAP.md)
95
- - DeepSeek V4 Flash施工文档: [DEEPSEEK_V4_FLASH施工文档.md](DEEPSEEK_V4_FLASH施工文档.md)
96
- - User-visible releases: [CHANGELOG.md](../CHANGELOG.md)
1
+ # Project State
2
+
3
+ > Last reviewed: 2026-09-28 (the Vault's copy of an article is cleaned as it is copied in - the eight WeChat interface phrases go, the frontmatter and any fenced code block stay, and the fetched original in `output/biz-daily` stays byte-for-byte (D-055); the rule that decides which days that copy covers had been keyed off a `backfilled` marker that only one of the two writers sets, so **12 days were never copied and never reported as skipped** - it now asks whether the day holds an article, 187 → 199 days; `wiki lint`'s similar-title report carries a caveat that its own threshold stopped discriminating at 22,374 pages (424,697 groups) and that the rule cannot catch the case it was written for; the knowledge base now holds **35,463** article copies, **34,092** reading notes and **22,374** concept pages across both lines, most of the article ones built with no model call; previously, 2026-09-27 (the two knowledge bases - articles and conversations - were split into one Vault with two directories, `Wiki/Concepts` and `Chat/Concepts`, which meant teaching five readers that a second directory exists, since reading one of two halves the results silently rather than failing; `wiki lint` went from 7,872 broken links to 0; previously, 2026-09-26: the knowledge base has a health check and a gate on what deserves a page - 120 article notes produced 387 concepts of which 37 were durable, and the 108 resulting pages (86 from articles, 6 from conversations, 6 from WeChat favourites, 5 from the user's own notes - a fourth source added the same day, which reads hand-written notes read-only and marks its summaries as model readings) passed `wiki lint` with 0 breaks, and the generated vault now explains itself (generated-vs-hand-edited, `verified: false`, `知识/概念` and `来源/文章|聊天|收藏` nested tags, and the graph-filter string to use - conventions borrowed from a local Obsidian vault built for exam prep); a 1200-token cap was truncating the chat line's JSON on long conversations and is now 2000; the WeChat channel reports an expired token instead of retrying forever; conversations now feed the knowledge base as cards the existing wiki pipeline aggregates; drafted replies: text only, never sent, and never judged against the user's own last message; the assistant may not invent a reason for a tool failure; every extension point in docs/EXTENDING.md now names the test that catches a missed step; extension is in-repo or over MCP, by decision). This is the current maintenance snapshot, not a release note. Keep it factual and update it with meaningful project changes.
4
+
5
+ ## Purpose
6
+
7
+ WeFlow CLI is a local-first command-line tool and MCP server for user-authorized WeChat data workflows: local chat queries and exports, official-account daily reading, favorites, knowledge workflows, and an optional local assistant.
8
+
9
+ ## Current Baseline
10
+
11
+ - Source package version: take it from `package.json` -> `version` rather than from this file - a hardcoded copy drifts silently, and `package-lock.json` must always match it. The npm registry may remain on an earlier version until a maintainer publishes a release.
12
+ - Runtime: Node.js 22.13+; Python 3.10+ is required for database and daily-reading workflows. The floor is set by `src/core/sqlcipherCore.ts`, which imports `node:sqlite` at module scope; that builtin exists from 22.5.0 and stops needing `--experimental-sqlite` at 22.13.0.
13
+ - Main development command: `npm run dev -- <command>`.
14
+ - Compiled/package command: `node cli.cjs <command>`; CLI, MCP, database, export, and assistant services resolve resources from the same package root in both source and compiled layouts.
15
+ - Build check: `npm run build`.
16
+ - Regression check: `npm test`.
17
+ - Downstream data contract: local JSON export is the first compatibility boundary for `she-love-me` and future consumers; see `docs/DATA_CONTRACT.md`.
18
+ - Supported focus: Windows WeChat 4.x; Linux WeChat 4.x has an NT database path; macOS requires user-provided local access credentials for initialization.
19
+
20
+ ## Verified Capabilities
21
+
22
+ | Area | Current state | Main entry points |
23
+ | --- | --- | --- |
24
+ | 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` |
25
+ | Initialization | Verify and reuse existing local database access by default; refresh only when needed. Missing-key tests can run without changing saved configuration. | `init`, `init --refresh`, `init --test-missing-keys`, `config forget-keys`, `check` |
26
+ | 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`. | `daily`, `daily favorites`, `daily-stats`, `daily-server` |
27
+ | Knowledge workflows | Wiki compilation (`compile_wiki` aggregates `[[concept]]` links into Vault pages; **the article corpus produced none as of 2026-09-26** (the 1,633 article notes then in the Vault carried only their own topic link and `## 关联网络` path links, measured over the whole corpus), so an article-side concept-extraction step was missing; `article-notes` (added the same day) is that step, and it works: 30 article notes produced 110 concepts and 10 concept pages on this machine, which are now the first knowledge pages the assistant can search (`vault search`, `search_knowledge`). The corpus has grown since - 7,119 concept cards, and 22,374 concept pages across both lines as of 2026-09-28. The degeneracy reported in that trial was an artefact of a 120-card sample: with all 710 September articles carded, the topic field splits into 学术/新闻/政治 (62/33/25 of 108 pages), so the earlier "the classifier puts everything in one bucket" judgement is withdrawn. Two upstream noise sources showed up in that trial. The first - WeChat reader UI text inside article summaries - is **fixed**, and the backfill this line used to defer has since been run: measured over the corpus it was 1,484 of 1,633 notes (the corpus has grown to 34,092 since), the cleaner runs on both write paths, and `create_reading_notes --clean-summaries` took the existing notes to **0**; the Vault's `Sources/WeChat` copies were cleaned on 2026-09-28 as well (D-055), so the residue now survives only in `output/biz-daily`, which is the archive. The second is **measured but not fixed**: of 224 notes whose titles read as news, 40 are labelled `学术` - that classifier belongs to the daily pipeline and changing its criteria without labels would be a guess. Fed by both sources: `output/biz-daily` articles and, since 2026-09-26, `output/chat-notes` conversation cards produced by the confirmed `chat-notes` command; **the graph is also kept free of duplicate nodes by `wiki compile --merge-duplicates`** (74 groups merged, 80 pages removed, and 98 links that pointed at the removed names still resolve through `aliases` - see D-053); **most of the article line's pages were never written by a model** - `wiki compile --pages-from-cards` assembles a page from material the cards already carry (the definition is the card's own `desc`, the related concepts are the other concepts from the same article, the source is that article) and marks it `summary_by: card`. That label is deliberate and is not the same as "no model was involved": the `desc` line was written by `article_notes`, so the quality is that line's quality, and a few read as meta-descriptions of where the term appeared rather than as definitions. 17,383 pages at ¥0; the model-based route was priced at ¥31 and rejected, because every one of those concepts is mentioned by exactly one article (D-054); **the pages accumulate only if `wiki compile --refresh-sources` is run** - `wiki compile` itself only builds pages for concepts that do not have one, so an existing page never gains an edge (measured: one run added 3,164 edges across 324 article-line pages; it rewrites the body only, never the frontmatter, and never deletes a source line no current card accounts for - see D-052), semantic search (two-stage: similarity, then a decision-model rerank over the top 20 candidates; `--no-rerank` reverts) - the index covers 90 days of chat and 30 date-directories of articles **by default**, and `search-index --days / --article-days` changes that (the numbers were hardcoded at the call sites until 2026-09-25, and neither the command nor its preview said so), RAG, WeRead sync, reviews, reading notes, and staged Vault promotion. | `wiki`, `vault`, `search`, `chat`, `weread`, `review` |
28
+ | 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` |
29
+ | 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>` |
30
+ | 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` |
31
+ | Report uncertainty | The daily report ends with a `我拿不准的` section: entries whose inclusion probability sits on the threshold (admitted and excluded listed separately) and entries whose topic confidence is low. Computed locally from frontmatter, no extra model call. States outright when the articles predate the probability fields, so an absent section is not read as confidence. | `python scripts/generate_ai_report.py --date <date>` |
32
+ | Media coverage | HTML export writes `<prefix>_media.json` with a status and reason per media item, and reports the remote-fetch budget it used. A media miss is recorded rather than shown as a bare placeholder. | `export <talker> html` |
33
+ | MCP | Local stdio MCP server exposes bounded read and local transformation tools, including the `wechat.export_messages` versioned message contract. Publishing, messaging, memory writes, todo mutation, configuration changes, and deletion are excluded, The surface is **derived from the assistant tool table minus `save_memory` and `look_at_image`**, so it is not read-only and `capabilities --json` says so (`safety.mcpDefaultReadOnly: false` plus a `safety.mcpSurface` breakdown). `export_chat` writes files; `who_owes_reply`, `search_chats`, `search_semantic` and `draft_reply` send user data to cloud models (the first three to the decision model, `search_semantic` also to a cloud embedding service). `look_at_image` is excluded because that transport returns text only - the image would go nowhere and the reply still claimed the model could see it. The four tools that send user data to cloud models (`who_owes_reply`, `search_chats`, `search_semantic`, `draft_reply`) each need an explicit confirmation from a machine caller: without `confirm: true` they return a preview only (what would go out, and to whom) and nothing leaves the machine (D-047). | `mcp-config`, `mcp-server/index.ts` |
34
+ | Judgement primitive | `decide` turns `{state, questions}` into typed answers with probabilities plus the call's cost, in one request (~1s regardless of question count). `--request` reads no local data; `--over <glob> --ask <text>` batch mode expands one glob times N questions into a single request and reads the matched files, echoing back how many characters of each it carried. Registered in `capabilities --json` as `primitives.decide`, deliberately **not** exposed over MCP. | `decide --request <file> --dry-run`, `decide --request <file> --yes` |
35
+ | Reply debt | `awaiting` asks one decision request per conversation whether a reply is owed, and prints a ranked list with probabilities, debt age, conversation kind, and the strength of the evidence behind each verdict. `--html` writes a self-contained page that carries those same fields and **no chat text**, so it is shareable by construction. Reads chat text and sends it to the decision model, so it follows the `search` discipline: `--dry-run` previews (no egress), `--yes` runs. Registered in `capabilities --json`. | `awaiting --dry-run --json`, `awaiting --yes` |
36
+ | Drafted replies | `draft <会话>` asks a decision model seven typed questions about one conversation (intent, what the other side needs, best action type, whether substance is owed, risk 0-9, money, unfulfilled commitment), **refuses** when the answer is money or risk >= 7, and otherwise generates candidate replies and ranks them with a ninth question. The judgement is **injected into the drafting prompt** and the user's own past messages are offered as a tone sample - both are things the reference implementation (`jev-chat-jarvis`) does not do, read from its source. It produces **text only**: no input-box filling, no key simulation, no focus stealing, and `capabilities --json` reports `sendsNothing: true` (D-047). The risk **threshold** is picked rather than calibrated, so the 0-9 band explains the refusal and does not decide alone; `commitment` constrains the prompt instead of refusing (`--gate-commitment` makes it hard). **The judgement step is degradable** (Jev's free period ended 2026-09-25): when that call fails the tool drafts on DeepSeek alone and returns `judged: false` with the reason, because the gate exists only as long as a judgement does - the CLI and the assistant tool both print "no judgement this time, the gate did not run" ahead of the candidates, and `deepseekApiKey` is now the only required key. The criteria are English while `reply_debt`'s money/commitment questions stay Chinese - two wording conventions against one model with **no A/B run**, recorded as an open question. Verified on real data: one live run produced three candidates, and a mutating check confirmed the tests catch a refusal that starts with `(` and a transcript that is not time-ordered. Reachable as the assistant tool `draft_reply`, which masks every message through `privacyGate` before handing the transcript to the script over stdin and **refuses outright in strict mode**. | `draft <会话> --dry-run --json`, `draft <会话> --yes` |
37
+ | Local conversation search | `scripts/route_cards.py` answers "which conversation was I talking about X in" without a model seeing the conversations. Jev picks the real query words out of the candidate n-grams the question produces (measured: keeps 会议 at 0.77, rejects 7 fragments at 0.11-0.40); **ranking is local**, by how many messages in each session literally contain those words. Retrieval reads WeChat's own `message_fts.db`, whose `acontent` is plaintext and whose integer `session_id` **is the rowid of its `name2id` table** - `LIKE` over 60k rows is instant, while `MATCH` is unavailable (`no such tokenizer: MMFtsTokenizer`). Only the candidate words and the question leave the machine; `--keyword` asks nothing at all. See D-036. | `python scripts/route_cards.py build`, `ask "…" --dry-run`, `ask "…" --yes` |
38
+ | Assistant trace | Every turn writes one record to `~/.weflow-cli/assistant_trace.jsonl`: the fast-route's decision, each tool call with a **redacted argument summary**, round trips, why it stopped, and any reasoning the model returned. Read it with `assistant trace` on the machine, or by sending `轨迹` in WeChat (last turn only, and **no `userId` in that rendering**). The audit is unchanged and stays narrow: it records events and byte counts and never content, because it is the egress record; the trace is a local debugging artifact, which is why argument summaries live here. Reasoning comes from the provider's `reasoning_content` and is empty with the default `deepseek-chat` - the trace says so rather than implying it thought. "Did this step produce anything" is a **convention** (`(`-prefixed results), not a field; its one exception is a whitelist guarded by a test, and the ~40-site alternative was priced and declined (D-043). | `assistant trace`, `轨迹` in chat |
39
+ | Assistant behaviour eval | `npm run eval:assistant` runs nineteen synthetic cases against the **real** model (one of them multi-turn) 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` |
40
+ | Assistant | Optional WeChat Bot-channel assistant: 19 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. 19 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, 18 of the 19 tool branches (only `get_daily_report` is never executed - it reads a module-level path constant with no injection point, so which branch it takes depends on the machine; see the coverage note in `test/assistant-tools-branches.test.ts`), 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` |
41
+ | 本机面板 (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). 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 now painted only in the three non-idle states** (`busy` / `offline` / `quota`); while idle the ball is just the mascot, with nothing behind it. It had been on permanently - which was the original request (an animated background) - but that made the mascot PNG's own transparency invisible and, because the same layer is the status light, made offline / busy / quota-out look identical. The `@keyframes drift` and `@property --hue` machinery stays (it drives the busy state); what changed is when the layer is drawn. 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). | `panel`, `panel --status`, `panel --ask` |
42
+
43
+ The 642-test TypeScript regression suite (counted from `npm test` on this revision; it grows with every capability) covers home-path expansion, custom NT data-root discovery, bounded daily-favorites synchronization, confirmed no-AI daily generation, no-AI Vault promotion, Vault content mutation previews, Vault initialization and synchronization confirmation, MCP configuration write confirmation, source/compiled Python resource lookup, outbound PII redaction, local-inference bypass, strict message-body masking, MCP path/date/URL validation and live tool discovery, message-contract preservation, local-date export bounds, export result metadata, evidence-package safety, assistant routing and lifecycle confirmation, message-channel authentication confirmation, todo and access-control mutation confirmation, message-send preview/confirmation, local-reader port validation, redacted configuration status, access-list JSON, database-key reset confirmation, human-gated initialization and key capture, content-free account-scan and assistant-log diagnostics, confirmed Vault RAG, confirmed semantic search and RAG chat, drafted-reply confirmation and its refusal path, structured todo reminders, strict knowledge limits, report previews, and cover-image signature validation. The panel's copy button is executed rather than read: `test/panel-copy-button.test.ts` boots `renderer.js` in a jsdom page, goes through the real `submit -> ask() -> fetch -> addTurn` path, clicks the button, and asserts both the success label and that a **failed** clipboard write says so instead of claiming success - and says in its own header that jsdom cannot reproduce Chromium's drag-region behaviour, so it proves the handler and not the OS-level click. Core automation entry points now include `capabilities --json`, `config show --json`, `whitelist list --json`, `blacklist list --json`, `check --json`, `init --dry-run --json`, `scan --json`, `dbkey --dry-run --json`, `sessions --json`, `messages --json`, `contacts --json`, `export ... --json`, `fav export ... --json-result`, `sns ... --json`, `daily-stats --json`, `daily --no-ai --dry-run --json`, `daily-server --status --json`, `assistant status --json`, `assistant log --json`, `todos list --json`, `todos remind --json`, `evidence --json`, `sync run --dry-run --json`, `sync run --yes --json`, `sync status --json`, `sync verify --json`, `evidence-review --dry-run --json`, Vault mutation previews, confirmed `vault rag`, confirmed `search` and `chat`, daily-favorite previews, `search-index --dry-run --json`, `pipeline run --dry-run --json`, report-generator previews, login/logout previews, and `mcp-config --output <file> --dry-run --json-result`. CI runs three jobs: `node` (build plus the TypeScript suite on Node 22.13), `python` (the Python suites on 3.11), and `release-consistency` (package.json and package-lock.json version agreement, plus a check that the npm package carries no outputs, models, databases or keys). The jobs are separate because a Node failure previously stopped the Python tests from running at all, which let them fail unnoticed.
44
+
45
+ The daily workflow supports `dailyAiEnabled=false` for a persistent no-AI mode, or `daily --no-ai` for a single run. Both the CLI and direct Python entry points honor the setting. Fetching, HTML generation, and local indexes remain available in that mode. Machine execution requires `daily --yes --json`; it keeps progress logs on stderr and returns a machine-readable completion result on stdout after checking the required local artifacts.
46
+
47
+ 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.
48
+
49
+ ## Security Baseline
50
+
51
+ - Process only data the user owns or is explicitly authorized to access.
52
+ - Sensitive configuration fields are encrypted at rest with machine- and user-bound AES-256-GCM.
53
+ - The daily reader binds to `127.0.0.1`; its API rejects cross-origin mutations, validates local paths, and restricts image proxy requests.
54
+ - The assistant denies all senders until `assistantWhitelist` is explicitly configured. Group routing is experimental and remains denied unless the upstream explicitly supplies group metadata, the group and sender are both allowlisted, and the bot is mentioned. New or incomplete configurations use `strict` privacy mode for cloud inference.
55
+ - MCP path inputs are constrained to their expected data roots. MCP clients remain trusted local integrations and must be reviewed before configuration.
56
+ - The default MCP tool list is read-only: it excludes publishing, messaging, assistant-memory writes, todo mutation, configuration changes, and deletion. Conversation display names must resolve uniquely before chat data is read.
57
+ - Public reports and commits must not contain databases, keys, tokens, wxid values, real chat content, or unredacted logs.
58
+ - Todo status changes and deletion use a preview/confirm protocol. Machine callers receive `CONFIRMATION_REQUIRED` unless the user-approved execution includes `--yes`.
59
+ - AI and database credentials, selected-conversation identifiers, private queries and filters, export locations and display names, and NT scan roots are inherited by workers through environment variables and are not copied into child-process command arguments. Long-lived worker environments clear unrelated internal values before startup.
60
+ - `config set-env` and `fav set-key --from-env` provide command-history-safe secret input. Their JSON workflows require a read-only preview followed by explicit `--yes`, and never return secret values or discovered database paths. Bulk clearing of configuration, access lists, or audit history requires interactive confirmation or explicit `--yes`.
61
+ - Core JSON reads validate pagination before database access. `messages --start/--end` now applies its Unix-second time range before offset and limit instead of silently ignoring the options.
62
+ - `scan --json` and `assistant log --json` return metadata only. They intentionally omit local paths, account identifiers, nicknames, and log content. `evidence-review` requires preview and confirmation, and its machine result omits both analysis text and the output path.
63
+ - `init --dry-run --json` exposes a content-free plan. JSON execution returns `INTERACTIVE_REQUIRED`; database verification, directory discovery, key capture, and configuration writes remain an explicit terminal workflow.
64
+ - `vault rag` requires a read-only preview and explicit confirmation before reading local knowledge or calling the configured AI provider. The question is inherited by the Python worker through the environment rather than copied into process arguments.
65
+ - `search` and `chat` use the same preview/confirmation boundary. Queries, questions, and optional conversation restrictions are inherited through the worker environment instead of appearing in process arguments; machine callers cannot start interactive RAG chat.
66
+ - `daily-server --status --json` remains read-only. Machine startup uses `daily-server --dry-run --json` followed by `daily-server --yes --json`; the confirmed process is detached and remains bound to loopback. The legacy `fav-server` compatibility entry enforces the same preview and confirmation rules.
67
+
68
+ Documentation was synchronized with the current source baseline on 2026-09-19. Command behavior is defined by `bin/weflow-cli.ts`; detached reader startup waits for service readiness, and release packages can lag behind the GitHub source until published.
69
+
70
+ ## Active Constraints
71
+
72
+ - Issue #7 remains open pending reporter verification. HTML emoji export preserves the emoji label and matches resource records only by nonzero server-message identity; local IDs are not unique across shards or conversations. WeChat 4.x V2 image containers are decoded with an account-specific media key derived from local `kvcomm` data and verified against a real cached V2 header. Remote emoticons use AES-CBC with the message key as both key and IV. Entity-escaped emoji XML is normalized before extracting MD5 and media URLs; failed encrypted downloads fall back across available thumbnail/CDN fields. Signature-only default `[打脸]` messages use the bundled official Facepalm asset when no message-specific resource is available. Forwarded app cards remain structured links with available covers, and Bilibili share pages can resolve covers through their BV metadata endpoint when no `og:image` is present. Forty-two synthetic Python regression tests (`export_chat_media_test.py`, `export_chat_media_report_test.py`) cover these paths; run `python -m unittest discover -s test -p '*_test.py' -v`. CI also runs these tests. This does not establish compatibility with every real WeChat media format.
73
+
74
+ - WeChat platform behavior, database formats, account restrictions, and terms can change without notice. Local operation is not a legal, account-safety, or platform-compatibility guarantee.
75
+ - **The daily is no longer all-or-nothing.** It fetches every article before writing anything and the bodies lived only in memory, so any interruption threw the whole day away - a 400-article day is over an hour of fetching, and the 2026-09-22 run stopped at 105/403 with nothing written. Bodies are now cached under `output/.cache/fetch/` keyed by URL, so a re-run fetches only the missing ones (measured: a resumed batch of 3 goes from 64 s to 2.0 s, and cache hits take **no** throttle sleep because they send no request - the throttle exists to spare the upstream, and this is the same direction). Only successes are cached, so a failure is retried next time rather than remembered. `--no-fetch-cache` forces a refetch. **The lesson that made this urgent**: a long single-shot run is a gamble, and `cmd | tee log` reports the wrapper's exit code, not the program's - the 105/403 failure looked like `exit 0`.
76
+ - Where a many-source day spends its time is now measured rather than assumed, and two of the three biggest items have been fixed. A 190-article day **was** ~3 h 10 m: fetching bodies ~130 min, the deliberate 8-12 s inter-article throttle ~32 min, image downloads ~18 min (47 image links per three articles, 0.37 s and 135 KB each, sequential), and the serial summary loop ~9 min (measured 2.27/2.92/2.45 s per article, plus 0.3 s each). It is now ~1 h 5 m: fetching ~33 min (gzip, below), throttle ~32 min, images ~3 min (6-way concurrent), summaries ~2 min (6-way concurrent). Classification was never a factor - already concurrent. **The throttle is now the largest single item, and it is the one thing left that is deliberate** ("reduce upstream pressure"); the other two costs were implementation choices, not policy. Both new concurrency settings are 6 (`IMAGE_WORKERS`, `SUMMARY_WORKERS`), matching `JEV_WORKERS`.
77
+ - **Article bodies are now fetched with `Accept-Encoding: gzip, deflate`**, which `urllib` does not send by default. Measured on the same four articles: 3.3-3.5 MB and 33-40 s before, 0.7-0.8 MB and 6-10 s after, with the decompressed page identical in size and `js_content` / `rich_media_content` still present - so ~130 min of fetching becomes ~33 min on a 190-article day, and **fewer bytes cross the wire than before**, which is the same direction as the throttle. `_decode_body` recognises only the two encodings the request asks for, and passes anything else through unchanged: raising there would turn a transfer optimisation into a fetch failure. The relative cost of the remaining parts is unchanged, so the throttle is now the largest single line item.
78
+ - 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).
79
+ - 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.
80
+ - 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.
81
+ - Cloud AI workflows may transmit user-selected, privacy-filtered input to the configured provider. Local inference avoids that network transfer.
82
+ - `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.
83
+ - `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.
84
+ - Automatic data-directory discovery checks common user locations only. Cross-drive name search requires `init --search-drives`; structural disk search requires `init --full-scan`. Both can be slow on large, removable, or network-attached volumes.
85
+ - Dependency audit findings must be reviewed before dependency upgrades; do not run breaking `npm audit fix --force` without validation.
86
+ - The WCDB native query entry point has no parameter-binding ABI. Calls that supply parameters are rejected rather than falling back to interpolated SQL; existing internal callers are unchanged.
87
+ - The daily report classifies article topic and relevance by calling a decision model (`api.typesafe.ai`) when `typesafeApiKey` is set, so article titles and bodies leave the machine. Article text already went to the configured LLM for summarisation, so this is an additional vendor rather than an additional category of data; `daily --no-ai` is the way to keep everything local. The switch was made on the strength of a 60-article comparison against the stored labels, **not against a gold standard** - the stored labels are the previous model's own output, and a hand-labelled sample is still owed. `--classifier llm` reverts in one flag.
88
+ - The decision-model calls now record **which model actually served them**, and validate the `choice` contract on the way in. Two facts were verified against the live API rather than taken from a source reading: `instructions` may be a **structured object** (`{"goal": …, "rules": […]}`) and not only a string, and every response carries a `model` field naming the **served version** (`jev-1.13.0`) as distinct from the requested alias (`jev-latest`). That name is now in `usage['model']` and on `JevClient.last_model`, and the daily stores it as a top-level `decisionModel` in `.articles.json` - the point is to make "production is no longer running what was measured" visible, because an alias that drifts produces results that look fine. Every `choice` answer is now checked before it is used: probabilities present, key set equal to the criteria, values in `[0,1]`, sum ≈ 1, and `choice` equal to the argmax. The argmax check is the one that matters - a non-argmax choice is not a wrong-looking answer but a completely normal-looking wrong one, the same failure shape as reading `noul` as a boolean. Because a check that rejects a *legitimate* response would make the daily fall back to the old LLM path silently, it was exercised rather than asserted: 14 real articles sampled across days and sources all passed, all served by `jev-1.13.0`, with relevance spanning 0.38-1.41 and inclusion 0.15-0.81. That is a spot check on one machine, not a guarantee about the model's future output; a rejection at run time is reported per article and degrades to the previous path.
89
+ - **The LLM is no longer asked to classify when the decision model already has.** The summary prompt carried `【主题】`/`【相关度】` plus the six-category criteria and three-level definitions whoever was judging, so on the Jev path those were generated and discarded: 446 characters of prompt per article (57% of the prompt skeleton, ~9% of total input since the body dominates) and 10-15 output tokens. `SUMMARY_ONLY_PROMPT` drops exactly those sections and a test asserts the **summary/tag/concept requirements are byte-identical** between the two prompts, which is what keeps the change from altering the output rather than just its cost. The switch is **per article**: `--classifier llm` and any article whose judgement failed still get the full prompt, because those need the LLM's own topic and relevance - so the fallback path stays byte-identical per D-031. Savings are estimated from prompt size and output fields; neither client exposes billed usage.
90
+ - **Judging no longer requires generating.** `daily --no-summary` keeps the decision model for topic/relevance/inclusion and skips every LLM call inside the daily step, so a machine with no DeepSeek key can still classify. Two things make it honest rather than convenient: the LLM-key checks had to be relaxed in **both** callers (`biz_daily` and `pipeline`), because they encode "Phase 2 needs an LLM key" - a claim that stops being true once nothing is generated; and the artifacts must not imply a summary that does not exist, so the md omits the `## AI 摘要` section entirely and `.articles.json` records `summary: ""` (an empty heading reads as a failed generation, the platform digest reads as our summary - both would be claims about something absent). Tags fall back to `[topic]`, the convention that already covers "no tags were extracted". The scope is deliberately the daily step only: the pipeline's later steps (action suggestions, wiki compile, AI report) still call an LLM and say so at runtime; a fully LLM-free run combines it with `--skip-classify --skip-wiki --skip-ai-report`. Verified with a deliberately invalid DeepSeek key: the run completes with no 401, so nothing reached the LLM.
91
+ - **The decision model's free period ends 2026-09-25**, after which input tokens are billed (output has been free during the promotion). Anything expensive to re-measure should be measured before then, or the cost of a re-run should be assumed. The client only ever sees token counts, not a billed amount, so cost stays an estimate from tokens times price - the same position the reference implementation (`browser-use` × TypeSafe) documents. Verified against the live API on 2026-09-23: `usage` is exactly `{input_tokens, output_tokens, model}`. There **is** a route that reports money - the same model is served by OpenRouter as `typesafe/jev-1.13` and that response carries `usage.cost` - so "what does a daily actually cost" is answerable, but only by adding a second vendor to the request path, which has not been done.
92
+ - **The decision-model path has never completed a production daily run.** Measured on the archive: of 2203 articles, **4** carry `relevanceScore` (2026-09-05, from a `--date` re-run while the feature was being built) and **none** carries `includeScore`; the last complete day is 2026-09-05, and 2026-09-22's directory exists but is empty because that run stopped at 105/403 fetches (the failure 1.7.0's fetch cache was built for). So "classification now uses Jev" describes the code and a 60-article comparison, not a day that has happened. Every threshold quoted above (relevance `0.5`/`1.5`, inclusion `0.50`) is still the guess it was written as, and the switch is unexercised at day scale. `quality_eval.py sample` drives the same production call over a 150-article pool, which is the closest it has come; the daily itself is the thing that has not run.
93
+ - Relevance in the daily report is split into 高/中/低 at cut points that are **provisional** (0.5 and 1.5 on a `[0,2]` score). The raw score is stored as `relevanceScore` so recalibrating does not require re-running a day. On a 6-article trial with the shipped criteria, nothing reached 高.
94
+ - Search reranking is fail-soft by design: no key, a network failure or an overloaded service (`HTTP 529` was observed live) all return the first-stage order with a warning. Its one real hazard is candidate numbering - a model that answers question `c3` against candidate `c5` sinks the best hit with no error at all - so every candidate is labelled in the state and a separate single-choice question cross-checks the argmax, warning on disagreement. Verified on real data before shipping, but only on one planted query.
95
+ - `build_index` had never indexed any article (`glob(' marriage*.md')` matched 0 of 188 files), so semantic search covered chat messages only. The glob is fixed; **the article corpus will enter the index on the next rebuild**, which spends embedding calls, so it is a deliberate action rather than a side effect.
96
+ - Every config key a script reads is declared in `CliConfig`, so `config set` can set it; `test/config_keys_declared_test.py` enforces this with a justification-required exception list. Two keys were found by that audit: `dashscopeApiKey` (read by two scripts, absent from the schema entirely) and `favPassphrase` (a first-class encrypted secret that the allowlist refused). Both are settable now, and `dashscopeApiKey` became encrypted.
97
+ - `bizKey` / `bizSalt` are read by three scripts and written by nothing, so that branch is unreachable and `biz_message_0.db` is always opened via the key derived from `favPassphrase`. Recorded rather than "fixed": adding config surface for a value nothing produces would be inventing a knob, not restoring one.
98
+ - No script reads a `configService.ENCRYPTED_KEYS` member without decrypting it; `test/encrypted_config_test.py` enforces this statically (accepting the `*_enc` hold-then-decrypt convention). Four scripts used to read `deepseekApiKey` raw. They worked only because that one key is plaintext on the development machine - every other encrypted key on disk is `lock:` ciphertext, so a routine `config set` of that key would have broken all four at once with an error that named the wrong cause.
99
+ - **`assistant start` had never once started the daemon on the machine this was developed against.** The child was spawned without `--yes` while `assistant run` confirms through `inquirer` on a stdin the daemon had set to `ignore`, so the child died on the prompt while the parent printed「✓ 守护进程已启动 (pid …)」and wrote a pid file - and `~/.weflow-cli/` contained **no `assistant.log` and no `assistant.pid` at all**, which is how the documented `assistant start` flow turned out never to have completed. The env marker that appeared to guard this was read by nothing and was built as an env **key** containing `=` (`'WEFLOW_ASSISTANT_DAEMON=1'`). Both are gone (D-039): the parent confirms and passes `--yes` explicitly, and success is now **observed** - the child is watched for 700 ms, a dead one is reported with its exit code and the log tail, and no pid file is written. Verified live at the time: it reported `子进程启动后立即退出 (code 1)` with a log tail naming the real blocker - the message channel was not logged in (`wechatOcToken` unset). **That blocker no longer stops the daemon**: since the local panel (D-045) the channel is optional, `assistant start` comes up in local-entry mode when no token is configured, and the sender allowlist being empty is reported by `assistant status --json` as `channelActive: false` rather than as a failed start.
100
+ - **Local data is framed and escaped before it enters the system prompt**, and facts are injected by
101
+ relevance rather than in full. Both come from the same source: a chat message, an article body or a stored
102
+ fact can contain text that looks like our own framing, so the summary and fact blocks are wrapped in a
103
+ `<weflow-local-data>` frame whose tags are neutralised inside the content - data cannot close the frame and
104
+ speak as the system. That is the guarantee; instructions hidden in data are a separate, unsolved problem
105
+ and are not claimed to be solved here. Selection applies a **relevance floor before** the character budget (measured: without the floor, thirty short facts all fitted and "select by relevance" was only sorting - 2,483 bytes of facts per request; with it, an unrelated question injects eight recent facts as a fallback, 791 bytes). The prompt states
106
+ how many facts were withheld rather than implying it sees everything.
107
+ - **The assistant's memory is versioned now, and an unknown version is refused rather than guessed at**
108
+ (D-041). Auditing `deepseek-harness` for a memory design turned up something else worth having: it has
109
+ **no long-term memory at all** - it delegates that to third-party MCP providers - but it does pin its
110
+ session format at `0` with a written criterion for bumping and a load-time rejection, which is the cheap
111
+ half of that discipline and the half this repo was missing. The same audit fed three other changes here:
112
+ retention by ratio instead of a fixed turn count (a fixed count is window-independent and goes wrong with
113
+ the model), a fixed eight-section skeleton plus a merge law for the rolling summary (free-form summaries
114
+ were where compression quietly lost information), and fact provenance (`sourceTurn`/`sourceQuote`) so a
115
+ stored fact can be checked against what the user actually said.
116
+ - **Non-text messages were being dropped at the reader, and the symptom looked like a model problem.**
117
+ `parsedContent` was populated only for `local_type == 1`; other bodies are zstd BLOBs, and "not a str, so
118
+ empty" discarded them. A 44-message conversation reported three "empty" messages which were a revoke
119
+ notice and two stickers, and the assistant said truthfully that it could not read them - the first
120
+ diagnosis ("the model did not recognise the sticker") was wrong about the layer. Types are now derived
121
+ (`apptype * 2**32 + 49`), zstd is decompressed, and file names, revoke notices and transfer notes are
122
+ carried into `parsedContent`; anything unrecognised still says what it is. The lesson repeats
123
+ one already in this file: when an answer looks like a model failure, check whether the data reached it.
124
+ **This bullet used to claim "quoted text" was carried too, and that was wrong** - the appmsg payload puts
125
+ the reply in `title` and the quoted original in `refermsg/content`, and only the former was ever read.
126
+ See the quote fix below; it is the same mistake one layer down (a field read, a field assumed read).
127
+ - **What 30 days of real traffic actually contains** (measured on one archive: 1564 messages across 122
128
+ conversations). Text 69.4%, **images 15.5%**, revoke notices 3.1%, **quote messages 2.4%**, stickers
129
+ 2.1%, files 1.5%, everything else (links, video, official-account cards, mini-programs) 2.7%,
130
+ **voice 0.8%**. Two consequences: the plan to give the assistant a voice tool was dropped - 12 voice
131
+ messages a month does not pay for a transcriber on the read path - and the **images** were the one real
132
+ remaining gap, because `[图片]` was all the model got for one message in six. That one is now closed
133
+ (`look_at_image`, D-042): measured on this deployment, a direct chat resolves in 0.9 s and a 30,315
134
+ message group in 18.9 s, and the membership was verified rather than assumed - 380/380 sampled image
135
+ messages resolved to real bytes. Voice transcription (`scripts/wechat_voice.py`, local
136
+ `faster-whisper`, no network) exists but is wired to the **export** path, not the reader, and stays
137
+ there.
138
+ - **The assistant reads pictures now, and that is a new class of egress.** Images go to the same
139
+ third-party model the text already went to, but "a sentence about the chat" and "the photograph in the
140
+ chat" are not the same disclosure, so the hold is explicit: `strict` mode blocks it at two layers, the
141
+ `#N` handle is not shown when it cannot be used, and `IMAGE_SENT`/`IMAGE_HELD` are audit lines. The two
142
+ layers are deliberate - the tool declining and the request builder dropping are different statements,
143
+ and only the second one is about what actually left the machine.
144
+ - **The audit line, not the prompt, is what makes a fabricated tool claim visible.** The assistant told a
145
+ user it had looked up a conversation and found the bodies masked by strict mode, while `TURN_DONE 264B
146
+ tools=0` showed it never called the tool. The prompt was then changed (privacy state stated as a fact
147
+ about the current mode rather than a conditional, plus a rule that a conclusion about tool output
148
+ requires having called the tool) - but **the causal story first written here was refuted by
149
+ measurement**: 32 real calls across four prompt variants (current, conditional-only, with the model's
150
+ own earlier "blocked" answer seeded in its window, and the complete pre-change prompt) called the tool
151
+ 32/32 times. The single live occurrence remains unexplained. Keep the per-turn `tools=N` count: it is
152
+ what separates "it says it looked" from "it looked".
153
+ - The daemon runs `dist/bin/weflow-cli.js` **when that file exists**, so a source change does not reach the daemon until `npm run build` runs. Rebuilding is also what makes a daemon test meaningful: before this, the child was a compiled snapshot of the daemon logic.
154
+ - "Is this a real article" has one predicate: the frontmatter carries `url:`. Generated artifacts (`README.md`, `行动建议.md`) appear at both `<day>/` and `<day>/<topic>/`, so path-based filtering misses half of them. Both search paths (index building and the keyword fallback) now agree; the fallback previously returned report artifacts as articles.
155
+ - Nothing declared in `scripts/` describes behaviour the code does not have. A sweep removed `MAX_ARTICLES` (a fetch cap that was never read), a duplicate `CONFIG_PATH`, `_utils.parallel_map` (no callers), and fifteen unreachable lines after `search()`'s return that documented a `dict` return the function no longer has. A constant whose comment lies is worse than no constant - this is the same failure shape as a field written on one path only.
156
+ - A sweep of `scripts/*.py` (compare every top-level function by name and source) finds **25 groups of same-named functions in more than one file, of which all but one differ**. Two of those groups are worth knowing about and neither is fixed:
157
+ - `get_name_map` + `open_db` exist in **six** copies each and are **semantically identical** (differences are whitespace and `except: pass` formatting), so they are safe to unify. Deferred: the shared version needs a lazy sqlcipher loader, and three test files stub `sqlcipher3` at *import* time — a lazy loader would resolve it at call time, outside the patch, and open the plain-SQLite fixtures with the real driver.
158
+ - `parse_frontmatter` exists in **four** copies and they are **not equivalent**: `_utils` keeps the body with `lstrip('
159
+ ')` and only unquotes when both ends carry the same quote character, while the other three use `strip()` and `strip('"').strip("'")`. Unifying therefore **changes what three commands produce** (leading whitespace in bodies, values with mixed quotes), so it needs a deliberate choice rather than a tidy-up.
160
+ - The same sweep over the TypeScript side (`src/**`, `bin/**`, ~18.8k lines) finds **5 same-named groups, and none of them is a defect** - recorded so the next sweep does not have to re-open them. Two were false positives from matching local assignments (`content`, `summary`). `resolvePackageRoot` is the **correct** shape: one implementation in `src/utils/packageRoot.ts` that walks up looking for `package.json` **and** `scripts/`, with `bin/weflow-cli.ts` and three services importing it and wrapping it in one-line local aliases. And the two talker resolvers differ **deliberately**: `talkerUtils.resolveTalker` is the CLI path (server-side search, interactive pick when a name is ambiguous, throws when non-interactive), while `assistantTools.resolveUniqueTalker` is the assistant path (no prompt is possible, so ambiguity always throws) and returns the query unchanged when nothing matches. That last fallback is benign rather than a silent wrong lookup: its only caller does `getMessages(talker)` and reports `(没找到「X」的消息)` from the empty result, so a mistyped name ends in an accurate "not found" instead of a wrong conversation.
161
+ - A scan for **string collections written in more than one file** (the `TOPIC_ORDER` shape, which is what the topic fallback bug turned out to be) finds three, and two were real. Both are now single definitions. `MESSAGE_ANCHOR_COLUMNS` moved into `nt_common` - it is the shared NT plumbing that module exists for, and it had **three** copies (a named one, a literal in the same file's `ORDER BY`, and one in the exporter). Its **order is behaviour**: the same tuple is used as a set for the schema check and as the `ORDER BY` key list, so putting `local_id` first would silently reorder every conversation read. It stays a tuple rather than a set (a set would let the order follow the interpreter's hash seed), and a test pins `create_time` as the primary key, the tuple type, and the absence of the literal in both readers. The relevance vocabulary `['低','中','高']` moved to `_utils` beside `TOPICS`, with `DEFAULT_RELEVANCE` naming the fallback that answers why the corpus once read 中 everywhere - it is the value every failed classification path lands on, and 中 reads as a *positive* judgement rather than "not judged". `extract_todos.py`'s identical `['高','中','低']` is deliberately **not** coupled: that is the `--urgency` vocabulary for todos, a different concept that merely shares three characters, carried as a documented exception in the check.
162
+ - Several checks added in this pass were written from an assumption rather than from the code, and each is the same mistake worth naming: asserting `jev_client.DEFAULT_RELEVANCE` exists (it does not - `score_to_relevance` always has a level to return, so it needs no fallback); searching the source text for the JSON writer's `tags` default, which flagged the docstring that documents that very pattern; and checking `relevance`'s inline vocabulary by confirming only that the module *imports* it - a mutation test proved "imports the table but writes the literal anyway" sails through an identity assertion, so that check now looks for the literal itself, with a justification-required exception list (the convention `test/config_keys_declared_test.py` already established).
163
+ - NT plumbing that two readers need (`discover_message_shards`, `derive_database_key`, `table_columns`) lives only in `scripts/nt_common.py`; both `nt_decrypt` and `export_chat_html` import it, and a test pins it by identity. They used to be four same-named helpers with different contracts - one `discover_message_shards` returned `[the configured database]` when it found no shards, the other returned empty and left the compensation to its caller.
164
+ - The topic taxonomy (`TOPICS` and `TOPIC_CRITERIA`) lives only in `_utils`; the prompt path and the decision-model path read the same table. It used to be copied into six files, and the copy inside the prompt had drifted to five categories while `TOPICS` held six - 政治 has 26% of the corpus and had no judging rule.
165
+ - One article gets one topic, decided once. The write path used to derive an article's topic from three places - the folder-grouping key (defaulting to `'学术'`), the JSON writer (defaulting to `''`), and the raw field - so one run could emit `topic: 学术` in the md and `topic: ""` in the JSON, with no error on either side. The 2026-09-04 output is that failure in full: 178 articles, all under `学术/`, all `topic: ""` in `.articles.json`, and two of them are "OpenAI 深夜发布 GPT-6 Astra" and an AI-tool launch. The trigger is ordinary rather than exotic - `daily --no-ai`, or a daily run with no API key, skips the classification phase entirely, so no article has a `topic` key at all while the write phase still runs. `_utils.DEFAULT_TOPIC` is now the only fallback, and `biz_daily._group_by_topic()` both normalises and groups in one entry point - so the md frontmatter topic (which is the *group key*) and the JSON topic cannot be different values by construction. It checks **membership** in `TOPICS` rather than emptiness, and reports the count when it fires so a whole-batch fallback cannot read as a normal classification. The **same shape, seven lines away, sat in `tags`** and was fixed in the same pass: the md writer defaulted a missing `tags` key to `[topic]` and the JSON writer to `[]`, so the same articles read `tags: ['学术']` in the md and `tags: []` in the JSON - measured on 09-04 (177 articles) and on no other day, because the 1408 articles of the eight normally-generated days agree completely. Both writers now call `_tags_for_write`, which keeps a *present but empty* list empty (the model extracted no tags is a different case from the key being absent, and merging them would just be a new default written in two places). The field-by-field check of everything both writers carry is now **exhausted** for this writer pair: `relevance` matches on 1591 of 1593 comparable articles (the two exceptions were the 2026-09-05 residue of an accidental local run; that day's `.articles.json` has since been rebuilt from its md files, so re-running this comparison no longer shows them), and `title` / `source` / `date` / `url` come from the same source values. `summary` looks like it disagrees on 79 articles, but that is not a live path: both writers read the identical expression (`biz_daily.py` lines 158 and 996), and the mismatches are day folders whose md and json were written by **different generations of the pipeline** - the md carries the older `## 深度解析` structured summary while the JSON carries the current prose one. The same folders show it more starkly: 2026-08-27 has 196 md files with no JSON counterpart and 2026-08-30 has 218 with the JSON holding zero entries. Regenerating those days is what would make them consistent; it is not a code defect. The fallback value is still a **guess**: the honest label for these articles is "unclassified", and introducing a real `未分类` category would change the folder layout, the HTML sections and every literal topic comparison, so it is not done. `TOPIC_ORDER` is still copied verbatim into four scripts that iterate it to lay out report sections; those are deliberately **not** unified - `create_reading_notes` and `generate_html` have no `_utils` import edge, so adding one makes whether the command runs depend on the caller's working directory - so `test/default_topic_test.py` asserts all four copies still equal `TOPICS`. Adding a seventh category now fails the tests instead of silently dropping a section from the report.
166
+
167
+ **The same shape recurred on 2026-09-26, in a new writer, and this is the second time.** `backfill_articles.py` (the
168
+ historical-article importer, see CHANGELOG) did not set a `topic` at all in its first version, so `_group_by_topic`
169
+ folded a whole day into `DEFAULT_TOPIC` - 106 of 106 articles under `学术/`, exactly the 09-04 failure above with a
170
+ different writer. What caught it was the same signal this paragraph describes: the fallback count in the output
171
+ (`主题兜底 113 篇`), which is why that counter exists. The lesson is not the fix, which was one line (`_guess_topic`,
172
+ the same fallback `biz_daily` uses when AI classification fails), but that **a counter reporting a whole-batch
173
+ fallback is a defect report, not a statistic** - it was in the output and read past once before the assertion
174
+ `'学术' != 'AI'` in a test made it undeniable. `test/backfill_articles_test.py` now pins it, including a mutation
175
+ check (replacing the guess with the default fails three tests).
176
+
177
+ **Historical backfill is a separate line from the daily pipeline, and stays separate.** Neither of the two
178
+ image-carrying outputs is needed for the knowledge base, and the difference is not marginal: the daily path stores
179
+ `output/biz-daily/<date>/` (article HTML plus images, ~1.1 MB/article) *and* an identical copy under
180
+ `wechat-vault/Sources/WeChat/<date>/` (`pipeline.py:133`), so the same 35,463 articles would cost ~65 GB the daily
181
+ way and ~0.6 GB as text. That "identical copy" describes the **daily** path only - since 2026-09-28 the backfill path's copy goes through `strip_wx_ads_document`, so it is no longer byte-identical to `biz-daily` (the original stays there, and D-055 explains why the cleaning had to live inside the copy step rather than the fetch). That measurement is what settled the design; the 10-minute timeout and the serial fetch loop
182
+ were symptoms of the same mismatch, that this path was built for one day at a time.
183
+
184
+ **Concept extraction is the one step that costs money per article, and its cost is now measured rather than
185
+ modelled.** `article-notes --topic` filters the corpus, because measured over the 3,498 backfilled articles news is
186
+ 57% and AI 28%, so carding only AI removes more than half the spend. The per-call figure came from the account
187
+ balance (120 probe calls, ¥0.22, **¥0.0018 each**, ≈¥30 for 16,600 articles) rather than from a price table -
188
+ DeepSeek's published prices exist in at least three mutually inconsistent versions for the same model. That
189
+ measurement also produced a fact no price table holds: the balance was ¥27.12, so the unfiltered run would have
190
+ stopped partway through with an insufficient-balance error rather than a clean finish. Two plausible-looking
191
+ savings were measured and are **not** levers: `MAX_ARTICLE_CHARS` (3000) never binds because a real call sends
192
+ ~1,394 characters, and the fetch step is not the cost driver. The remaining unmeasured lever is media de-duplication
193
+ (major outlets republish the same story; 红星新闻 holds 2,040 articles, 政事儿 2,297) - it is not quantified here
194
+ because the title-similarity measurement has not been run, and quoting an unmeasured saving would be a guess.
195
+
196
+ **Concept page filenames collide when names differ only in case, and on Windows that silently merges pages.**
197
+ Measured on the full run: 1,984 concept names produced **1,963 files**, because Windows' filesystem is
198
+ case-insensitive - `Codex` / `codex` / `CodeX` are one file, as are `Claude Code` / `claude code` and 30 other
199
+ groups (`OpenClaw` / `openclaw` / `openClaw` / `Openclaw`). Every name has a file (checked with `Path.exists()`),
200
+ so nothing is missing; what happens is that the later write wins and the earlier one's content is gone. **This is
201
+ pre-existing** - the name sanitisation and the filesystem are unchanged by the concurrency work - and it is **not
202
+ fixed for the pages already written**, because fixing those changes filenames in the user's Vault. What *was* fixed on 2026-09-27 is the future: the model-free page builder skips a name whose `casefold()` already exists (`compile_wiki.py:699`), and `--merge-duplicates` groups by `casefold()` (`:840`), so new pages no longer overwrite one another - the remaining problem is the historical loss, which is a restore decision rather than a code change. The measurement above also predates the current 22,374-page corpus. The measurement that found it was also a
203
+ lesson in method: comparing a *set of expected filenames* against `glob('*.md')` reported "40 pages were never
204
+ written", which was wrong; the directory listing collapses case-variant names, so the set comparison disagreed with
205
+ the filesystem. Checking `Path.exists()` per name gave the true answer (0 missing) - **for "is this file there",
206
+ ask the filesystem, do not compare name sets.**
207
+
208
+ **The Vault's layers and which of them are supposed to be empty.** `000_Inbox` / `003_Ideas` /
209
+ `004_Permanent` / `005_Reference` / `006_Projects` / `008_MOC` / `999_Archive` are created by `vault init` for the
210
+ **user's own notes** and stay empty until `vault promote` (or the user) fills them - their emptiness is the design,
211
+ not a gap. `Sources/` holds **raw material** (`Sources/WeChat/<date>/` from the daily pipeline *and* from `backfill_articles --vault-sync`; the backfilled copies have the WeChat interface phrases stripped, frontmatter and fenced code blocks excepted, so "raw" is the right word only for `output/biz-daily`, which is the archive - see D-055),
212
+ `Sources/WeRead/` from `sync_weread`), `001_Daily` holds one diary page per processed date, `002_Literature` holds
213
+ the reading notes, `Wiki/` holds the **article line's** generated concept pages plus `00-Overview.md`, and `Chat/` holds the
214
+ **chat line's** (`Chat/Concepts/`, `Chat/00-Overview.md`) - one Vault, two knowledge bases, split on
215
+ 2026-09-27 at the user's request (D-051 records why every reader had to be told about both: reading one of
216
+ two directories is silently wrong, not broken). Two things about this were
217
+ wrong on 2026-09-27 and are now fixed: the concept directory was **declared in two places** (`VAULT_DIRS` said
218
+ `007_Wiki/Concepts`, every writer and reader used the top-level `Wiki/Concepts`), so `007_Wiki/` was created and
219
+ never filled; and the historical import skipped `Sources/` entirely (it would have carried ~33 GB of images), which
220
+ left raw material for 11 days against reading notes for 175. `backfill_articles --vault-sync` copies the `.md`
221
+ files only (~400 MB) and copies per-file rather than replacing the day's directory; re-running it is safe and is also how the Vault's copy gets re-cleaned after any change to the cleaner. Its day-selection rule was **wrong until 2026-09-28**: it keyed off a `backfilled` marker in `.articles.json`, a key only the backfill writer sets, so the 12 days produced by the daily path were copied never *and* reported never - two of them (08-24, 08-25) had no directory in the Vault at all. It now asks whether the day holds a `.md` besides `README.md` (187 → 199 days, 33,232 → 35,463 files). **The lesson recorded here is
222
+ about how the second one was missed**: the omission was disclosed in conversation, and a caveat that exists only in
223
+ chat is not a caveat - it left no trace the user could find, which is why it surfaced as "the vault looks stale".
224
+
225
+ **The Vault's notes embed Dataview queries, so the Vault has an external dependency the repo does not carry.**
226
+ 34,092 reading notes and 176 daily notes each contain a `dataview` code block (100% of both), and the templates are
227
+ the only place the queries are written - `RELATED_QUERY` and `DAILY_QUERY` exist as single constants because both
228
+ the template and the rewrite pass need them. **Dataview is a community plugin and `output/` is gitignored, so it
229
+ must be installed per machine** (0.5.68 here, enabled via `.obsidian/community-plugins.json`); after installing,
230
+ Obsidian has to be restarted before it loads, and the user must have community plugins enabled. Measured on
231
+ 2026-09-27, when the user reported the vault "looking stale": the plugin was absent AND every query was wrong
232
+ (`SORT date` on a corpus that stores `published`; `WHERE created = date(...)` on a field that is the *generation*
233
+ date and is therefore identical across all 34,092 notes). **Changing the templates fixes nothing on its own** -
234
+ `create_reading_note()` returns `skip` for an existing note - so `--refresh-queries` exists to rewrite the block in
235
+ place; it is idempotent, never touches the rest of the file, and reports rather than guesses (7 notes have an empty
236
+ topic and are counted separately).
237
+
238
+ **Declaring a directory is a promise that something writes to it, and the Vault had three unkept ones.** This is
239
+ the same shape as the `007_Wiki` bug and it recurred twice more in the same audit: `Wiki/Entities/` and
240
+ `Wiki/Topics/` were listed by `vault init` **and described in the generated `README.md` as if they existed**
241
+ ("实体页(公众号、作者等)", "主题总览页") while no code writes either, and `.obsidian/app.json` pointed
242
+ attachments at `Assets` while the layout creates `_attachments` (that literal appeared exactly once in the
243
+ codebase - an outlier, which is the same tell as before). Both are now removed/realigned. Because the two layout
244
+ lists live in different languages (TS CLI and Python script) they cannot share a constant, so the agreement is
245
+ pinned by test instead - the pattern this repo already uses for four copies of `TOPIC_ORDER`:
246
+ `test/create_reading_notes_test.py` parses the CLI's `dirs` array and asserts it creates everything `VAULT_DIRS`
247
+ expects, contains no `Wiki/*` subdirectory other than `Concepts`, and that `attachmentFolderPath` names a directory
248
+ the layout actually creates. Three mutation checks confirm each assertion bites.
249
+
250
+ **The existing Vault had never been initialized.** It was built by `create_reading_notes`'s `VAULT_DIRS` (just
251
+ `mkdir`), so `README.md`, `Templates/article.md`, `.gitignore` and the configured `.obsidian/app.json` were all
252
+ missing - `.obsidian/app.json` existed but was `{}`. Running `vault init --yes` once fixed that. **Order matters
253
+ here**: Obsidian was closed first, because it keeps `app.json` in memory and writes it back on exit, which would
254
+ have discarded the corrected `attachmentFolderPath`. The `README.md`'s directory table was also rewritten: it now
255
+ describes the real layout and states explicitly which folders are meant to stay empty (the `000`-`008` series is
256
+ for the user's own notes and only `vault promote` fills them), because "empty because it is your layer" and "empty
257
+ because nobody built it" look identical to a reader.
258
+ - The stored corpus under `output/biz-daily/` shows **two README generations**, and no code in the repository produces the older one (checked by searching every README writer: the only ones left are `biz_daily` and `fix_topics`, and both emit tables). The older generation - the numbered list, on the eight days 2026-08-27..09-03 - rendered **every article link's text as `---`** (1446 links across those days, the only well-formed entry being the `行动建议` one). That text has been repaired in place from each target file's frontmatter title, leaving the numbered format, the entry order and the recorded counts untouched, with each original kept as `.README.before-title-repair.md`. The counts were **not** changed even where they differ from the directory (226 in the README vs 228 articles on 08-27, 205 vs 217 on 08-30): a README count describes the run that wrote it, whereas the folder accumulates, so "they disagree" is not by itself an error.
259
+ - Two days whose `.articles.json` was effectively absent were rebuilt from their md files: 2026-08-27 held 33 entries against 228 articles (14%) and 2026-08-30 held 0 against 217. Both are marked with a `reconstructed` block naming the reason, the recovered fields and what was excluded, and the originals are kept as `.articles.before-reconstruction.json`. The **topics are copied from the md and verified** - every entry agrees with the "one topic per source across days" map (228/228 and 217/217), which is what makes copying legitimate here; on 2026-09-05 the same check fails, so that day's unclassified articles are recorded as **unknown (`""`) rather than as the md's `学术` fallback**. The `行动建议.md` report artifact is excluded from all of these, on the same `url:`-in-frontmatter test the search paths use - the first attempt at the rebuild included it as an article.
260
+ - **Every stored day's `.articles.json` now describes that day's articles, and the convention it follows was recovered from the days that were never rewritten rather than assumed.** That convention has three parts, and all three were needed before the six rebuilt days matched the six untouched ones: the JSON **excludes the `收藏/` collection** (a separate set, synced by its own command - read off 2026-08-25, whose 186 entries are its 184 articles plus 2 orphans, not its 197 files), **excludes report artifacts** like `行动建议.md` (the article predicate is `url:` in the frontmatter, the same test the search paths use), and **includes entries whose md was never written** because the body was too short - `biz_daily` prints `[SKIP] 内容过短` and skips the file while still recording the article, so an entry with no file on disk is expected rather than a dangling pointer. Checked across all twelve days: no article on disk is missing from the JSON, no favorite is in it, and the only entries without a file are those short-content orphans (08-24 has 2, 08-25 2, 08-28/09-02/09-04 1 each, 08-29 2). Six days were rebuilt from their md (2026-08-24, 08-27, 08-28, 08-30, 08-31 and 09-05), each carrying a `reconstructed` block with the reason, the recovered fields, the scope, and the topic verification - and each keeping its original as `.articles.before-reconstruction.json` (09-05's as `.articles.overwritten-by-accidental-run.json`) with its `generated_at` preserved in the new file. **Two mistakes were made and corrected inside this work, and they are the same class**: the first rebuild counted `行动建议.md` as an article, and the first rebuild of four days also pulled in the `收藏/` copies and dropped the short-content orphans - that is, it quietly changed what the file *means* instead of completing it. Both were caught by checking the rebuilt days against the untouched ones. 2026-09-04 is left exactly as it was: its 178 empty topics are the record of the failure this session fixed, and rewriting it would destroy the evidence.
261
+ - **Jev cannot rank conversations for search, and this is measured rather than assumed.** The first design of `route_cards` put one card per conversation (type, message count, span, recency, name, query-term hit counts) into a single request and asked Jev which ones were relevant. Both question shapes failed: as a per-card `noul` every candidate landed at 0.50-0.51 (a coupon group with one message sat in the same band as the conference group with 213 hits, and the model left out the session that most deserved opening), and as a per-card `score` on a 0/1/2 scale all 20 candidates scored 1.74-1.76 regardless of whether they had 247 hits or 8. Sorting the same data locally by hit count ranked it correctly. The card simply carries too little for the judgement being asked (the model has never seen the conversation), so the shipped tool **ranks locally and keeps Jev only for the one job it does well**: choosing the real query words from the n-gram fragments. A second `noul` finding is recorded with it - the answer is a **probability float** (0.98 for a true claim, 0.01 for a false one), so reading it as a boolean makes every question look answered "no"; the run printed "it thinks none of these words are what you want" when the truth was that the parser read the wrong field.
262
+ - **A limited run still writes a README that describes the whole directory.** `biz_daily`'s README and its `今日简报` are built by scanning the day's existing `.md` files, not from the articles that run fetched, so `--limit 6` on a day that already holds 191 articles rewrites the index for all 191 and reports `共 191 篇推送`. This is not a bug (the README is supposed to describe the day's folder) but it is misleading as a check: a limited test run leaves no trace of its own limit in the README, and the only sign is the count. Use the run's stdout progress lines - not the README - to tell what a partial run actually fetched.
263
+ - **`tags` is the configured category for any source that has one, by design.** The prompt for a categorised source is summary-only and explicitly says *不要输出主题、标签、相关度或概念字段*, and the code sets `tags = [category]` itself. Measured over the 1584 articles of the 11 stored days: 1396 carry exactly `[topic]`, 182 carry none, and only **6** carry real extracted tags - and those 6 are the corpus built without configured categories. The checkable signature is that **on every one of those days each source maps to exactly one topic** (36/40/15/43/41/27/35/36/3/36/6 sources, no exceptions), which is what topic-from-config produces and per-article classification does not. A live call through the production prompt returns real tags (`AI与数学, 人类数学家, 数学共同体, 人机关系, 学术人文`), so extraction itself is not broken - it is simply not asked for on that path. Practical consequence: to get meaningful tags for a source, leave its category unconfigured (and accept that its topic then comes from classification rather than from you).
264
+ - **No real artifact on disk carries `includeScore`.** The six files in the 2026-09-05 output directory were written before that field existed, so the field has never been produced in a real run - which is why calibration has to re-score articles rather than read stored values.
265
+ - The report's admission threshold (`INCLUDE_THRESHOLD = 0.5` on `includeScore`) is provisional in the same way and, unlike the old rule, it is actually applied: both loaders call one predicate. Turning it on **reduced** what the report collects, because the primary loader previously collected everything unfiltered. On a 6-article trial the scores straddled the cut point (0.48 and 0.63 on two engineering posts), so the cut point is doing real work rather than being decorative.
266
+ - Message reads select only the columns the reader consumes, resolved per shard from what that shard actually has. `nt_decrypt` reads six (`local_id`, `server_id`, `local_type`, `real_sender_id`, `create_time`, `message_content`) and a WeChat version that renames or drops any other column of the message table therefore cannot make a shard unreadable. What is lost is reported, not swallowed: a shard lacking one of those six is read anyway and named in `missingColumns`, while a shard where none of them is present is reported as `SCHEMA_MISMATCH` and makes `coverage` partial. `export_chat_html` reads rows positionally, so there a missing column keeps its slot as `NULL` instead of shifting the fields after it. Both are verified against synthetic shards only - no future WeChat schema has been tested.
267
+
268
+ - **Topic exclusion is a display-layer switch, and that is a measurement result rather than a preference.** `dailyExcludeTopics` (or `--exclude-topics`) removes a topic from the daily report and the reader page; the article is still fetched, judged and archived. Filtering *before* the fetch was the original ask and was rejected on evidence: from a source name plus a title and the platform digest - everything that exists pre-fetch - the type judgement agreed with the source-level configuration on only **60%** of 217 articles, with 48 false positives, and there is no gold standard either way. A dropped fetch is irreversible (nothing is archived, and the page is not the same weeks later) while an unwanted article costs only attention, so the decision is deliberately placed where it can be undone by regenerating. The exclusion is orthogonal to `--include-all` and to the inclusion score, and **all three lists in 我拿不准的 are filtered** - passing the exclusion set to `admits()` in the "not admitted" list had the opposite effect and surfaced the excluded articles (that list is populated by exactly the articles being excluded). One predicate (`is_excluded_topic`) now serves both directions. Excluding the focus topic is refused with a WARN, because the result is not an empty report but a misleading one: it exits with "未找到文章,请先运行 biz_daily.py". The key is registered in all four `configService` literals plus `bin`'s `configurableKeys`, which `test/config-keys.test.ts` now enforces - a key missing from defaults or `clear()` survives `config set` and then quietly disappears.
269
+ - **The source-level prior is recorded, never acted on.** Every daily appends `(source, topic)` counts for the articles it archived to `~/.weflow-cli/source_topics.json`, and names the sources whose history is stable and lands in an excluded topic (`来源先验: 甲号 → 新闻(20 篇里 19 篇 = 95%)`). **Nothing is skipped.** The table has to be measured rather than authored - the pre-fetch guess it replaces was already measured at 60% - so recording is a prerequisite for a future skip decision, not that decision. It lives outside the repository because it holds 公众号 names (a test asserts this), counts are append-only, and only articles that landed on disk are recorded, so the table and `output/` describe the same corpus. Thresholds (8 samples, 80% share) are **provisional and uncalibrated**. `stable_source_topic` returns `None` for "not enough history yet", and callers must not read that as a default topic - skipping a source *because* it is unknown is the worst failure available here. Whether a stable source is ever skipped before the fetch remains the user's call, to be made on the printed numbers.
270
+ ## Current Priorities
271
+
272
+ 1. Keep initialization and database access compatible with supported WeChat 4.x variants, with reproducible issue diagnostics that contain no secrets.
273
+ 2. Keep the daily reader reliable for configured sources and categories without widening access to local data.
274
+ 3. Maintain security boundaries around local servers, assistant access, MCP tools, and cloud AI disclosure.
275
+ 4. Evaluate external collaboration through read-only, synthetic-data, versioned interfaces before connecting real user data.
276
+ 5. Make Bot-channel send capability and its limitations unambiguous in CLI naming and help text before extending messaging features.
277
+
278
+ ## External Collaboration Position
279
+
280
+ For projects such as Yance, WeFlow CLI remains an independent local data-access layer. Early collaboration may cover read-only schemas, anonymized fixtures, and controlled MCP adapters. It must not require sharing real databases, credentials, keys, full local directories, or direct database access by an external product.
281
+
282
+ Where outside code attaches is stated rather than implied (D-048): **in-repo contribution, or MCP/`--json` from a
283
+ separate process.** There is no plugin loader, no adapter registry and no public import surface, and that is a boundary
284
+ decision, not an unfinished chore - an in-process plugin would hold the same reach as the code it joins. `docs/EXTENDING.md`
285
+ is the checklist for the in-repo path, including the two extension points where **nothing** will fail if you forget a
286
+ step (the CLI's interactive-menu entry is the notable one).
287
+
288
+ ## Related Documents
289
+
290
+ - Architecture and data flow: [ARCHITECTURE.md](../ARCHITECTURE.md)
291
+ - Setup and troubleshooting: [OPERATIONS.md](../OPERATIONS.md)
292
+ - Security and reporting: [SECURITY.md](../SECURITY.md)
293
+ - MCP surface: [MCP.md](MCP.md)
294
+ - Design rationale: [DECISIONS.md](DECISIONS.md)
295
+ - Partner integration record: [PARTNERS.md](PARTNERS.md)
296
+ - AI integration surface: [AI_INTERFACE.md](AI_INTERFACE.md)
297
+ - Overall improvement and expansion plan: [ROADMAP.md](ROADMAP.md)
298
+ - DeepSeek V4 Flash施工文档: [DEEPSEEK_V4_FLASH施工文档.md](DEEPSEEK_V4_FLASH施工文档.md)
299
+ - User-visible releases: [CHANGELOG.md](../CHANGELOG.md)