weflow-cli 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +1949 -1
  3. package/OPERATIONS.md +774 -1
  4. package/README.en.md +93 -7
  5. package/README.md +31 -6
  6. package/bin/weflow-cli.ts +934 -27
  7. package/dist/bin/weflow-cli.js +952 -27
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.js +39 -13
  10. package/dist/mcp-server/index.js.map +1 -1
  11. package/dist/src/core/appMsgFormat.d.ts +37 -0
  12. package/dist/src/core/appMsgFormat.d.ts.map +1 -0
  13. package/dist/src/core/appMsgFormat.js +106 -0
  14. package/dist/src/core/appMsgFormat.js.map +1 -0
  15. package/dist/src/core/ntCore.d.ts +19 -2
  16. package/dist/src/core/ntCore.d.ts.map +1 -1
  17. package/dist/src/core/ntCore.js +11 -1
  18. package/dist/src/core/ntCore.js.map +1 -1
  19. package/dist/src/core/sqlcipherCore.d.ts +0 -12
  20. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  21. package/dist/src/core/sqlcipherCore.js +5 -64
  22. package/dist/src/core/sqlcipherCore.js.map +1 -1
  23. package/dist/src/core/wcdbCore.d.ts +1 -1
  24. package/dist/src/core/wcdbCore.d.ts.map +1 -1
  25. package/dist/src/core/wcdbCore.js +5 -1
  26. package/dist/src/core/wcdbCore.js.map +1 -1
  27. package/dist/src/panel/client.d.ts +25 -0
  28. package/dist/src/panel/client.d.ts.map +1 -0
  29. package/dist/src/panel/client.js +34 -0
  30. package/dist/src/panel/client.js.map +1 -0
  31. package/dist/src/panel/endpoint.d.ts +45 -0
  32. package/dist/src/panel/endpoint.d.ts.map +1 -0
  33. package/dist/src/panel/endpoint.js +132 -0
  34. package/dist/src/panel/endpoint.js.map +1 -0
  35. package/dist/src/panel/launch.d.ts +50 -0
  36. package/dist/src/panel/launch.d.ts.map +1 -0
  37. package/dist/src/panel/launch.js +74 -0
  38. package/dist/src/panel/launch.js.map +1 -0
  39. package/dist/src/panel/server.d.ts +23 -0
  40. package/dist/src/panel/server.d.ts.map +1 -0
  41. package/dist/src/panel/server.js +413 -0
  42. package/dist/src/panel/server.js.map +1 -0
  43. package/dist/src/panel/userId.d.ts +37 -0
  44. package/dist/src/panel/userId.d.ts.map +1 -0
  45. package/dist/src/panel/userId.js +56 -0
  46. package/dist/src/panel/userId.js.map +1 -0
  47. package/dist/src/services/assistantDaemon.d.ts +20 -3
  48. package/dist/src/services/assistantDaemon.d.ts.map +1 -1
  49. package/dist/src/services/assistantDaemon.js +46 -9
  50. package/dist/src/services/assistantDaemon.js.map +1 -1
  51. package/dist/src/services/assistantEval.d.ts +141 -0
  52. package/dist/src/services/assistantEval.d.ts.map +1 -0
  53. package/dist/src/services/assistantEval.js +497 -0
  54. package/dist/src/services/assistantEval.js.map +1 -0
  55. package/dist/src/services/assistantMemory.d.ts +106 -4
  56. package/dist/src/services/assistantMemory.d.ts.map +1 -1
  57. package/dist/src/services/assistantMemory.js +396 -61
  58. package/dist/src/services/assistantMemory.js.map +1 -1
  59. package/dist/src/services/assistantPrivacy.d.ts +15 -2
  60. package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
  61. package/dist/src/services/assistantPrivacy.js +18 -4
  62. package/dist/src/services/assistantPrivacy.js.map +1 -1
  63. package/dist/src/services/assistantRouter.d.ts +41 -0
  64. package/dist/src/services/assistantRouter.d.ts.map +1 -0
  65. package/dist/src/services/assistantRouter.js +151 -0
  66. package/dist/src/services/assistantRouter.js.map +1 -0
  67. package/dist/src/services/assistantService.d.ts +209 -3
  68. package/dist/src/services/assistantService.d.ts.map +1 -1
  69. package/dist/src/services/assistantService.js +590 -75
  70. package/dist/src/services/assistantService.js.map +1 -1
  71. package/dist/src/services/assistantTools.d.ts +205 -1
  72. package/dist/src/services/assistantTools.d.ts.map +1 -1
  73. package/dist/src/services/assistantTools.js +1089 -58
  74. package/dist/src/services/assistantTools.js.map +1 -1
  75. package/dist/src/services/assistantTrace.d.ts +47 -0
  76. package/dist/src/services/assistantTrace.d.ts.map +1 -0
  77. package/dist/src/services/assistantTrace.js +150 -0
  78. package/dist/src/services/assistantTrace.js.map +1 -0
  79. package/dist/src/services/chatService.d.ts +1 -1
  80. package/dist/src/services/chatService.d.ts.map +1 -1
  81. package/dist/src/services/chatService.js +5 -2
  82. package/dist/src/services/chatService.js.map +1 -1
  83. package/dist/src/services/configService.d.ts +9 -0
  84. package/dist/src/services/configService.d.ts.map +1 -1
  85. package/dist/src/services/configService.js +15 -8
  86. package/dist/src/services/configService.js.map +1 -1
  87. package/dist/src/services/pythonBridge.d.ts +41 -0
  88. package/dist/src/services/pythonBridge.d.ts.map +1 -0
  89. package/dist/src/services/pythonBridge.js +119 -0
  90. package/dist/src/services/pythonBridge.js.map +1 -0
  91. package/dist/src/services/syncService.d.ts +8 -2
  92. package/dist/src/services/syncService.d.ts.map +1 -1
  93. package/dist/src/services/syncService.js +26 -7
  94. package/dist/src/services/syncService.js.map +1 -1
  95. package/dist/src/services/wechatMessageService.d.ts +25 -0
  96. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  97. package/dist/src/services/wechatMessageService.js +69 -7
  98. package/dist/src/services/wechatMessageService.js.map +1 -1
  99. package/dist/src/utils/atomicWrite.d.ts +4 -0
  100. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  101. package/dist/src/utils/atomicWrite.js +60 -0
  102. package/dist/src/utils/atomicWrite.js.map +1 -0
  103. package/dist/src/utils/dateRange.d.ts +15 -0
  104. package/dist/src/utils/dateRange.d.ts.map +1 -1
  105. package/dist/src/utils/dateRange.js +47 -0
  106. package/dist/src/utils/dateRange.js.map +1 -1
  107. package/dist/src/utils/text.d.ts +18 -0
  108. package/dist/src/utils/text.d.ts.map +1 -0
  109. package/dist/src/utils/text.js +20 -0
  110. package/dist/src/utils/text.js.map +1 -0
  111. package/docs/AI_INTERFACE.md +2 -0
  112. package/docs/DECISIONS.md +1424 -0
  113. package/docs/EXTENDING.md +187 -0
  114. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  115. package/docs/MCP.md +23 -4
  116. package/docs/PROJECT_STATE.md +299 -98
  117. package/docs/SETUP.md +22 -0
  118. package/docs/SYNC_CONTRACT.md +31 -3
  119. package/mcp-server/index.ts +681 -657
  120. package/package.json +84 -83
  121. package/resources/panel/ball-position.cjs +153 -0
  122. package/resources/panel/index.html +46 -0
  123. package/resources/panel/main.cjs +447 -0
  124. package/resources/panel/mascot-focus.png +0 -0
  125. package/resources/panel/mascot-happy.png +0 -0
  126. package/resources/panel/mascot-sorry.png +0 -0
  127. package/resources/panel/mascot-tired.png +0 -0
  128. package/resources/panel/mascot.png +0 -0
  129. package/resources/panel/package.json +7 -0
  130. package/resources/panel/panel.css +407 -0
  131. package/resources/panel/preload.cjs +72 -0
  132. package/resources/panel/quick-menu.cjs +197 -0
  133. package/resources/panel/renderer.js +441 -0
  134. package/resources/panel/tray-menu.cjs +40 -0
  135. package/resources/panel/tray.png +0 -0
  136. package/scripts/_utils.py +403 -54
  137. package/scripts/annual_report.py +4 -2
  138. package/scripts/article_notes.py +423 -0
  139. package/scripts/backfill_articles.py +438 -0
  140. package/scripts/biz_daily.py +590 -105
  141. package/scripts/chat_notes.py +470 -0
  142. package/scripts/classify_daily.py +10 -18
  143. package/scripts/compile_wiki.py +1059 -46
  144. package/scripts/create_reading_notes.py +199 -14
  145. package/scripts/decide.py +253 -0
  146. package/scripts/draft_reply.py +671 -0
  147. package/scripts/export_chat_html.py +35 -33
  148. package/scripts/extract_todos.py +31 -5
  149. package/scripts/fav_notes.py +318 -0
  150. package/scripts/fix_topics.py +2 -1
  151. package/scripts/generate_ai_report.py +214 -9
  152. package/scripts/generate_html.py +18 -3
  153. package/scripts/generate_review.py +2 -2
  154. package/scripts/jev_client.py +294 -0
  155. package/scripts/jev_probe.py +440 -0
  156. package/scripts/nt_common.py +82 -0
  157. package/scripts/nt_decrypt.py +1755 -1460
  158. package/scripts/pipeline.py +27 -2
  159. package/scripts/promote_ideas.py +19 -4
  160. package/scripts/quality_eval.py +707 -0
  161. package/scripts/rag_chat.py +4 -4
  162. package/scripts/read_image.py +187 -0
  163. package/scripts/reply_debt.py +488 -0
  164. package/scripts/route_cards.py +453 -0
  165. package/scripts/semantic_search.py +208 -49
  166. package/scripts/user_notes.py +315 -0
  167. package/scripts/vault_rag.py +13 -7
  168. package/scripts/vault_search.py +21 -4
  169. package/scripts/wiki_lint.py +397 -0
  170. package/src/core/appMsgFormat.ts +117 -0
  171. package/src/core/ntCore.ts +27 -2
  172. package/src/core/sqlcipherCore.ts +5 -61
  173. package/src/core/wcdbCore.ts +6 -2
  174. package/src/panel/client.ts +49 -0
  175. package/src/panel/endpoint.ts +144 -0
  176. package/src/panel/launch.ts +101 -0
  177. package/src/panel/server.ts +424 -0
  178. package/src/panel/userId.ts +72 -0
  179. package/src/services/assistantDaemon.ts +57 -9
  180. package/src/services/assistantEval.ts +611 -0
  181. package/src/services/assistantMemory.ts +413 -57
  182. package/src/services/assistantPrivacy.ts +18 -4
  183. package/src/services/assistantRouter.ts +191 -0
  184. package/src/services/assistantService.ts +671 -74
  185. package/src/services/assistantTools.ts +1118 -57
  186. package/src/services/assistantTrace.ts +175 -0
  187. package/src/services/chatService.ts +6 -2
  188. package/src/services/configService.ts +403 -385
  189. package/src/services/pythonBridge.ts +139 -0
  190. package/src/services/syncService.ts +37 -9
  191. package/src/services/wechatMessageService.ts +73 -7
  192. package/src/utils/atomicWrite.ts +56 -0
  193. package/src/utils/dateRange.ts +46 -0
  194. package/src/utils/text.ts +20 -0
package/docs/DECISIONS.md CHANGED
@@ -294,6 +294,1430 @@ The gaps being closed - shard read failures dropped silently, media counters com
294
294
  - Still not advertised: a stable incremental cursor. D-027 stands - overlapping windows plus local
295
295
  deduplication is what this exposes.
296
296
 
297
+ ## D-030: Adapt message reads to the shard's actual columns, and refuse a window rather than approximate it
298
+
299
+ **Status:** Active
300
+
301
+ Three changes to message reads, all about a read that cannot be done as asked.
302
+
303
+ 1. The SELECT is built per shard from `PRAGMA table_info` against the six columns the reader
304
+ actually consumes (`local_id`, `server_id`, `local_type`, `real_sender_id`, `create_time`,
305
+ `message_content`). The previous SELECT named fourteen columns while `_message_dict` read six,
306
+ so a version renaming any of the other eight made every shard raise and the conversation came
307
+ back **empty with no error** unless `--report-shards` was used. A shard missing one of the six is
308
+ now read anyway, with the loss named in `missingColumns`; a shard where none of the three anchor
309
+ columns (`create_time`, `local_id`, `server_id`) survives is reported `SCHEMA_MISMATCH`.
310
+ 2. `messages` gains `--from`/`--to` (unix seconds, inclusive) pushed into SQL. A shard whose table
311
+ has no `create_time` is reported `WINDOW_UNAVAILABLE` and skipped rather than returning
312
+ out-of-range rows.
313
+ 3. `export_chat_html.fetch_messages` keeps its ten-column positional projection, but a column the
314
+ shard does not have is selected as `NULL AS <name>` instead of being dropped. The rest of that
315
+ file reads rows by index (`row[4]` is the sender, `row[5]` the time), so dropping a column would
316
+ shift every later field by one. A `--date` export on a shard without `create_time` raises rather
317
+ than widening itself to the whole conversation.
318
+
319
+ **Reason:** A read failure that is indistinguishable from "nothing here" is the worst failure mode
320
+ this codebase has had - it cost a real investigation into an allegedly truncated export. Columns
321
+ nobody reads must not be able to cause one, and in the exporter a shifted column would be worse
322
+ still: silently wrong output that no error surfaces. For the window, the caller of a windowed read is
323
+ tracking what it has covered: returning rows it did not ask for lets it record a range it never
324
+ read, and silently returning fewer rows is the same failure in the other direction. Refusing is the
325
+ only answer that stays honest, and it is visible because every in-tree window caller also passes
326
+ `shard_report`.
327
+
328
+ **Consequences:**
329
+
330
+ - `coverage: 'partial'` now means "an opened shard carries a `reason`", not "a shard reported
331
+ `READ_FAILED`". `SCHEMA_MISMATCH` and `WINDOW_UNAVAILABLE` join it: all three describe rows that
332
+ were not read. A shard that never opened still yields `unverified`, unchanged - nothing is known
333
+ about its rows, which is a different claim from knowing they were missed.
334
+ - `missingColumns` is **not** a coverage failure. The rows came back; only fields were lost. It is
335
+ reported as a `shard-columns-missing:<shard>:<columns>` warning and leaves `coverage: complete`.
336
+ Treating the two alike would make every sync on such a database report `partial` forever and never
337
+ advance `lastSuccessfulRun`.
338
+ - The window pushdown is an **optimisation, never the correctness guarantee**. `syncService` still
339
+ filters by the window itself, because the non-NT backends have no pushdown. It measured no faster
340
+ (519 ms with no window vs 514 ms from the newest message on a 925-message conversation): the cost
341
+ is process start plus 256000-round PBKDF2 per shard, not row transfer. Do not advertise it as a
342
+ speed-up.
343
+ - The `--from`/`--to` pushdown is a prerequisite for a stable cursor, not a cursor. D-027 stands.
344
+ - Unknown columns are ignored rather than selected, so a future schema needs no code change to stay
345
+ readable - but no future schema has been tested. Synthetic shards in
346
+ `test/nt_decrypt_shards_test.py` are the only evidence.
347
+ - The exporter keeps its own copy of both the shard discovery and the projection. They were **not**
348
+ unified with `nt_decrypt` here; only the failure mode was fixed on both sides. The two copies are
349
+ pinned together by a test, so the next person to change one is told about the other.
350
+
351
+ ## D-031: Judgement goes to a decision model, generation stays with the LLM
352
+
353
+ **Status:** Active
354
+
355
+ The daily pipeline's article **topic** (6-way) and **relevance** (3-level) are now decided by
356
+ TypeSafe's Jev (`POST https://api.typesafe.ai/v1/systemone`, `scripts/jev_client.py`), a model that
357
+ returns typed answers with probabilities instead of text. The LLM still **generates** the summary,
358
+ tags and concepts - that is what it is for, and Jev cannot generate at all. When no TypeSafe key is
359
+ configured, the previous "prompt for a format then parse it with two regexes" path runs unchanged.
360
+
361
+ **Reason:** the old path was not merely inaccurate, it was inert. Measured over the 2201 stored
362
+ articles, `relevance` is the default `中` in **2199** of them - and the code says why:
363
+ `biz_daily.py` assigned it on only two of five paths, the `category_hint` path hard-coded `'中'`,
364
+ and the exception and short-content paths never assigned it at all, leaving it to the writer's
365
+ `fm.get('relevance', '中')`. So `generate_ai_report.py:123`'s gate
366
+ (`if topic != FOCUS_TOPIC and relevance != '高': continue`) had only ever filtered on topic;
367
+ the "relevance" dimension had never once admitted an article. **That sentence was itself too
368
+ generous, and a later check corrected it**: the gate lived only in the markdown-scan
369
+ *fallback* loader. The primary loader (`.articles.json`, which exists on every normal run)
370
+ returned every article unfiltered, so the documented rule was not being applied at all. Topic fared little better: it is
371
+ `学术` in **zero** articles on a normal day and in **100%** of them on 2026-09-04/05.
372
+
373
+ *That last attribution was wrong, and the data said so.* It called 09-04 the signature of the
374
+ `except` branch's `topic = source_category or '学术'` fallback - but that branch would have left
375
+ `topic: 学术` and `tags: ['学术']` **in the JSON**, and 09-04's JSON has neither key populated
376
+ (`""` and `[]` for all 178 articles). Article dicts with no `topic` and no `tags` at all mean the
377
+ classification phase never ran: no API key, or `--no-ai`. The `学术` a reader sees on that day
378
+ comes from the *write* path - the grouping step's default and the md frontmatter's `tags`
379
+ default - which is precisely the divergence the follow-up pass fixed (see below).
380
+
381
+ A 60-article comparison was run before switching (`scripts/jev_probe.py`, stratified across topics
382
+ and days, sent state = title + body only): agreement with the stored labels was 58.6%, and the
383
+ disagreements ran **against** the stored labels almost everywhere - `习近平向第八届中俄能源商务论坛
384
+ 致贺信` stored as 学术 against Jev's 政治, a `人民日报·夜读` cooking essay stored as 政治 against
385
+ Jev's 文学 (the body was read to confirm), a `Nature Climate Change` paper stored as AI against
386
+ Jev's 学术. **This is not an accuracy measurement** - the labels are the DeepSeek output, not a gold
387
+ standard - which is exactly why the switch is reversible and why the raw score is kept.
388
+
389
+ **Consequences:**
390
+
391
+ - `--classifier {auto,llm,jev}` (default `auto`) keeps one-flag rollback and lets anyone re-run a
392
+ date both ways. `auto` means "Jev if a key is configured, otherwise the old path".
393
+ - **The LLM prompt is deliberately unchanged.** It still asks for `【主题】`/`【相关度】` that we now
394
+ ignore. That keeps the fallback *byte-identical to the old behaviour* rather than a new, worse
395
+ fallback; the cost is roughly 20 wasted output tokens per article. Slimming the prompt is a
396
+ follow-up, not this change.
397
+ - Two **additive** frontmatter keys: `relevanceScore` (raw) and `topicConfidence` (0-1). Downstream
398
+ compares `relevance` as a literal string and ignores unknown keys. Without these the probability -
399
+ the entire new information - would be discarded at the write step.
400
+ - The three-level cut points (`<0.5` 低, `<1.5` 中, else 高) are **provisional and uncalibrated**.
401
+ They are derived from the zero-indexed score scale, and the raw score is stored so recalibrating
402
+ does not require re-running a day's report.
403
+ - **Classification runs before the generation loop, concurrently.** The two stages have no
404
+ dependency in either direction, so their ordering was only ever historical. Measured: 12
405
+ real articles in 3.3s at 6 workers against ~12s one at a time. The dependency that *does*
406
+ exist is positional - `decisions[k]` must belong to `articles[k]` - so the eligibility test
407
+ in the concurrent stage is kept character-for-character identical to the loop's, and any
408
+ article that fails still occupies its own key with a `None` rather than being absent (an
409
+ absent key would let the loop treat a neighbour's judgement as its own). Concurrency is
410
+ kept at 6 deliberately: the service is new enough to return `529` under load, and raising
411
+ the fan-out buys retries rather than throughput.
412
+ - The client is **fail-loud** (`JevError` with the HTTP status and a 400-character truncated body,
413
+ never the key); the caller is **fail-soft** (per article, printing a WARN and falling back). One
414
+ unclassifiable article must not abort a day's report - but a client must not disguise a failure
415
+ as a plausible-looking answer either.
416
+ - **New data egress**: article titles and bodies now also go to `api.typesafe.ai`. Article text
417
+ already went to DeepSeek for summarisation, so this is a new vendor rather than a new category,
418
+ but it is a vendor that did not exist before 2026-09-15. Configuring `typesafeApiKey` is the
419
+ opt-in; `daily --no-ai` remains the way to keep everything local.
420
+ - The key is a first-class config field (`typesafeApiKey`, in `ENCRYPTED_KEYS`, settable via
421
+ `config set`), and Python reads it through `_utils.get_typesafe_key()`. That function returns an
422
+ empty string rather than raising when the ciphertext cannot be decrypted - `configService`'s
423
+ `lockDecrypt()` silently returns `''` in the same situation, and a config copied from another
424
+ machine must degrade to the old path rather than crash the daily run.
425
+ - **The taxonomy is one table, and both paths read it.** `TOPICS` had been copied into six
426
+ files and `TOPIC_CRITERIA` lived separately inside `jev_client`; both now sit in `_utils` and
427
+ every consumer imports them. The trigger was a concrete contradiction: `TOPIC_PROMPT`
428
+ generated its "must be one of" line from `TOPICS` (six categories, including 政治) while
429
+ hardcoding **five** in its two reminders and omitting 政治 from the judging rules - a category
430
+ holding 26% of the corpus had no definition, in a prompt whose whole job is to be strict about
431
+ the list. Prose enumerations drift; a table does not. The criteria table is shared rather than
432
+ mirrored precisely so that "falling back to the LLM path" means judging by the same standards
433
+ it always did. Pinned by identity in `test/topic_taxonomy_test.py`, because a copy that happens
434
+ to be correct passes an equality check.
435
+ - **The report's admission question is now asked directly.** `worth_including` (a `noul`) rides
436
+ along in the same request - measured at 0.91s for 12 questions versus 0.84s for 2, with `state`
437
+ dominating the tokens, so the marginal question is essentially free - and lands in frontmatter as
438
+ `includeScore`. The gate used to be `relevance != '高'`, which asks "how useful is this to the
439
+ reader" and then reads the answer as "put it in today's report". Those are different questions and
440
+ no threshold tuning can reconcile them. The two scores do diverge in practice: an award
441
+ announcement scored `relevance` 0.79 but `includeScore` 0.03 (related to the field, nothing to
442
+ use today), and two engineering posts landed at 0.48/0.63 - close enough to the cut point to show
443
+ the probability is not saturated at the ends.
444
+ - **Both loaders now share one predicate** (`admits()`), with `--include-all` to revert to
445
+ collecting everything. This is a real behaviour change on the primary path: the report will
446
+ contain fewer non-focus articles than before, because before it contained *all* of them. Articles
447
+ written before `includeScore` existed fall back to the old `relevance == '高'` rule, so
448
+ regenerating an old date does not silently swap its article set.
449
+ - An unused question is worse than no question. `is_research_paper` was added in the first cut of
450
+ this change and never read by anything - the same "compute it and throw it away" shape as the
451
+ exporter's old `COVER_STATE` counters. It was removed and replaced by `worth_including`.
452
+ - **Not done, deliberately**: the other eight "ask the LLM then parse the text" call sites (assistant
453
+ tool routing, long-term memory extraction, todo urgency, monthly-report task detection, ...).
454
+ That group was never measured on Chinese, so switching it would be a bet rather than a change.
455
+ - **Superseded by later work, kept so the reasoning stays readable**: this decision listed `tags` and
456
+ "`TOPICS` duplicated across five files" as deliberately-not-done. `TOPICS` and `TOPIC_CRITERIA` are
457
+ now single-sourced in `_utils` (the prompt path and the decision-model path read one table), and
458
+ `test/topic_taxonomy_test.py` pins every consumer by identity. The four `TOPIC_ORDER` copies in
459
+ `auto_tag` / `create_reading_notes` / `enrich_backlinks` / `generate_html` remain, because two of
460
+ those files have no `_utils` import edge and whether an import resolves would then depend on the
461
+ caller's working directory - so a test asserts the four copies still equal `TOPICS` instead, turning
462
+ a silent future drift (adding a seventh category would drop a section from the report) into a
463
+ failure.
464
+ - **A follow-up pass found the root cause behind the 2199-of-2201 number and fixed it.** The default
465
+ was not merely applied too often - it was applied **inconsistently by the two writers**: the JSON
466
+ writer defaulted a missing topic to `''` and the grouping step that names the folder and writes the
467
+ md frontmatter defaulted it to `学术`, seven lines apart, so a single run could emit `topic: 学术` in
468
+ the md and `topic: ""` in the JSON with nothing reported either way. The 2026-09-04 output is that
469
+ failure in full (178 articles, all under `学术/` in the md, all `""` in the JSON, two of them
470
+ "OpenAI 深夜发布 GPT-6 Astra"). The reachable trigger is ordinary: `daily --no-ai`, or a run with
471
+ no API key, skips classification entirely, leaving every article without a `topic` key while the
472
+ write phase still runs. Both writers now read one normalised value (`_utils.DEFAULT_TOPIC`, applied
473
+ by `biz_daily._group_by_topic`), the check is **membership in `TOPICS`** rather than emptiness, and
474
+ the run prints how many articles fell back so a whole-batch fallback cannot read as a normal
475
+ classification. The same shape sat in `tags` and was fixed the same way.
476
+
477
+ **The `tags` question this decision left open is now answered, and the answer is "not a bug".**
478
+ Measured over the 1584 articles across the 11 stored days: 1396 carry exactly `[topic]`, 182 carry
479
+ none, and **6 carry real tags** - and those 6 are the whole corpus built without configured source
480
+ categories. The mechanism is deliberate, in the prompt: when a source has a configured category,
481
+ `biz_daily` sends a summary-only prompt that says *不要输出主题、标签、相关度或概念字段* and sets
482
+ `tags = [category]` itself. Every source in those runs had a configured category, so no article
483
+ ever reached the path that asks for tags. The signature is unambiguous and checkable: **on all 11
484
+ days every source maps to exactly one topic** (36/40/15/43/41/27/35/36/3/36/6 sources, zero
485
+ exceptions) - which is what topic-from-config looks like and per-article classification does not.
486
+ A live call through the production prompt on one stored article returns
487
+ `AI与数学, 人类数学家, 数学共同体, 人机关系, 学术人文`, so the extraction works whenever that path is
488
+ used. Two consequences worth keeping: real tags appear only for sources *without* a configured
489
+ category, and the earlier "tags never persist" reading was wrong - it described the
490
+ configured-category path, not a defect.
491
+ - **Contract hardening, added later and verified against the live API** rather than
492
+ against a source reading. Two capabilities this client was not using: `instructions`
493
+ accepts a **structured object** (`{"goal": …, "rules": […]}`) as well as a string, and
494
+ every response carries a **`model` field naming the served version** (`jev-1.13.0`) as
495
+ against the requested alias (`jev-latest`). The served name is now recorded
496
+ (`usage['model']`, `JevClient.last_model`, and a `decisionModel` key in the daily's
497
+ `.articles.json`) because an alias can drift while every result still looks correct -
498
+ the same shape as the bug this decision's own history keeps producing: a plausible
499
+ value that is silently not the one you think. Every `choice` answer is now validated
500
+ before use (probabilities present, key set equal to the criteria, values in `[0,1]`,
501
+ sum ≈ 1, `choice` == argmax); the argmax rule is the load-bearing one, since a
502
+ non-argmax choice looks exactly like a normal answer. The daily's prompts stay as
503
+ strings: switching them to the object form would change model behaviour, and the
504
+ existing cut points were calibrated against the string form.
505
+
506
+ ## D-032: Use the decision model as a reranker - one request per pass
507
+
508
+ **Status:** Active
509
+
510
+ `semantic_search.rerank()` takes the top 20 hits from the first-stage search, asks one
511
+ `noul` question per candidate in a **single** request, and reorders by the returned
512
+ probabilities. `search` calls it before slicing to `top_k`; `--no-rerank` restores the
513
+ previous behaviour exactly.
514
+
515
+ **Reason:** the retrieval stage had no second pass at all. `search()` scored by cosine
516
+ similarity and took `argsort[:top_k]` - one line, no reranking. Embeddings answer "is this
517
+ semantically near the query", not "does this actually answer it", and the keyword fallback
518
+ (used whenever embeddings are unavailable or return zero vectors) answers something cruder
519
+ still. Reranking is the purest form of what this model does, and the cost model makes it
520
+ viable: measured 0.84s for 2 questions against 0.91s for 12, since `state` dominates the
521
+ token count. **One pass over 20 candidates is ~1 second, not 20 round trips.**
522
+
523
+ **Consequences:**
524
+
525
+ - **Numbering is the hazard.** If question `c3` gets answered against candidate `c5`, the
526
+ most relevant hit sinks to the bottom and the result looks like an ordinary "the model
527
+ thought it was irrelevant" outcome. Nothing errors. So every candidate carries an
528
+ explicit `【候选k】` label in the `state`, and the request also asks a `choice` for
529
+ "which candidate best answers this" purely as a **cross-check**: if the single choice
530
+ disagrees with the argmax of the per-candidate scores, a warning is printed. The check
531
+ only warns - it does not reorder, because one wrong answer should not be able to swap
532
+ the whole list.
533
+ - Verified live before shipping: the relevant article was planted at position 4 of 8 and
534
+ came out first at 0.94 against 0.01-0.04 for the rest, with the self-check agreeing.
535
+ - `score` keeps its meaning (cosine similarity or keyword count) and the new value goes to
536
+ `rerankScore` - the same additive-key rule as `relevanceScore` and `includeScore`.
537
+ - A candidate the model did not score is ordered **after** the scored ones and carries no
538
+ `rerankScore`: "unknown" and "irrelevant" are different answers, and collapsing them
539
+ would silently reorder on a partial response.
540
+ - Fail-soft: no client, fewer than two candidates, or any error returns the original order.
541
+ Reranking is an improvement to search, not a precondition for it.
542
+ - **Transient failures are retried.** The client got a live `HTTP 529 system_overloaded`
543
+ while this was being built; TypeSafe's own reference implementation retries transient
544
+ provider failures twice by default. `JevClient` now retries 429/5xx/529 with a
545
+ deliberately short backoff (worst case ~2.4s), because the daily pipeline calls it once
546
+ per article and a long backoff would stretch an already overloaded run into tens of
547
+ minutes - time the caller should be spending on its fallback path.
548
+
549
+ ## D-033: Every machine judgement shown to a person carries its evidence
550
+
551
+ **Status:** Active
552
+
553
+ `scripts/reply_debt.py` asks, once per conversation, whether the thread is sitting on a reply
554
+ the user owes (`waiting`), how urgent it is, whether a promise is outstanding, whether money or
555
+ delivery is involved, and what kind of conversation it is. Results are ranked and printed with
556
+ their probabilities. Two rules came out of building it, and both are general:
557
+
558
+ 1. **A message with no text must be labelled, not left blank.** WeChat stores images, voice
559
+ notes, videos and stickers with an empty `message_content`. Unlabelled, they entered the
560
+ state as a line reading `对方:` with nothing after it - and the model returned a confident
561
+ `0.48` for "is this person waiting on me" **from an empty line**. Every such message now
562
+ carries its type (`[图片]` / `[语音]` / `[非文本 localType=N]`). Measured effect: that same
563
+ conversation dropped to `0.40` and moved out of the result set. A model asked to judge
564
+ nothing will still answer; supplying the type is what stops it.
565
+ 2. **A judgement shown to a human must carry how thin its evidence was.** "Waiting 0.69" derived
566
+ from a two-character last message is not the same claim as one derived from a full
567
+ explanation, and the model cannot tell the difference - it only sees text. So each row prints
568
+ `证据:对方末条 N 字 · 对方实质发言 M 条`, and anything resting on fewer than five characters
569
+ is marked as too thin to act on.
570
+
571
+ **The report now states its own uncertainty.** `generate_ai_report.py` appends a `我拿不准的`
572
+ section listing entries whose `includeScore` sits inside the band (both admitted and excluded)
573
+ plus those whose `topicConfidence` is low enough that the section they were filed under may be
574
+ wrong. It is computed locally from frontmatter - asking a model to describe its own uncertainty
575
+ is asking it to generate more prose, which is the failure this whole line of work exists to
576
+ avoid. When the articles predate the probability fields, the section says so explicitly: an
577
+ absent section must not be readable as "everything here was certain".
578
+
579
+ **Measured prevalence, which is what the threshold actually trades against.** Over a
580
+ topic-balanced 160-article candidate pool: **86.1%** of articles score below 0.2 on
581
+ `includeScore`, 4.4% in 0.20-0.35, 0.6% in 0.35-0.50, and **8.9% at or above 0.50** (3.8% +
582
+ 1.9% + 3.2%). So the 0.5 threshold admits roughly one article in eleven of a balanced mix -
583
+ and the real daily mix is 新闻-heavy, which scores low, so the true figure is likely lower.
584
+ An earlier guess in this document ("about 3%") was not derived from anything; this one is.
585
+ The sampler also had to change to produce a usable curve: sampling by topic alone yielded a
586
+ 50-article sample in which 45 items sat below 0.2, so labelling 50 bought the information of
587
+ labelling 4. It now draws from a larger scored pool, stratified **by probability band**, which
588
+ put 22 of 50 in the bands that matter - and reports the pool's band shares separately, because
589
+ a band-stratified sample is not a natural distribution and must not be read as one.
590
+
591
+ **How the "no gold standard" gap finally gets closed.** `scripts/quality_eval.py sample`
592
+ draws a stratified sample and scores each article through the **production** path at sample
593
+ time, then writes blank `topic` / `include` fields for a person to fill in; `score` prints
594
+ topic agreement (for Jev *and* for the stored labels), a calibration table by probability
595
+ bucket, and a threshold sweep naming the cut point that best matches the human. Two design
596
+ points are load-bearing:
597
+
598
+ - **The sample is re-scored instead of reusing stored values.** No real artifact on disk
599
+ carries `includeScore` - the six files in the 2026-09-05 output directory were written by a
600
+ run of the code from *before* that field existed, so they have `relevanceScore` and
601
+ `topicConfidence` only. And the stored `topic` is the very thing under test: it labelled 364
602
+ articles as 学术 in one day. So the (probability, human label) pairs have to be produced
603
+ fresh, which also means the corpus never has to be re-run and `output/` is only read.
604
+ - **A blank label is "not sure", not "wrong".** Items left `null` are excluded from every
605
+ number rather than counted as misses, because collapsing the two would make accuracy look
606
+ worse the more honest the labeller is.
607
+
608
+ **A field written on one path and not the other is a field that does not exist.** The three
609
+ probability fields were added to the markdown frontmatter but not to `.articles.json`, and the
610
+ report loader prefers the JSON. The new section therefore worked in the fallback path and was
611
+ dead on the primary one - which is how it was found, by running a real dry-run and watching it
612
+ report "no probability fields" while the frontmatter plainly carried them. The same shape has
613
+ now appeared five times in this codebase: two loaders, two shard-discovery copies, argv-vs-env
614
+ secrets, the admission gate living only in the fallback, and now serialization. **When adding a
615
+ field whose value is consumed by another script, add it to every representation and test the one
616
+ the consumer actually reads.**
617
+
618
+ **Measured: what you feed sets the ceiling, not the model.** The same 132 questions (44 scripts x
619
+ does-it-write / does-it-network / does-it-spawn) were asked twice, changing only the evidence in
620
+ the state. Given each file's first 14 lines: **77.3%** agreement with a regex baseline. Given its
621
+ first 1500 characters: **88.6%**, with subprocess at **100%** and file-writing at 97.7%, for 2.3s
622
+ and $0.0011. The ceiling moved 11 points without touching a single question.
623
+
624
+ **And the disagreements are not automatically the model's fault.** Of the remaining `network`
625
+ mismatches, five of the six checked by hand call `call_ai` / `create_engine`, which reaches
626
+ `urllib.request.urlopen` inside `_utils` - so those scripts *do* egress and the model was right
627
+ while the regex baseline (which only looked inside each file) was wrong. **Two imperfect
628
+ instruments agreeing 88.6% of the time is not an accuracy figure for either of them.** Treating
629
+ the baseline as truth would have produced a confidently wrong conclusion about which instrument
630
+ to trust.
631
+
632
+ **Also:** `waiting` has one mechanically checkable failure mode - claiming the other side is
633
+ waiting while the last message in the transcript is the user's own. That contradiction is
634
+ detected and reported. It is the only part of this output that can be falsified without reading
635
+ the messages by hand, which is exactly why it is worth checking.
636
+
637
+ **Honest limitations:** there is no gold standard, so every number is a prompt, not a fact. On
638
+ the machine this was developed on the tool reports **two** candidates at 0.54 and 0.62 out of 25
639
+ recent conversations, with nine more between 0.30 and 0.45 marked uncertain and three filtered
640
+ out as customer-service or marketing - a modest result, and the right one for someone who
641
+ answers promptly. It is not evidence that the model would be accurate on someone else's data.
642
+ Debt age is measured from the **other side's** last message, not the conversation's last
643
+ activity: if the user replied most recently the debt is zero, and using session activity would
644
+ have flattened exactly the case worth surfacing.
645
+
646
+ ## D-034: Expose the decision model as a local primitive, but not over MCP
647
+
648
+ **Status:** Active
649
+
650
+ `weflow-cli decide --request <file>` (and `scripts/decide.py`, which also reads stdin) takes a
651
+ caller-supplied `{state, questions}` and returns `{answers, usage, costUsd}` from one decision
652
+ request. The command reads **no local data of its own** - the entire state is whatever the
653
+ caller hands it.
654
+
655
+ **Reason:** the value is not that this judges better than the caller's own model; it is that a
656
+ batch of judgements becomes affordable. Measured: ~1s for a request regardless of whether it
657
+ carries 2 questions or 12, since `state` dominates the token count, and 20 candidates cost
658
+ around two ten-thousandths of a cent more than 1. **That ~1s is an idle-service figure.** It is
659
+ independent of state size (200 characters and 4000 characters both measured ~1.1s), but not of
660
+ concurrency: a 158-article pass at 6 workers averaged **5.7s per call**, with a 10.1s outlier.
661
+ The cost model is still what makes batch judging viable - it just means wall-clock for a large
662
+ batch should be estimated at seconds per call, not at one. So "label 200 items across six dimensions"
663
+ stops being a token-budget decision. Two further properties come from it being non-generative:
664
+ the answers arrive **typed with probabilities** rather than as prose to be parsed, and nothing
665
+ is being *written*, so it is safe to place inside control flow where generated text would be
666
+ unwanted.
667
+
668
+ **Consequences:**
669
+
670
+ - **Batch mode exists because the primitive was unusable without it.** Judging 44 files across
671
+ 3 questions by hand meant writing 132 question blocks; the first real use of `decide` was a
672
+ throwaway loop that built them. `--over <glob> --ask <text>` now does that expansion, and the
673
+ response carries a `batch` mapping from question name to file and question, so a caller never
674
+ has to parse names - file names contain spaces, `|` and CJK punctuation, and encoding them into
675
+ question names would make every reader a string-parsing exercise.
676
+ - **The evidence size is an argument, and it is reported back.** `--max-chars` defaults to 1500
677
+ and the response echoes it, because the measurement above says the evidence you feed sets the
678
+ ceiling. A tool that decides that number silently would invite the conclusion that its output
679
+ means more than it does.
680
+ - **Requests are validated locally.** The service returns `422` for a malformed request, but
681
+ that error can only say which field is invalid - not what the caller meant. Local validation
682
+ names the intent: "a `score` needs at least two ordered levels", "a `choice` needs a non-empty
683
+ criteria object", "a `score` with one level is a constant zero and carries no information".
684
+ - **Deliberately not exposed over MCP this round.** An MCP client - explicitly a separate trust
685
+ boundary per D-002 - would be able to drive local outbound calls carrying arbitrary text of
686
+ its choosing. That is a new egress surface and it needs its own decision, not a side effect of
687
+ adding a convenience tool. `capabilities --json` records `mcpExposed: false` with the reason,
688
+ so the omission is visible rather than inferred.
689
+ - Follows the `search`/`awaiting` discipline: `--dry-run` validates and echoes the shape with
690
+ **no egress**, `--yes` runs, and neither given means confirm. `--dry-run` needs no key, on the
691
+ same reasoning as `awaiting`: a preview exists to answer "is this worth spending?".
692
+ - Honest limitation: this is a *different* model, not a strictly better one, and there is no gold
693
+ standard behind its numbers. For a single one-off judgement the caller's own model is fine and
694
+ this is just an extra hop. It pays off at scale, where the alternative is many calls and prose
695
+ to parse.
696
+
697
+ ## D-035: The assistant's single-round fast path: default-off, and how it got there
698
+
699
+ **Status:** Active (shipped 2026-09-22 behind a default-off switch; see the update at the end)
700
+
701
+ The assistant's ReAct loop costs two LLM round-trips for the common "answer from local data"
702
+ question: round 1 picks a tool and its arguments, round 2 phrases the reply from the result. A
703
+ decision-model pre-router could resolve `{needs_tool, tool, closed-set arguments}` in one ~1s
704
+ call before the loop, execute that tool, and let the very first `callLLM` see the result - turning
705
+ two round-trips into one. Measured elsewhere in this repo, the call model supports it: 12
706
+ questions cost 0.91s against 0.84s for two, so the routing decision is nearly free.
707
+
708
+ **Why it is not implemented here:** `handleMessage` is a privacy-audited, daemon-resident path.
709
+ It carries the access-control gate, `privacyGate.audit` on every tool call, the daily limit, and
710
+ three-tier memory - and it can only be exercised end to end against a **live** WeChat channel
711
+ (bind by QR, whitelist a sender, run the daemon). None of that is reproducible in the environment
712
+ this was built in, so the change would have shipped unverified into a path that handles the user's
713
+ real messages.
714
+
715
+ The failure mode is not a crash, which is what makes it worth writing down: if the router picks
716
+ the wrong tool, the model receives a result that does not answer the question and will phrase a
717
+ **confident** reply around it. That is worse than being slow.
718
+
719
+ **What it would take to land it safely:**
720
+
721
+ - an opt-in switch on the model of `--classifier` / `--no-rerank` / `--include-all`, **default
722
+ off**, so live behaviour is unchanged until someone watches it;
723
+ - a routing-confidence threshold that falls back to the normal loop, plus an assertion that the
724
+ fallback path is bit-identical to today's behaviour;
725
+ - a synthetic harness that drives `handleMessage` with a stubbed `callLLM` and a stubbed tool
726
+ executor, covering: routing correct, routing wrong (must degrade), routing uncertain (must
727
+ degrade), and **no tool dispatched without the audit line** - the last one is the security
728
+ property that must not regress;
729
+ - a way to observe real traffic before trusting it, e.g. run it in log-only mode ("would have
730
+ routed to X") alongside the normal loop for a week and compare.
731
+
732
+ **Until then** the loop stays as it is. One extra LLM round-trip is not worth an unverifiable
733
+ change to the path that mediates the user's messages.
734
+
735
+ **A reference implementation exists, and one of its tricks is directly transferable.** The
736
+ `browser-use` × TypeSafe demo repo (`jev_ultrafast`, MIT, ~1950 lines) ships this pattern in the
737
+ browser domain and publishes its measurements. Its key design point is that a **speculative
738
+ second question cannot see the first answer** - model calls inside one request are mutually
739
+ blind - so the target-selection question has to **state the operation it is assuming** in its own
740
+ instructions ("assuming the operation is TYPE_TEXT, which element is the target"). Each operation's
741
+ target is computed anyway and only the matching one is consumed, which is how "two decisions, one
742
+ round trip" works. That is the same move as writing the judging rule into the criteria text, one
743
+ level up: not just multiple questions, but questions with a **dependency** spelled out.
744
+
745
+ Its numbers are worth having as a shape rather than a baseline: 17 model requests for one flight
746
+ search, 90,558 input / 6,325 output tokens, 178 ms median latency, and the browser-protocol call
747
+ count dropping 1092 → 101 when one full snapshot replaced hundreds of round trips. It also states
748
+ the boundary this decision already assumed: **DONE is not evidence of success** - their version
749
+ keeps an independent post-check, and a failed development attempt (8.697 s) is recorded rather
750
+ than dropped.
751
+
752
+ **Its browser path is not portable to this repo's scenarios**, and the design doc says why:
753
+ shadow roots, iframes, canvas, file uploads, new tabs and nested scrolling are unsupported - which
754
+ is most of what the official-account console, n8n and conference-site work consists of. It also
755
+ drives its own stack (browser-harness + CDP + a reused Chrome profile), separate from whatever
756
+ browser automation this repo has. The transferable parts are the **question organisation** above
757
+ and the discipline of publishing measurement boundaries with the numbers, not the transport.
758
+
759
+ ### Update 2026-09-22: shipped behind a default-off switch
760
+
761
+ The four prerequisites above are met, so the fast path now exists in
762
+ `src/services/assistantRouter.ts` and is wired into `handleMessage`. It is **off by
763
+ default**: `assistantFastRoute` accepts `off` (default, byte-identical to before), `log`
764
+ (decide, record "would have routed to X", dispatch nothing) and `on`.
765
+
766
+ What changed versus the deferred design:
767
+
768
+ - **Only tools with closed or empty arguments are routable.** The design assumed
769
+ `{tool, arguments}` could be resolved in one call; the arguments cannot, because a
770
+ `choice` can only pick from a closed set and "which contact", "which keyword" are free
771
+ text. Nine capabilities over six tools (`list_sessions`, `get_stats`, `get_daily_report`,
772
+ `get_sns` ×2, `get_weread` ×2, `get_todos` ×2) carry their arguments inside the option
773
+ text; everything else falls back. Passing the user's whole message as the argument would
774
+ have manufactured the exact failure this decision was written to avoid.
775
+ - The routing question is a `noul` ("must this be answered from local data?") plus a
776
+ `choice` over those nine capabilities **plus `none`**, whose criterion covers both "no
777
+ local data needed" and "local data needed, but not by any of these ways" - without the
778
+ second half the model is forced to pick among nine wrong options.
779
+ - **Every uncertain exit falls back**, and falling back *is* today's behaviour, not a
780
+ weaker new one: judge failure, non-numeric probability, sub-threshold confidence, an
781
+ unknown capability name. `asProbability` refuses booleans (`Number(true)` is `1`, so a
782
+ `noul: true` would have routed *certainly* - the same misread as the earlier client,
783
+ in the opposite direction).
784
+ - The dispatch and audit are now **one implementation** (`runToolCall`) shared by the loop
785
+ and the fast path, because "no tool dispatched without the audit line" is exactly the
786
+ kind of invariant that rots when written twice.
787
+
788
+ **Measured** (`scripts/assistant_route_probe.ts`, 17 representative questions, real
789
+ decision layer, 2026-09-22, free period): 15 matched the expectation I wrote in the probe,
790
+ **0 routed to a wrong capability**, 10 routed concretely, ~1.33 s per decision (two
791
+ outliers at 2.6 s and 4.2 s). The two mismatches fell back rather than routing: 我明天该干嘛
792
+ (needs_tool 0.40) and 最近有没有什么书可以读 (0.17) - and for the second one the *probe's*
793
+ expectation is the arguable one, since it asks for a recommendation rather than for what is
794
+ being read. The expectations are mine, not a human-labelled gold standard.
795
+
796
+ **Still not done, deliberately:** the log-only week on real traffic. That needs the
797
+ daemon running against a live channel, which is the user's to start; `log` mode exists for
798
+ exactly that. Free-text-argument tools remain unroutable until something can select a
799
+ value rather than a label - the same job `route_cards.py` does for conversation search.
800
+
801
+ ### Update 2026-09-22 (later): a contradiction check, because the cause could not be found
802
+
803
+ Two live answers claimed a lookup had been made and the bodies masked, while the audit read `tools=0`.
804
+ Forty real calls across four prompt variants (current prompt, the conditional-only variant, a window
805
+ seeded with the model's own earlier "blocked" answers, and the complete pre-change prompt) **called the
806
+ tool 40/40 times**, so the first explanation written here - that the prompt's conditional was read as the
807
+ current state - is wrong, and the cause of those two turns is unknown.
808
+
809
+ What ships instead is a guard that does not depend on knowing the cause: when the router says the message
810
+ needs local data and the turn produced no tool call, the model is told it holds no tool result and the loop
811
+ runs once more. That adds one switch whose semantics differ from `assistantFastRoute`: the guard is armed
812
+ whenever the router ran, **including `log`**, because `log`'s promise is about routing rather than about
813
+ safety, and an observation period is when a fabricated lookup is most likely to be noticed. `off` remains
814
+ bit-identical because the router never runs and there is no signal to check.
815
+
816
+ ## D-036: Search your own conversations with the decision model selecting query terms, not ranking sessions
817
+
818
+ **Status:** Active
819
+
820
+ `scripts/route_cards.py` answers "which conversation was I talking about X in". Two stages:
821
+ the decision model picks the real query words out of the candidate n-grams the question
822
+ produces, and **ranking is local**, by how many messages in each session literally contain
823
+ those words. Retrieval reads WeChat's own `message_fts.db`.
824
+
825
+ **Reason:** the obvious design - one card per conversation, ask the model which are
826
+ relevant - **was measured and does not work**. As a per-card `noul` every candidate scored
827
+ 0.50-0.51, putting a one-message coupon group in the same band as the conference group with
828
+ 213 hits and leaving out the session most worth opening; as a per-card `score` (0/1/2) all
829
+ 20 candidates scored 1.74-1.76 whether they had 247 hits or 8. Local sorting by hit count
830
+ ranked the same data correctly. The card carries too little for the judgement being asked -
831
+ the model has never seen the conversation - so the shipped split gives ranking to the
832
+ objective local signal and leaves the model the job it demonstrably does well: choosing
833
+ words from a closed candidate list (it kept 会议 at 0.77 and rejected seven fragments at
834
+ 0.11-0.40, consistently across runs). That is also the boundary of what it can do at all -
835
+ it cannot generate the search terms, only select them.
836
+
837
+ **Consequences and boundaries:**
838
+ - **New egress surface, deliberately small**: only the candidate words and the user's
839
+ question leave the machine. No message text, and - unlike the first design - no
840
+ conversation names, counts or timestamps either, since the model no longer sees cards.
841
+ `--keyword` skips the model entirely and keeps the whole path local.
842
+ - Ranking by literal match is the known limit: "聊过上线的事" will not find a conversation
843
+ that says 部署. That is what the embedding path (`search`) is for; the two are
844
+ complementary recall paths, and the tool says so instead of pretending to have found
845
+ nothing relevant.
846
+ - The article-favourite reading of `message_fts.db` is the enabling fact: `acontent` is
847
+ plaintext and `session_id` is the rowid of that database's own `name2id` table (595
848
+ sessions), so retrieval needs no index of our own. `MATCH` is unusable
849
+ (`no such tokenizer: MMFtsTokenizer`), but `LIKE` over 60k rows is instant.
850
+ - A `noul` answer is a **probability float**, not a boolean (measured: 0.98 true, 0.01
851
+ false). Reading it as a boolean makes every question look answered "no" while the output
852
+ blames the model - this happened, and the tool now separates "the answer shape changed"
853
+ from "the score is low" in what it prints.
854
+
855
+ ## D-037: Topic exclusion lives in the display layer, not in front of the fetch
856
+
857
+ **Status:** Active
858
+
859
+ `dailyExcludeTopics` / `--exclude-topics` stop a topic from appearing in the daily report and
860
+ the reader page. Articles are still fetched, judged and archived; the topic is not shown. The
861
+ focus topic (AI) cannot be excluded - asking for it prints a warning and ignores that entry.
862
+
863
+ **Reason:** the feature was asked for as "decide before fetching whether I want this article".
864
+ That was measured and rejected: from a source name, a title and the platform digest - all that
865
+ exists before a fetch - the judgement agreed with the source-level configuration on **60%** of
866
+ 217 articles, and where it disagreed it was mostly wrong in the direction that drops articles
867
+ worth keeping. There is no gold standard either way, so "the model said 新闻" cannot justify a
868
+ dropped fetch. The asymmetry decides it: an unwanted article costs a little space and attention,
869
+ while a wrongly skipped one is **gone** - it was never archived, and re-fetching a 公众号 page
870
+ weeks later is not the same article. The display layer also buys a cheaper correction path:
871
+ changing your mind is a regeneration, not a refetch.
872
+
873
+ **Consequences and boundaries:**
874
+ - Exclusion is **orthogonal**: it is checked before `--include-all` and before the inclusion
875
+ score, so no flag combination brings an excluded topic back. All three lists in **我拿不准的**
876
+ (near-threshold admitted, near-threshold not admitted, low topic confidence) are filtered too.
877
+ This is not cosmetic: the "not admitted" list is populated by exactly the articles being
878
+ excluded, so passing the exclusion set to `admits()` there does the opposite and makes them
879
+ *more* likely to be listed. One predicate (`is_excluded_topic`) serves both directions.
880
+ - The focus topic is protected because excluding it does not produce an empty report, it
881
+ produces a misleading one: with nothing left, the report exits with "未找到文章,请先运行
882
+ biz_daily.py" and blames the wrong thing. Unknown names and refused names both print a WARN;
883
+ silently ignoring either would make an ineffective filter look like a working one.
884
+ - Both views take the same set, and `generate_html.collect_articles` skips an excluded topic
885
+ directory outright rather than filtering after the scan, so the page and the report cannot
886
+ disagree about what was excluded.
887
+ - The key is registered in all four `configService` literals plus `bin`'s `configurableKeys`;
888
+ `test/config-keys.test.ts` asserts every key in that list is declared, defaulted, reset and
889
+ read back.
890
+
891
+ ## D-038: The source-level prior is recorded from real judgements, and acts on nothing
892
+
893
+ **Status:** Active
894
+
895
+ Every daily run appends `(source, topic)` counts for the articles it archived to
896
+ `~/.weflow-cli/source_topics.json`. The run reports which sources are now stable and which of
897
+ them publish a topic you exclude. It does **not** skip anything.
898
+
899
+ **Reason:** the per-article judgement D-037 rejected is unreliable because its input is thin.
900
+ There is a second signal, but only after the fact: every archived article has a topic decided
901
+ from its **body**, so a source's history answers what a title cannot. That table has to be
902
+ measured rather than authored - a hand-written source-to-topic map would be a guess wearing a
903
+ table's clothes, and D-037 exists because that guess was already measured at 60%. Recording is
904
+ therefore a prerequisite for ever making the skip decision, not the decision itself.
905
+
906
+ **Consequences and boundaries:**
907
+ - Counts are **append-only**: cumulative counts are the only evidence for "this account is
908
+ stable", and pruning them destroys the basis for a later decision. A failed write reports the
909
+ OS error and leaves the run alone - the table is auxiliary data and must not fail a day's
910
+ daily - but it does report, since a silent failure looks exactly like a table that is growing.
911
+ - Only articles that landed on disk are recorded (matched against `written_urls`), so the table
912
+ and `output/` describe the same corpus and can be reconciled later. This was a real trap:
913
+ `topic_groups` is rebuilt for the briefing as topic-to-strings before the end of the run, so
914
+ recording from it produces an empty table.
915
+ - The file holds 公众号 names, so it lives outside the repository; a test asserts the path is
916
+ under the home directory and not under the repo. It follows `CONFIG_PATH` at call time, so
917
+ tests - and any future multi-profile setup - can point it at a temporary directory.
918
+ - `stable_source_topic` returns `None` for "not enough history yet" as distinct from "no topic".
919
+ The distinction is the point: `None` read as a default topic would skip a source precisely
920
+ because it is unknown, which is the worst failure this feature has.
921
+ - Thresholds (8 samples, 80% share) are **provisional and uncalibrated**, chosen so the first
922
+ weeks of data cannot produce a confident wrong answer. Whether a stable source is ever skipped
923
+ before the fetch remains the user's decision; the numbers are printed each run so it can be
924
+ made on data rather than on a promise.
925
+
926
+ ## D-039: The daemon confirms at the parent, and reports a child that dies
927
+
928
+ **Status:** Active
929
+
930
+ `assistant start` spawns `assistant run` detached. The spawn now passes `--yes`, and
931
+ `startDaemon` observes the child for a short window before reporting success. A dead env marker
932
+ (`WEFLOW_ASSISTANT_DAEMON`) was deleted.
933
+
934
+ **Reason:** the child was spawned without `--yes`, while `assistant run` confirms through
935
+ `inquirer` - and the daemon gives it `stdin: 'ignore'`, so nobody could ever answer. The child
936
+ died on the prompt (or later, on the missing channel login) while the parent printed
937
+ 「✓ 守护进程已启动 (pid …)」 and wrote the pid file. On the machine this was developed against,
938
+ `~/.weflow-cli/` had **no `assistant.log` and no `assistant.pid` at all**: the documented
939
+ `assistant start` flow had never once completed. The env marker that appeared to handle this
940
+ was read by nothing, and was constructed as an env *key* containing `=` (`'…=1'`), so it could
941
+ not have worked even if something read it - a dead mechanism that made the path look guarded.
942
+
943
+ **Consequences and boundaries:**
944
+ - Confirmation stays **at the parent**: a human typed `assistant start` (or a machine passed
945
+ `--json --yes`). The child inherits that decision as an explicit argument, not as an ambient
946
+ env var - an env-var-based bypass would be inherited by every grandchild process, which is
947
+ exactly the shape the repo's permission rules avoid.
948
+ - The failure is now **observed, not assumed**: the child is watched for `SETTLE_MS` (default
949
+ 700 ms) and a child that has already exited is reported with its exit code and the tail of the
950
+ daemon log, and **no pid file is written** - writing one would be a claim that it is running.
951
+ - A spawn `'error'` event is now handled; without a listener Node turns it into an uncaught
952
+ exception in the caller.
953
+ - Verified live: `assistant start` on this machine now says
954
+ `子进程启动后立即退出 (code 1);日志尾部: Error: 未登录消息通道, 先运行 weflow-cli login-wechat`.
955
+ The fix did not break the daemon - it made a broken state visible.
956
+ - The daemon runs `dist/bin/weflow-cli.js` **when it exists**, so source changes need
957
+ `npm run build` before the daemon reflects them. That precedence is a live operational trap,
958
+ now written down in OPERATIONS.md.
959
+ - Untested and left untested: the daily quota (`100 条/天`) and the whole `start()` message
960
+ loop, because both require a logged-in WeChat channel. The per-message logic is covered by a
961
+ synthetic harness instead (stubbed `callLLM`, local-only tools).
962
+
963
+ ## D-040: The fetch guard is a denylist of known forms, and it says so
964
+
965
+ **Status:** Active
966
+
967
+ `assistantTools.isSafeUrl` decides whether the assistant may fetch a URL found in a favourite.
968
+ It is now covered by tests, and two real holes were found and closed: IPv6 forms were not
969
+ handled at all (`[::ffff:127.0.0.1]` - an IPv4-mapped loopback - as well as `[fd00::1]` and
970
+ `[fe80::1]` were **allowed**), while the private-range regexes were matched against any
971
+ hostname, so a legitimate public domain such as `10.example.com` was refused.
972
+
973
+ **Reason:** the guard runs immediately before a `fetch` made by a process that also holds the
974
+ local database handle, and its failure mode is silent in both directions - a bypass reaches
975
+ loopback or a cloud metadata address, and an over-block merely looks like a broken link
976
+ (`(链接不安全, 拒绝抓取: …)`). Neither raises. Testing it was the only way to find out which
977
+ direction it was wrong in; the measurement also settled an assumption: Node's URL parser
978
+ normalises integer IPv4 forms (`2130706433`, `0x7f000001`) to `127.0.0.1` **before** this
979
+ function sees them, so the dotted-quad check already covered them - that is now a test rather
980
+ than a belief.
981
+
982
+ **Consequences and boundaries:**
983
+ - IPv6 is handled by prefix: `::`, `::1`, `::ffff:` (IPv4-mapped), `fc00::/7` (unique-local),
984
+ `fe80::/10` (link-local) are refused. `[0:0:0:0:0:0:0:1]` arrives already normalised to
985
+ `[::1]`.
986
+ - The dotted-quad private-range checks now apply **only when the hostname really is four
987
+ dotted octets**, which is what stops `10.example.com` from being caught by `^10\.`.
988
+ - This is a denylist of known forms, **not a proof that an address is publicly routable**.
989
+ NAT64 (`64:ff9b::/96`) and similar mappings are not covered. The comment in the code says so,
990
+ so nobody reads the guard as a stronger guarantee than it is.
991
+ - The pure helpers (`isSafeUrl`, `stripTags`, `extractText`, `extractFromChallengePage`) are
992
+ exported for testing. They take strings and return strings - no side effects, no configuration.
993
+
994
+ ## D-041: The memory file carries a version, and an unknown version is refused rather than migrated
995
+
996
+ **Status:** Active
997
+
998
+ `~/.weflow-cli/assistant_memory.json` now starts with `version: 1` and puts every conversation under a
999
+ `users` key. A file without a `version` is the previous shape (v0, conversations at the top level) and is
1000
+ **migrated**; a file whose version is anything else is **refused**, renamed to
1001
+ `assistant_memory.json.unreadable-<timestamp>`, and the assistant starts with an empty memory and says so
1002
+ in the log and the audit.
1003
+
1004
+ **Reason:** the format had no version at all, and the repo freezes schemas for its other artifacts
1005
+ (`docs/SYNC_CONTRACT.md`, the `reconstructed` provenance block in `.articles.json`) — memory was the
1006
+ exception, and the cost of that lands on the first migration. The write criterion came from the same
1007
+ audit as the rest of this change: `deepseek-harness` pins its session format at `0` with the note that
1008
+ **no compatibility is implied and no migration is provided**, and rejects anything else at load. That
1009
+ combination (field + written criterion + refuse-don't-guess) is cheap and removes the "we will figure it
1010
+ out later" debt.
1011
+
1012
+ **Consequences and boundaries:**
1013
+ - **What counts as a structural change** (this criterion is part of the format, not an afterthought):
1014
+ renaming or removing a field, changing a field's meaning or units, or changing the key space (the
1015
+ `users` layer). **Adding an optional field does not** — readers ignore fields they do not know, and a
1016
+ test pins that a same-version file with unknown fields still loads.
1017
+ - v0 is migrated because **we wrote it** and know its exact shape. Unknown versions are refused because we
1018
+ do not: reading another producer's fields by guess is how silent corruption gets in.
1019
+ - Refusing must not destroy data: the rename to `unreadable-<timestamp>` is mandatory and the startup log
1020
+ names the file. A file that cannot be parsed at all takes the same path — it may be the user's only copy.
1021
+ - The same audit produced two other changes here rather than the file format: the compression **retains by
1022
+ ratio, not by a fixed turn count** (a fixed count is window-independent and goes wrong the moment the
1023
+ model changes), and the summariser prompt is a **fixed eight-section skeleton** with an explicit merge
1024
+ law ("keep what is still true, drop what is stale, never copy the previous summary verbatim"). The two
1025
+ gates differ in what they retain, deliberately: the turn gate keeps about half, the budget gate keeps
1026
+ what fits 16% of the budget and lets the character bound win over the turn floor, because six 6,000
1027
+ character turns are 36,000 characters and no turn floor justifies exceeding the input budget.
1028
+
1029
+ ## D-042: The assistant looks at one image per request, and the picture is what must not leak
1030
+
1031
+ **Status:** Active
1032
+
1033
+ The assistant gained a `look_at_image` tool. `get_messages` renders an image message as
1034
+ `[图片 #<local_id>]`, the model calls `look_at_image {contact, image}`, the reader decrypts that one
1035
+ image **locally** and the service attaches it to the next request as an OpenAI `image_url` content part.
1036
+
1037
+ Images ride a side channel (`ToolContext.pendingImages`) and are turned into multimodal content in
1038
+ **exactly one place** (`toApiMessages`). Memory, redaction, quota and the audit keep operating on text
1039
+ `content`; nothing else in the codebase has to know that content can be an array.
1040
+
1041
+ **Reason:** 15.5% of the messages in one measured 30-day archive are images (242 of 1564), and the model
1042
+ saw `[图片]` and nothing else - the largest remaining capability gap, and one no amount of prompt work
1043
+ closes. The alternative that was measured first got rejected on the same evidence: voice is 0.8% (12
1044
+ messages a month), so a transcriber on the read path would never pay for itself.
1045
+
1046
+ **Consequences and boundaries:**
1047
+ - **An image is egress, and it is treated as egress.** `strict` mode ("third-party chat text does not
1048
+ leave the machine") holds images back at **two** layers: the tool refuses to fetch *and* refuses to
1049
+ hand anything back, and `toApiMessages` drops whatever got through anyway. The tool layer is the cheap
1050
+ one; the request layer is the one that has to be right, because "the tool was willing" and "the gate
1051
+ let it out" are different statements. A drop is **stated in the body it attaches to**, never silent: a
1052
+ request that quietly lost an image looks to the model exactly like one that never had it.
1053
+ - `get_messages` shows the `#N` handle **only when the mode allows images out**. Advertising a handle
1054
+ that the tool will certainly refuse is worse than not mentioning it.
1055
+ - Looking is **on demand, one image per call, at most two per turn**. That is not a technical limit: both
1056
+ cost and exposure scale with pixels, and "the model chose to look at this picture" is a decision that
1057
+ belongs in the audit. Local engines (`ollama`, `lmstudio`) are exempt from the hold, and no
1058
+ `IMAGE_SENT` line is written for them - that line records **egress**, and writing it for a call that
1059
+ never left the machine would make the log lie about where data went.
1060
+ - Audit gained `IMAGE_SENT <bytes> n=<count> ids=<local_ids>` and `IMAGE_HELD <count> strict`. Byte
1061
+ counts and ids only, **never content**, like every other audit line.
1062
+ - Images never enter memory. Turns are stored as text, so a later turn sees the sentence the model wrote
1063
+ about the picture, not the picture. That is both the honest behaviour and the cheap one.
1064
+ - **Where the time goes, and the one cache.** Building the media index is the entire cost of a read, and
1065
+ it scales with the conversation: measured 0.9 s for a direct chat and **18.9 s for a group with 30,315
1066
+ messages** (that group's local media is thumbnails only, 290×435). The resolved, downscaled bytes are
1067
+ therefore cached at `output/.cache/read-image/<md5(talker)>/<local_id>.json`, which makes a second look
1068
+ at the same picture cheap. **Only hits are cached** - a miss may be "WeChat is not running" (see below),
1069
+ which is a state that changes, and caching it would make it permanent. The timeout is 90 s (≈4× the
1070
+ worst measured) and a timeout is reported as a failure rather than silently retried: the assistant
1071
+ handles messages serially, so one stuck read blocks every later message.
1072
+ - **"This picture is not here" and "I cannot see it right now" are different answers.** The V2 thumbnail
1073
+ key is derived from a file WeChat maintains while running; with WeChat closed the derivation returns
1074
+ nothing and most `.dat` thumbnails drop out of the index **silently**. A miss therefore carries a hint
1075
+ saying which of the two it is, instead of reporting a state that may be false.
1076
+ - What this does **not** do: no describe-once-and-cache, so looking at the same picture twice still costs
1077
+ two vision calls (only the local bytes are cached); no vision on stickers (type 47) or video (43), only
1078
+ type 3 images; no OCR fallback when the model cannot read something.
1079
+ - Known limit: an image whose local copy is genuinely gone returns a stated reason rather than a picture.
1080
+ The tool says so; it does not pretend to have looked. Note the honest scope of that claim: across 380
1081
+ sampled messages every image resolved, so the miss path was exercised by removing index entries, not by
1082
+ observing a real eviction.
1083
+
1084
+ ## D-043: The assistant records a per-turn trace, and the audit stays what it was
1085
+
1086
+ **Status:** Active
1087
+
1088
+ Every assistant turn now writes one record to `~/.weflow-cli/assistant_trace.jsonl`: the fast-route's
1089
+ decision, each tool call with a **redacted argument summary**, how many model round trips it took, why
1090
+ it stopped, and how much reasoning the model returned. Two ways to read it: `weflow-cli assistant trace
1091
+ [-n N]` on the machine, and the in-chat command `轨迹` for the last turn (no `userId` in that one).
1092
+
1093
+ **Reason:** the assistant's only output used to be the reply itself. Everything in between lived in the
1094
+ audit as separate event lines - `TURN_DONE tools=5` says five tools were called and nothing about which
1095
+ five, in what order, or with what arguments. When the answer looked wrong there was no way to see which
1096
+ step went wrong, and the one time this mattered (a turn that called `search_favorites` twice and
1097
+ `read_favorite` twice) the arguments were not recorded, so the question could not be answered from the
1098
+ log at all.
1099
+
1100
+ **Consequences and boundaries:**
1101
+ - **The audit is unchanged and must stay narrow.** It records events and byte counts and **never
1102
+ content**, because it is the egress record. The trace is a *local* debugging artifact and does contain
1103
+ redacted argument summaries - that is the whole point of it, and it is why it is a separate file with
1104
+ a separate reader rather than more fields on the audit line.
1105
+ - Argument summaries go through `privacyGate.redact` and truncate each value at 40 characters. The
1106
+ in-chat rendering (`轨迹`) is the only path where they leave the machine, which is no more than the
1107
+ reply itself already does; `userId` is deliberately omitted there.
1108
+ - **Reasoning is whatever the model returns**, surfaced from `reasoning_content` and clipped at 800
1109
+ characters. The default model (`deepseek-chat`) does not return it, so the field is usually empty and
1110
+ the trace says "无" - it does not pretend. Switching to a reasoning model fills it in; nothing else
1111
+ changes. The reasoning text is **not** fed back into the conversation: it is the model's monologue,
1112
+ not content for the user.
1113
+ - **"Did this step produce anything" is a convention, not a field.** The rule is "the result starts
1114
+ with `(`", which is how this codebase already writes every "could not do it" reply (`fail()` and the
1115
+ per-branch misses); the single exception, a success that also starts with `(`, is a whitelist entry
1116
+ guarded by a test that scans the tool source. Measuring the honesty of this: a wrong answer here
1117
+ costs one extra or missing "(无内容)" marker in a debugging view. The alternative - threading an
1118
+ outcome through roughly forty return sites - was priced and not done; if a future feature needs a
1119
+ machine-readable outcome (say, retrying differently on failure), that is the change to make, and this
1120
+ convention should then be deleted rather than extended.
1121
+ - The trace is capped at 512 KB, trimmed to the most recent 200 turns. It is not a metrics store and
1122
+ there is no aggregation over it: `assistant trace` prints turns, it does not compute rates. The
1123
+ behaviour eval (D-042's sibling work) is where aggregate numbers come from, because it asserts
1124
+ conditions instead of dumping history.
1125
+ - Writing a trace can never break a turn (failures are swallowed), which is the same posture
1126
+ `AssistantMemory.save` takes - with the same obligation: because it is swallowed, `recordTurn`
1127
+ creates `~/.weflow-cli` if it is missing, or a fresh machine would silently record nothing. That was
1128
+ caught by the feature's own tests.
1129
+
1130
+ ## D-045: The local panel is a token-gated loopback endpoint on the existing daemon, and the panel is only a client
1131
+
1132
+ **Status:** Active
1133
+
1134
+ The assistant gained a second entrance: a small always-on-top panel window on the same machine, so
1135
+ talking to it does not require logging into the WeChat channel. It is implemented as a
1136
+ **loopback-only HTTP endpoint inside the assistant daemon** (`src/panel/server.ts`), plus a client
1137
+ (`weflow-cli panel`, the page under `resources/panel/`, and an Electron shell). The panel holds no
1138
+ assistant of its own: every turn goes through the daemon's existing `runTurn` — the same allowlist
1139
+ path for WeChat, the same daily quota counter, the same serial queue, the same memory file.
1140
+
1141
+ **Reason:** the quota counter, the serial queue and the instance fields it protects (`turnCalls`,
1142
+ `lastReasoning`) are **in-process state**. A panel that built its own `AssistantService` would get a
1143
+ second quota (each entrance 100/day, total unbounded), a second queue protecting nothing, and two
1144
+ processes writing the same user's memory window (the file's read-merge-write protects *other* users,
1145
+ but for the same `userId` it is still last-writer-wins). The cheapest correct shape was therefore one
1146
+ host process with two entrances.
1147
+
1148
+ **Consequences and boundaries:**
1149
+ - **Loopback only, not configurable** (continuing D-004), plus two gates the reader does not have.
1150
+ `scripts/fav_server.py` has **no token at all** and its origin check **allows a request with no
1151
+ Origin header**; copying that here would hand an endpoint that reads chat data to any local program.
1152
+ So: **every request carries a token** (`/api/status` included), Origin is a second gate (present but
1153
+ not allowlisted → 403 even with a correct token; **absent → allowed**, because `file://` renderers
1154
+ and `curl` look like that), and POSTs must be `application/json`.
1155
+ - **The token is per-run and lives in a file next to the pid file** (`assistant_endpoint.json`, atomic
1156
+ write, read-merge not needed). It is **not** claimed to be protected by file permissions: on Windows
1157
+ `fs.chmod` / `mode: 0600` do essentially nothing, and what actually helps is the default ACL on
1158
+ `%USERPROFILE%\.weflow-cli\`. The token's real job is to stop **other local programs and any web
1159
+ page**, not a same-user process willing to read that directory — such a process can already read the
1160
+ database. **Cleanup is not relied on**: `stopDaemon` sends SIGTERM and Node does not run handlers for
1161
+ it, so the file is expected to be left behind; readers probe `kill(pid, 0)` and then verify the
1162
+ `service` + `startedAt` fields before trusting a port.
1163
+ - **The panel's identity is a memory bucket, and the bucket decides whether it is "one brain".**
1164
+ Memory facts are stored per `userId`, so sharing means using the same string as the WeChat DM. The
1165
+ bucket resolves to the **single** entry of `assistantWhitelist` when there is exactly one; with zero
1166
+ or several it does **not guess** — it falls back to a `panel` bucket and the interface says so, and
1167
+ `assistantPanelUser` pins the choice. Note what is inference and what is observed: the chain
1168
+ (DM `conversationId` == `senderId` == the allowlist value) is sound in the code, but it has **never
1169
+ been observed on a real inbound WeChat message** — the channel on this machine has never completed a
1170
+ login. The panel displays its resolved bucket so the first real message can be compared by eye.
1171
+ - **Credentials never enter a command line.** Windows exposes any process's argv to any local user
1172
+ (`wmic process get commandline`), so the Electron path passes **no** arguments and reads the
1173
+ endpoint file itself, then installs the token as an `HttpOnly` cookie before loading the page. The
1174
+ browser fallback cannot do that, so it uses a **one-time, 60-second, single-use code** from
1175
+ `/api/pair` — a code in argv or in browser history is spent, a token there would not be.
1176
+ - **`AssistantService.start()` no longer refuses to start without a channel.** It used to throw
1177
+ `未登录消息通道` before doing anything, so "not logged into WeChat" meant "no assistant at all"
1178
+ (recorded in PROJECT_STATE as a field observation). Now the channel is optional and the local
1179
+ entrance still comes up. The cost is that `assistant start` can now report success with an empty
1180
+ channel, so `assistant status` gained `channelActive` / `mode` / `panelPort` / `memoryBucket` —
1181
+ `messageChannelLoggedIn` only ever meant "is a token configured".
1182
+ - **The panel authenticates by token, not by the WeChat allowlist.** Reusing `evaluateAssistantAccess`
1183
+ would deny the panel when the allowlist is empty (i.e. by default) and, worse, print the
1184
+ "run `config set assistantWhitelist <id>`" bootstrap hint telling the user to add **their own panel
1185
+ identity** to the WeChat allowlist. The allowlist answers "who may talk to me in WeChat"; it is not
1186
+ the right gate for the person sitting at the machine.
1187
+ - **Verified on Windows 11 with Electron 42** (the binary had to be downloaded first - it was
1188
+ declared but not installed, so the browser fallback was the only path for a while): the ball window
1189
+ measures 76x76 with no caption (frameless) and `WS_EX_TOPMOST` set, the renderer loads the page and
1190
+ authenticates **through the cookie** (visible in the daemon log as `[panel] 界面已加载(cookie)`),
1191
+ and the ball is visible on screen - confirmed by eye, because **GDI screen capture does not capture a
1192
+ transparent layered window**, so a screenshot showing nothing at that spot is a false negative.
1193
+ The interactive paths were then driven **without** a click: over the DevTools protocol the ball was
1194
+ really clicked (the window measured 421x560, always-on-top dropped) and collapsed again (77x76,
1195
+ always-on-top restored); the global hotkey was verified differentially - a second Electron app
1196
+ registering the same chord gets `false` while the panel runs and `true` once it stops, so the panel
1197
+ genuinely holds it. Driving it that way found **two real bugs that a screenshot could never have
1198
+ shown**: `setMode` locked the window non-resizable *before* resizing it, and Windows ignores
1199
+ `setSize` on a non-resizable window, so collapsing left the window at chat size (a giant ball); and
1200
+ the `ready-to-show` listener was attached *after* `await loadURL`, so when that event fired during
1201
+ the load it was never replayed and the window stayed hidden - geometry and style all measured
1202
+ correct, only `IsWindowVisible` was false. A third bug came out of exercising what the tray items *do* rather than clicking them: the
1203
+ "quit and stop the assistant" action spawned the CLI as `spawn(electron, [cli.cjs, …])`, which
1204
+ fails with `error: unknown command '…\cli.cjs'` because commander in Electron's Node mode does not
1205
+ skip `process.argv[1]`. The repository already knew this (`bin/weflow-cli-electron.cjs` documents
1206
+ it); the working form is `-e "import('file:///…')" -- <args>`, with the path passed through
1207
+ `pathToFileURL` because this checkout's directory name is non-ASCII. Verified by running that exact
1208
+ shape: the daemon stops and both the pid file and the endpoint file are cleaned up. The one thing
1209
+ still unverified is a **click on the tray menu itself** - its contents are pinned by static
1210
+ assertions, and each item's effect has been exercised directly.
1211
+ - **Placement and drag were then closed too, and they were not merely untested - they were wrong.**
1212
+ The window was created without `x`/`y`, so it landed wherever Windows put it (measured 815,418,
1213
+ mid-left) - not "a ball in the corner". It now defaults to the primary work area's bottom-right
1214
+ corner, **remembers where it was dragged** (`~/.weflow-cli/panel_position.json`, atomic write), and
1215
+ falls back to the corner when the remembered spot is unreachable (a monitor was unplugged, or the
1216
+ ball was dragged off-screen). Expanding to the chat window and collapsing back both re-fit into the
1217
+ work area, because a 420x560 window opened at a bottom-right corner would otherwise hang off the
1218
+ screen. Two details worth keeping: the listener is `move`, not `moved` - `moved` fires on
1219
+ `WM_EXITSIZEMOVE`, which a programmatic move never produces (measured: the window moved and the
1220
+ position file was not written) - and only the **ball's** position is saved, since the ball is the
1221
+ anchor and the chat window's geometry is transient.
1222
+ The arithmetic lives in `resources/panel/ball-position.cjs` so it can be tested in CI with
1223
+ **synthetic monitor layouts** - negative-x second displays, a removed display, a rect larger than
1224
+ the work area - because **this machine has one monitor and cannot produce those**. What that does
1225
+ not cover: a real second display. The code path is exercised with synthetic coordinates; plugging
1226
+ in a second monitor is still an unperformed experiment.
1227
+
1228
+ ## D-044: "You have no todos" and "todos were never extracted" are different answers
1229
+
1230
+ **Status:** Active
1231
+
1232
+ `extract_todos.py list` now reports whether the todo file exists, and every reader keeps the two cases
1233
+ apart. The bare-array shape of `list --json` is unchanged; the distinction rides on a new `--meta` flag
1234
+ (`{items, extracted, count}`). The assistant tool calls `list --json --meta` and, when `extracted` is
1235
+ false, answers "these have never been extracted - run `weflow-cli todos extract --days 7 --yes` first;
1236
+ until then this list is empty, which does not mean you have nothing to do" instead of "no pending
1237
+ todos". The terminal output of `list` and `remind` says the same thing.
1238
+
1239
+ **Reason:** Extraction is a deliberate, confirmed action (`todos extract` carries its own `--yes` gate),
1240
+ so on a machine where it has never been run the list is empty for a reason that has nothing to do with
1241
+ the user's workload. Reporting that emptiness as "nothing to do" asserts a state that was never
1242
+ established - the same failure the `(未查到会话, 数据库可能未连接)` wording avoids. It was not
1243
+ hypothetical: on this machine `~/.weflow-cli/todos.json` does not exist at all, and the assistant's
1244
+ `get_todos` was answering `(没有待办任务)`.
1245
+
1246
+ **Consequences and boundaries:**
1247
+ - The `--json` array shape stays because it is a published capability (`bin/weflow-cli.ts` maps
1248
+ `todos: { cli: 'todos list --json' }`), so a marker inside that array would have been a compatibility
1249
+ break dressed up as a fix. The tool also tolerates the old shape: if it receives an array rather than
1250
+ the meta object, it falls back to the previous wording rather than reading `undefined.items` as
1251
+ "never extracted".
1252
+ - The MCP side (`mcp_bridge.py`) has always separated these two cases - it already said "no todo file
1253
+ yet, run extract_todos.py first". The assistant tool was the one implementation that dropped the
1254
+ information, which is what made this look like a tool defect rather than a missing feature.
1255
+ - Not fixed: nothing here makes extraction happen. The pipeline that would run it on a schedule is the
1256
+ same open question as the daily report's (no scheduled task is registered on this machine).
1257
+
1258
+ ## D-046: Atomic writes retry a busy target, and fall back to writing in place
1259
+
1260
+ **Status:** Active
1261
+
1262
+ `writeFileAtomic` (`src/utils/atomicWrite.ts`) writes `<target>.tmp` and renames it over the target, as
1263
+ before - but when the rename fails with `EPERM` / `EACCES` / `EBUSY` it retries five times with a short
1264
+ synchronous backoff, and if the target is still busy it **writes the target directly** instead of giving
1265
+ up. Both `AssistantMemory.save()`, `configService.save()` and the panel's endpoint file go through it.
1266
+
1267
+ **Reason:** on Windows, `renameSync` over a file that **another handle has open** fails with `EPERM` -
1268
+ measured: a second handle opening the target read-only is enough, and the rename succeeds the moment that
1269
+ handle closes. Antivirus, search indexers and other readers produce that state briefly and routinely. The
1270
+ callers all swallow the failure (they record a reason and carry on), so the result was a **silent lost
1271
+ write**: one full-suite run here saved the memory file and the file came back without its `version` field,
1272
+ with the test itself green the other three times. "Saved" and "never saved" must not look the same.
1273
+
1274
+ **Consequences and boundaries:**
1275
+ - The fallback gives up **atomicity** for that one write, not correctness of the bytes: the same string is
1276
+ written either way, so a reader never sees a half-written file. What is lost is the guarantee that the
1277
+ replacement is instantaneous.
1278
+ - If the other handle holds the file with a deny-write share mode, the direct write fails too and the error
1279
+ propagates, exactly as before. The fallback is best-effort, not a guarantee - do not describe it as one.
1280
+ - Deciding to fix this was not cosmetic: this repository already treats a silently failed save as a defect
1281
+ (there is a CHANGELOG entry about `MEMORY_SAVE_FAILED`), and the flake was the same class of thing
1282
+ arriving from the filesystem instead of the code.
1283
+
1284
+ ## D-047: Drafted replies produce text and stop there, the risk gate rides on the binary questions, and the assistant path masks before the script sees anything
1285
+
1286
+ **Status:** Active
1287
+
1288
+ `draft` (CLI) and the assistant tool `draft_reply` ask a decision model seven typed questions about one
1289
+ conversation (intent / need / action / should this get substance / risk 0-9 / money / unfulfilled
1290
+ commitment), refuse when the answer is money **or** risk >= 7, otherwise generate N candidate replies and
1291
+ rank them. The output is **text only** - there is no path from here into WeChat, no input-box filling, no
1292
+ key simulation, no focus stealing. `capabilities --json` declares `sendsNothing: true` alongside
1293
+ `writesNothing`.
1294
+
1295
+ **Reason:** the pattern comes from reading `jev-chat-jarvis` (an Android assistant that reads the screen
1296
+ through accessibility, decides, and drafts three replies into the input box without ever sending). Two
1297
+ things it does *not* do are the two things worth doing here, and both were read from its source rather than
1298
+ assumed: its danger level (0-9) is consumed **only** as a badge colour - it never refuses a draft, degrades
1299
+ or blocks - and its judgement result is **not** in the drafting prompt (`ReplyClient.draft` takes no
1300
+ `Analysis`), so "draft with the judgement's sense of proportion" is missing. Its "never send" is its
1301
+ boundary; here sending was already structurally unreachable (`TOOL_DEFS` has no send tool, D-008), so the
1302
+ risk this feature adds is not a mis-send but **making it easier to send a message the user will regret** -
1303
+ which is what the gate is for.
1304
+
1305
+ **Consequences and boundaries:**
1306
+ - **The gate rides on the binary questions, not on the 0-9 score.** `money` is a hard refusal; `risk >= 7`
1307
+ is a refusal whose advice is taken from the `action` answer ("go read the earlier history first"). The
1308
+ 0-9 band is calibrated in wording but its **threshold is picked, not calibrated**, so it explains
1309
+ ("risk 8/9: it has already turned into a fight") and does not decide alone. `commitment` deliberately
1310
+ does **not** refuse by default - "I promised and went quiet" is exactly when a draft is most useful - it
1311
+ constrains the prompt instead; `--gate-commitment` makes it a hard refusal, which is the trade this
1312
+ record exists so someone can reverse.
1313
+ - **The English criteria are borrowed, and that choice is unverified here.** The question set follows the
1314
+ reference implementation, which was calibrated against a 30-conversation Chinese set in its own
1315
+ (intimate-relationship) domain; the criteria are English because its `TASK.md` states the model's main
1316
+ training language is English. `reply_debt.py` still asks its own money/commitment questions in **Chinese**
1317
+ and those two are reused **verbatim**, so this repo now runs two wording conventions against one model
1318
+ with **no A/B run**. Recorded as an open question, not as a settled choice.
1319
+ - **Two input paths, and the mask asymmetry is deliberate.** The CLI reads the database itself and does
1320
+ **not** mask - the user ran it explicitly and the preview states how many characters go to which models
1321
+ (same as `awaiting`). The assistant tool masks every message through `privacyGate` and hands the result
1322
+ to the same script **over stdin**; Python has no redaction implementation at all (`scripts/` contains
1323
+ only two unrelated bit-masks), and the one implementation lives in TypeScript. `strict` mode **refuses**
1324
+ the tool outright rather than drafting from a body masked into "content N characters", because that
1325
+ produces a fluent answer built on nothing.
1326
+ - **`isLocalInference()` is deliberately *not* an escape hatch here**, unlike `look_at_image`. That image
1327
+ really does go to the local model, so "local inference" is a valid bypass there; drafting's egress is the
1328
+ **script's own** two remote calls (`jev_client` over HTTP, `_utils.call_deepseek` hard-wired to DeepSeek),
1329
+ which `aiEngine` does not touch. The first implementation copied the bypass from `look_at_image` and a
1330
+ test caught it: with `aiEngine=ollama` + `assistantPrivacy=strict` the transcript was handed to the
1331
+ script. Verified by measurement, then removed.
1332
+ - **Clicking a name in the panel's quick-reply menu *is* the consent.** That menu (right-click; the list comes from
1333
+ the `quickReplyContacts` config key) turns one click into a draft for one person - which means that conversation goes
1334
+ to two cloud models. So the cost is written **inside the menu** rather than discovered afterwards, and the menu
1335
+ deliberately does not exist in the browser fallback, where the native right-click menu (with Copy) belongs to the
1336
+ user. The menu is a **native** one for the same reason: a page-drawn menu cannot leave the window and the ball's is
1337
+ 76x76, so the first attempt expanded the window to make room - which turned a quick action into "right-click opens the
1338
+ chat window" (the user's words). A native popup draws outside the window, so nothing has to move - and because the
1339
+ chat window is deliberately *not* always-on-top, expanding it now also raises and focuses it: closing a native
1340
+ popup can hand focus back to some other window, and the next complaint was "I do not know where the answer
1341
+ went" (it went into a window that was behind something else). The conversation shows a readable turn
1342
+ ("快速回复:<name>") while what is actually sent is the explicit tool-naming request. Nothing here sends a
1343
+ message: the chain is structurally text-only, same as everywhere else. The menu's **last item is not a consent at
1344
+ all** - it hides the ball and reaches no model - and it is the one item that survives an empty contact list, because
1345
+ a menu of two grey lines with nothing clickable gives that user no way to dismiss the ball. It **hides rather than
1346
+ quits**: the exit actions stay in the tray menu, which already separates them from show/expand, and hiding is the
1347
+ reversible choice. The label therefore has to name the way back (`关闭悬浮球(托盘图标能再打开)`) - the ball runs with
1348
+ `setSkipTaskbar(true)`, so a hidden ball leaves nothing on the taskbar to click, and a label saying only "关闭" would
1349
+ read as "it is gone for good".
1350
+ - **The transcript line has one shape, and a wxid is not a speaker label.** The line the judgement and drafting steps
1351
+ read (`[09-23 12:20] 老王:…`) is rendered in two places - `assistantTools.transcriptLine` on the tool path (mask in
1352
+ TypeScript, then send the rendered lines over stdin) and `reply_debt.format_line` on the CLI path (which reads the
1353
+ database itself) - and they had drifted in four ways with nothing failing. The rule that came out of fixing it is the
1354
+ second half of the title: `senderUsername` is a raw column holding the sender's **wxid**, not a name, so neither side
1355
+ may use it as a label; a resolved display name (`senderDisplay`) is used when there is one, and `对方` otherwise.
1356
+ This was first recorded as a trade-off, on the assumption that the alternative to a wxid was `对方` and that
1357
+ collapsing a group's speakers would cost the ability to answer "who said that". **Measuring retired that
1358
+ assumption**: across 8 sessions and 179 messages from other people, `senderDisplay` held a name 174 times,
1359
+ `senderUsername` was guid-shaped 176 times, and the two were never equal. So the name keeps speaker distinction
1360
+ *and* removes the identifier, and `get_messages` was changed to use the same rule rather than left on the raw
1361
+ column. The rule is one function (`speakerLabel`), because "who is speaking" is the kind of thing that grows a
1362
+ second implementation.
1363
+ - **The judgement step is degradable now, and the degradation says so.** Jev's free period ended on
1364
+ 2026-09-25; when that call fails (expired, bad key, or a dropped connection - the first failure seen here was
1365
+ an `http.client` read error) the tool no longer refuses to draft. It falls back to DeepSeek alone: three
1366
+ candidates, no ranking, and **no gate** - because the gate (money, or risk >= 7 -> no draft) exists only
1367
+ because a judgement exists. That is a deliberate trade the user asked for, and it is only acceptable because
1368
+ the output **states it**: `judged: false` plus a `judgeNote` travel with the result, the CLI prints
1369
+ "这次没有判断…闸门没生效" before the candidates, and the assistant tool prints the same. A fallback that
1370
+ looked like a normal run would be the worst outcome here, and the tests pin both directions (with a
1371
+ judgement `evaluate_gate` is consulted exactly once; without one it is never consulted). The consequence
1372
+ for availability follows: `deepseekApiKey` is now the only required key, and a missing `typesafeApiKey` no
1373
+ longer hides the tool - hiding something that can run would be its own kind of lie.
1374
+ - **The MCP surface gets a stricter boundary than the panel does, on purpose, and it applies to all four tools that egress.** `draft_reply` is on the
1375
+ MCP tool list (that list is *derived* from the assistant tool table minus `save_memory` and `look_at_image`, so
1376
+ anything added there appears there too, and a tool that cannot work on that transport has to be excluded
1377
+ explicitly - `look_at_image` hands its image to the assistant's model through a side channel that MCP never
1378
+ reads, so it used to answer "you can see it now" while shipping nothing) and an MCP call that does not pass `confirm: true` returns a **preview only** -
1379
+ how many messages, how many characters, which two models - with nothing leaving the machine. The panel and
1380
+ the WeChat bot do not need that flag: their boundary is the sender allowlist plus a user asking in a
1381
+ conversation only they can see. The same gate covers `who_owes_reply` (every conversation's text, one
1382
+ request each), `search_chats` (the question plus words pulled from the chats) and `search_semantic` (the
1383
+ query, then the hits) - one shared message shape in `confirmPreview()`, with each tool filling in what it
1384
+ actually sends. Two of them can produce real numbers for the preview by running their script's own
1385
+ `--dry-run`; the other two say it from their arguments, because `route_cards.py --dry-run` prints plain
1386
+ text rather than JSON and `semantic_search.py` has no `--dry-run` at all. An MCP client is a **third-party process** whose session nobody here can
1387
+ observe, so its default is "show me the cost first". The tool description states this, because a calling
1388
+ model that does not know it cannot ask its user.
1389
+ Two claims next to this were wrong and are now corrected rather than quietly dropped: `capabilities --json`
1390
+ reported `safety.mcpDefaultReadOnly: true` (false - that surface has contained a file writer and a
1391
+ model-calling tool since `export_chat` and `look_at_image`), and an earlier revision of this record said
1392
+ drafting was deliberately kept off MCP. It was never off; it had simply never been *checked*, because the
1393
+ tool names are generated at runtime (`wechat.${name}`) and a grep for the literal `name: 'wechat.` cannot
1394
+ see them.
1395
+ - **The panel only gets a copy button**, on the assistant's turns. It copies the whole reply rather than
1396
+ per-candidate lines: the visible reply is the model's own rendering, so guessing which lines are
1397
+ candidates would sometimes copy half a sentence. A failed clipboard write says so and selects the text
1398
+ instead of claiming success.
1399
+ - Group chats are out of scope (the reference implementation documents the same limit, and local group
1400
+ semantics are not reliable here), as is any automatic labelling: there is no gold standard for "is this
1401
+ draft right", so the feature records what it judged and says so rather than claiming calibration.
1402
+
1403
+ ## D-055: The Vault's copy of an article is cleaned, the fetched original is not - and "which days does the copy run cover" was being decided by a marker that only one writer sets
1404
+
1405
+ **Status:** Active
1406
+
1407
+ **Decision.** `copy_to_vault` now writes the Vault's `Sources/WeChat/<day>/` copy through
1408
+ `strip_wx_ads_document`, which removes the known WeChat interface phrases (`继续滑动看下一个`, `轻触阅读原文`, the
1409
+ `在小说阅读器中沉浸阅读` family, eight in total) while leaving **two things untouched**: the frontmatter and any
1410
+ fenced code block. `output/biz-daily/` keeps the fetched text byte-for-byte.
1411
+
1412
+ **Where the cleaning lives is the whole point, not an implementation detail.** Cleaning in the fetch step would
1413
+ have been simpler and would have made the two copies identical - but `biz-daily` is the only copy of the fetched
1414
+ bodies, so a cleaner with a bug there destroys material that cannot be re-derived. Cleaning only inside the copy
1415
+ function keeps the original recoverable, at the cost of the two trees no longer matching - and that cost has to be
1416
+ paid **inside the copy**, because this step is re-runnable. It is not hypothetical: on 2026-09-28 a plain re-sync
1417
+ silently restored the residue on 15,731 files, because an earlier one-off pass had cleaned the Vault copies while
1418
+ the copy function was still dumb. Any future path that writes article text into the Vault must go through this
1419
+ function or it will undo the cleaning again.
1420
+
1421
+ **Two protections, each because the obvious version is silently wrong.** The phrases are short words:
1422
+ `去阅读` appears inside the title `如何去阅读一本书`, and the title is the only alignment key between a reading note
1423
+ and its source, so the frontmatter is never cleaned (measured: 0 of 35,060 files currently need it - the guard is
1424
+ for the future, and is written down as such rather than presented as a fix). `strip_wx_ads` also normalises
1425
+ whitespace, which is harmless for the summaries it was written for and destroys indentation in code blocks:
1426
+ 20 files carry fences and **10 of them would have been altered** (HTML/JS samples).
1427
+
1428
+ **What it deletes is more than the phrases, and that has to be stated rather than glossed.** It is
1429
+ `strip_wx_ads`, the function the reading notes already use, so it also drops `______` runs, `javascript:void(0)`
1430
+ links, the `原创 <账号名>` byline WeChat repeats several times, and repetitions of the same token. The byline rule
1431
+ alone fires on **9,334 of 35,463 files**. Classifying every diff over a 300-article sample found no reworded prose
1432
+ - that is the part that was verified, and it is the reason it was defensible to point the cleaner at a whole
1433
+ article body. An earlier draft of this entry said the pass touched only phrases and whitespace; measuring the
1434
+ corpus disproved it, and the correction is recorded here rather than quietly edited away. The byline is page
1435
+ furniture and `source:` in the frontmatter still carries the account name, so the trade is acceptable - but it is
1436
+ text removal, and the pristine copy lives in `output/biz-daily/`.
1437
+
1438
+ **A gate that had been wrong from the start.** `--vault-sync` selected days by the `backfilled` marker in
1439
+ `.articles.json`. That key is written by the backfill path only, so the 12 days produced by the `daily` path (which
1440
+ writes `articles` but not `backfilled`) were never copied **and were never reported as skipped** - including two
1441
+ days, 08-24 and 08-25, with no directory in the Vault at all. The gate is now "the day has at least one `.md`
1442
+ besides `README.md`", which is the condition the flag was always meant to express. Measured: 187 → 199 days,
1443
+ 33,232 → 35,463 files. The 1,755 files that kept their residue after the first cleaning pass were exactly this
1444
+ population.
1445
+
1446
+ **Why the notes were never affected.** Every reading note points at `output/biz-daily/...` through `local_source`,
1447
+ not at the Vault copy - verified across five months, 1,144 of 1,144. So none of this created a broken link, and
1448
+ `wiki lint` reports the same result before and after.
1449
+
1450
+ ## D-054: A page can be assembled from material that was already paid for, and "no model call" is not the same as "no model wrote it"
1451
+
1452
+ **Status:** Active
1453
+
1454
+ **Decision.** `wiki compile --pages-from-cards` builds pages for concepts that have none, locally and without any
1455
+ API call: the definition is the `desc` the card already carries, related concepts are the other concepts from the
1456
+ same article (co-occurrence, median 3), the source is that article. 17,383 pages, ¥0. It was written after the
1457
+ model-based route was priced at **¥31** and the user called that expensive - which is the right call, because
1458
+ every one of those candidates is mentioned by exactly **one** article, so there is nothing to synthesise and a
1459
+ model would only be rewording a single sentence.
1460
+
1461
+ **The honest description is "reusing output already paid for".** The `desc` lines were written by
1462
+ `article_notes` when it extracted concepts from articles, so calling this "no model involved" would be wrong:
1463
+ the quality is that line's quality. Most read like definitions, a few are meta-descriptions about where the term
1464
+ appeared in the article (`免疫生态位` - "the other thing the title splits out alongside cell types"). Pages carry
1465
+ `summary_by: card` for exactly this reason, following the `summary_by: model` convention `user_notes`
1466
+ established: text that came from somewhere else must not be readable as "this is what the page's subject is".
1467
+
1468
+ **Why this is not the same trade as the earlier ¥32 rejection.** The 500 trial pages built with a model are
1469
+ better pages - they have a synthesised definition and a "key points" section - and they cost ¥0.9 for 500. The
1470
+ difference is not quality, it is what the money buys: with one source per concept, it buys prose polish that a
1471
+ reader cannot tell from the original sentence. That trade is worth making per page only when the user asks for it;
1472
+ it is not worth making 17,383 times by default.
1473
+
1474
+ **A guard that had to be added, and why the first version was wrong.** `--refresh-sources` deliberately scans
1475
+ **all** card directories (it fills in edges on existing pages, so more is strictly better). Reusing that same
1476
+ default for page building would have put chat-line concepts into `Wiki/Concepts` - the two lines are two
1477
+ knowledge bases, which the user asked for explicitly on 2026-09-27. The flag now requires explicit `--cards` and
1478
+ prints the exact commands for each line when it is missing.
1479
+
1480
+ ## D-053: One concept is one node, and duplicates are merged through `aliases` rather than by rewriting links
1481
+
1482
+ **Status:** Active
1483
+
1484
+ **Decision.** `wiki compile --merge-duplicates` finds concept pages whose names are the same under a normal form
1485
+ (strip spaces, hyphens and punctuation; lowercase; drop an English plural tail) and merges each group into one
1486
+ page. It was prompted by the user's own reading of the graph - "some of them are the same thing but drawn as
1487
+ different ones ... I want the graph to be effective, not piled up like garbage" - and the measurement backed it:
1488
+ 74 groups, 80 redundant pages, in shapes like `GPT 5.6` / `GPT-5.6` / `GPT5.6`, `AI skill` / `AI skills`, and
1489
+ `AI 工具` / `AI工具`.
1490
+
1491
+ **The other names become `aliases`, and that is the whole reason this is cheap.** 98 links in the vault pointed
1492
+ at names that would be deleted. Writing those names into the surviving page's `aliases` means Obsidian resolves
1493
+ every one of them - so **nothing else in the vault has to be rewritten**. Doing it the obvious way, by rewriting
1494
+ every referencing page, would have touched files across both knowledge bases for a graph-shape change, and the
1495
+ next merge would touch them again. Verified both directions: all 80 dropped names are aliases somewhere, and all
1496
+ 98 links are covered.
1497
+
1498
+ **Which page survives is decided by source count, not by name.** `claude code` (411 sources) absorbs `ClaudeCode`
1499
+ (7). Sorting by name instead would keep the emptier page - and a test now pins this specifically, because the
1500
+ first version of the fixture happened to make both rules agree, so the mutation "sort by name" passed.
1501
+
1502
+ **The criterion is not the lint's.** `wiki_lint` calls two titles near-duplicates when they share a 5-character
1503
+ run covering 40% of the shorter one, which reports `Claude Code` and `Claude 4.8` as a pair. The merge does not
1504
+ use that rule and does not merge them: a false merge destroys a real concept, a missed merge just leaves a
1505
+ duplicate. `wiki_lint` now reads `aliases` as well - otherwise it would report the merged-away names as "not
1506
+ built yet, run compile with a higher limit", which is not a warning but a wrong instruction (that count fell
1507
+ 9,421 -> 9,384).
1508
+
1509
+ **Not done, deliberately.** Nothing automatic: it is a flag, `--dry-run` shows the list first, and it is meant to
1510
+ be run when the user notices the graph getting noisy rather than on a schedule. Nor does it try to merge the
1511
+ looser "similar name" set - deciding whether `Claude Code` and `Claude 4.8` are one thing is a judgement, and
1512
+ that is where a decision model rather than a string rule would be needed. **That last one was tried on
1513
+ 2026-09-28, and Jev cannot do it either** ($0.17): of the 13,367 loose pairs, 741 came back "the same
1514
+ concept", and sampling them (the most confident 25, then a random 12) showed essentially all are wrong
1515
+ (`Token 节省` vs `Token计费`, `Agentic RAG` vs `Agent面试准备`). Rewriting the instructions with
1516
+ counter-examples fixed those and broke the true ones in the same run (`AI skill` vs `AI skills` came back
1517
+ "related but different"). The question here is **literal** - same string modulo spacing, case, plurals - and
1518
+ Jev is a semantic model; the normal form was the right tool all along. Narrowing the 741 to "one name is an
1519
+ "extension of the other" left 27, and inspecting each of them by its actual definition left **two** that
1520
+ are genuinely the same (`TRAE IDE Linux 版` / `TRAE IDE Linux 版本`, and
1521
+ `100 万 token 上下文窗口` / `100万token上下文`) - merged by hand, aliased, 0 broken links. The other
1522
+ twenty-five were rejected on meaning: "日均Token调用量" is a rate and "Token调用量" is a total;
1523
+ "2026世界人工智能大会" is one year's event and "世界人工智能大会" is the series; "Token 节省" is a
1524
+ concept and "Token 节省技巧" is a set of methods. So the loose set stays untouched, and that is a finding
1525
+ rather than a gap: the graph's name-level duplication was already nearly exhausted by the 74 groups the
1526
+ rule could settle, and the 741 Jev proposed contained two.
1527
+ untouched, and that is a finding rather than a gap: the graph's name-level duplication was already nearly
1528
+ exhausted by the 74 groups the rule could settle.
1529
+
1530
+ ## D-052: Concept pages accumulate on their own schedule, and the body's source list deliberately diverges from `sources:`
1531
+
1532
+ **Status:** Active
1533
+
1534
+ **Decision.** `wiki compile --refresh-sources` is a local, model-free step that rewrites the `## 来源` section
1535
+ of **existing** concept pages so they carry every source the cards currently support. It landed because the
1536
+ user asked how the graph grows when new articles arrive, and the honest answer turned out to be "only new
1537
+ nodes": `build_jobs` skips concepts that already have a page, so an article mentioning `DeepSeek` adds nothing
1538
+ to `DeepSeek.md`. `OPERATIONS.md` had been claiming the pages were "a ledger" while the implementation made
1539
+ them a snapshot; one run added 3,164 edges across 324 article-line pages and 148 across 34 chat-line pages.
1540
+
1541
+ **Three things it does not do, each because the obvious version is silently wrong.**
1542
+
1543
+ *It does not sync the frontmatter.* The body and `sources:` carry **deliberately different** names, and the
1544
+ live example is a chat page: `sources: [24环境专硕班]` (the card name, which is what `source_kinds_for`
1545
+ looks up under `output/chat-notes/`) versus `- [[会话-24环境专硕班]]` in the body (the Vault name, which is
1546
+ what Obsidian resolves under `Sources/Chat/`). Writing either name into the other place breaks something
1547
+ without erroring: put the body's name into `sources:` and 100% of chat-line tag inference stops (measured),
1548
+ while `relabel_pages` only ever *adds* `来源/*` tags - so the page keeps looking correct. This is a case of
1549
+ "the same fact in two places" where the two places have genuinely different consumers, so the rule is to say
1550
+ so out loud rather than to unify them.
1551
+
1552
+ *It only adds.* 322 article-line source lines have no card behind them any more (the card was rewritten, or
1553
+ no longer mentions the concept). They stay. That makes the page's source count **larger** than the index's
1554
+ citation count on 195 concepts - and the earlier reading of that gap, that the two numbers *should* match,
1555
+ was wrong: the index says what the cards say now, the page holds every reference it ever had. When this was
1556
+ measured, all 195 differed in that one direction and every extra line resolved to a real file.
1557
+
1558
+ *It skips names that cannot be written.* Six contain square brackets (Obsidian cannot resolve those either),
1559
+ and one has a title that itself ends in `.md`. That last one is worth remembering: `source_link_target` peels
1560
+ one suffix and yields `….md`, while `wiki_lint.resolve` sees the `.md` and looks for a *file* by that name -
1561
+ so the write would have turned the lint's "0 broken links" into 1. The card's real name is `….md.md`.
1562
+
1563
+ **Ordering is a spec, not a history.** Sources are written `sorted()`. The first draft appended new lines after
1564
+ the existing ones, which makes the order a function of when each line arrived - so the diff never settles and
1565
+ an insert lands in a different place on every machine. Measured cost of normalising: 29 extra pages.
1566
+
1567
+ **What was NOT changed.** `generate_concept` still truncates the model's *reference material* to five entries
1568
+ (`build_ref_lines`, a prompt-budget decision, tested), so a page may now list 301 sources while the model that
1569
+ wrote it saw 5. That is a real asymmetry and it is stated here rather than left to be discovered - the
1570
+ alternative, feeding 301 sources into the prompt, is a different change with its own cost.
1571
+
1572
+ ## D-051: One Vault, two knowledge bases - and a directory list that five readers have to agree on
1573
+
1574
+ **Status:** Active
1575
+
1576
+ **Decision.** The article line and the conversation line are kept **apart on disk and together in the graph**:
1577
+ `Wiki/Concepts` (+ `Wiki/00-Overview.md`) is the article knowledge base, `Chat/Concepts` (+ `Chat/00-Overview.md`)
1578
+ is the chat one, and the conversation cards live at `Sources/Chat/会话-<name>.md` next to `Sources/WeChat/`.
1579
+ Both are inside the same Obsidian vault, because the request was to keep the two knowledge bases apart, not to
1580
+ make the person browse two vaults - and the graph is the one place where their 4% overlap is information rather
1581
+ than noise. The split is the user's call (2026-09-27); the parts below are the consequences that had to be paid
1582
+ for it.
1583
+
1584
+ **The list cannot be shared, so five declarations are pinned to one.** The set of concept directories is needed
1585
+ in Python (`_utils.CONCEPT_DIRS`), in `src/services/assistantTools.ts`, in `bin/weflow-cli.ts`'s `vault init`
1586
+ preview, and in the separately-packaged `mcp-server/index.ts` - four languages- and package-boundaries that a
1587
+ single constant cannot cross. Following the precedent already in this repo for the same situation
1588
+ (`config-keys.test.ts`, the `requiresConfirm` list), the declarations stay separate and
1589
+ `test/concept-dirs-agreement.test.ts` fails if any of them drifts. The two scripts that `import` the constant
1590
+ instead (`vault_search`, `vault_rag`) cannot drift, so what is asserted about them is the other failure -
1591
+ importing it and then not iterating it.
1592
+
1593
+ **Why this deserved a decision rather than a commit message: reading one directory of two is silently wrong, not
1594
+ broken.** Every reader here degrades by *losing half its answers* - fewer search hits, fewer concepts for the
1595
+ assistant, "未找到概念" from MCP - while continuing to exit 0 and print a plausible result. Nothing in the repo
1596
+ would have gone red. That is the same failure class this project keeps recording (a path written in two places;
1597
+ a directory declared and never written), and it is the reason the agreement test exists rather than a comment.
1598
+
1599
+ **A name belongs to exactly one page, and that had to be enforced in code.** Splitting the directories created
1600
+ 11 concepts with a page on both sides on the very first run (`deepseek.md` in `Wiki/` and in `Chat/`). Two files
1601
+ with the same stem make a bare `[[DeepSeek]]` ambiguous: Obsidian silently picks one and the other is reachable
1602
+ only by its full path, which is the same failure the `会话-` prefix was introduced for on the cards - and, as
1603
+ there, **nothing errors**. So `build_jobs` skips a name that already has a page on the other line, and the run
1604
+ reports the two skip reasons separately (a re-run versus a deliberate non-duplicate), because a single "skipped
1605
+ N" line would read as if all of them were re-runs. The alternative - keeping both pages, one per line - was
1606
+ rejected: a browseable graph is the thing the user asked for, and an ambiguous link is not a link.
1607
+
1608
+ **A migrated parameter is a silent failure too.** `conceptNeighbors(pageName, wikiDirs)` took one directory and
1609
+ now takes a list; a caller still passing a string is not a type error at runtime, and `for (const dir of
1610
+ wikiDirs)` walks the string **one character at a time**, so the function answers "no neighbours" with full
1611
+ confidence. `tsc` covers `src/**` but not `test/`, which is where the stale call was. It now throws on a string:
1612
+ a wrong answer that looks like an empty answer is worse than a crash, and the same reasoning already governs the
1613
+ "no data" cases elsewhere in this file.
1614
+
1615
+ **What was NOT changed.** `output/wechat-vault` still holds both, and the chat side is still produced by
1616
+ `compile_wiki --source output/chat-notes --output output/wechat-vault/Chat/Concepts` **explicitly** rather than by
1617
+ teaching `compile_wiki` to iterate `CONCEPT_DIRS` - it produces one line's pages per run, and a default that
1618
+ writes both would mix the two corpora into whichever directory ran last. `user_notes` is unchanged: it reads a
1619
+ whitelist of the user's own layers, so the new `Chat/` directory is out of scope by construction rather than by
1620
+ an added exception.
1621
+
1622
+ ## D-050: The WeChat channel's failures are classified, retried with backoff, and reported where the user looks
1623
+
1624
+ **Status:** Active
1625
+
1626
+ **Decision:** `wechatMessageService.startPolling` classifies every failure before acting on it. `errcode: -14`
1627
+ (or HTTP 401) means the token is invalid - retrying cannot succeed - so it is reported once, in a single
1628
+ unmissable line naming the remedy, and `isChannelActive()` turns false so the panel stops claiming the channel
1629
+ works; the loop keeps polling rather than exiting, because the local entrance is unaffected and a restart is the
1630
+ user's decision. Everything else is `retryable` and retried with 1s→30s exponential backoff, reset by any healthy
1631
+ poll. Exceptions thrown by registered message callbacks are logged rather than swallowed.
1632
+
1633
+ **Reason:** this was fixed by checking the vendor's own implementation instead of guessing -
1634
+ `third-party/WeKnora/internal/im/wechat/longpoll.go:151` states `ErrCode == -14 → ErrTokenExpired`, while this
1635
+ repository checked `-1 || 401`. The consequence of a wrong code is not an error: it reclassifies "expired" as
1636
+ ordinary, so the loop retried every 5 seconds forever and the user saw nothing at all. Two further differences
1637
+ were closed at the same time (fixed 5 s interval → backoff; `try { cb(msg) } catch {}` → logged), and one earlier
1638
+ claim was **retracted**: the long-poll timeout is 40 s here and 40 s in the vendor's `longPollHTTPTimeout`, so
1639
+ that item from the 2026-09-17 comparison was not a divergence. The reason the *reporting* half is in this
1640
+ decision rather than the code: the old `isChannelActive()` meant "a token was configured at startup", so every
1641
+ status surface kept saying the channel was fine - silence, not breakage, was the real defect.
1642
+
1643
+ **Consequences:** `isChannelActive()` now means "the channel is usable", which is what the panel actually wants to
1644
+ show; a fake channel without `isTokenExpired` is treated as healthy (the method is optional on the interface). A
1645
+ re-login does **not** heal a running daemon - it still holds the old token in memory - so OPERATIONS.md says to
1646
+ restart it, and the log line says so too. `assistant status --json` still derives `channelActive` from the
1647
+ endpoint file rather than from the live loop, so after an expiry it can disagree with the panel for as long as
1648
+ the daemon runs: recorded here as a known gap rather than fixed quietly, because publishing live state into that
1649
+ file is its own change. The live expiry path has never been observed end to end (it needs the server to send
1650
+ `-14`); what is tested is the classification, the backoff and a driven loop.
1651
+
1652
+ ## D-049: Conversations become knowledge notes locally, the model call is the only egress, and it is the user's call
1653
+
1654
+ **Status:** Active
1655
+
1656
+ **Decision:** `chat-notes` turns recent conversations into knowledge cards (`output/chat-notes/<会话>.md`), and
1657
+ `wiki compile --source ./output/chat-notes` aggregates them into concept pages through the pipeline that already
1658
+ exists. It is two-phase in the same shape as the rest of the project: `--dry-run` reports how many conversations,
1659
+ messages and characters would be sent (reading only local data), and nothing leaves the machine without `--yes` -
1660
+ one model call per conversation, which is the only egress. What it writes is local markdown; it never sends a
1661
+ message.
1662
+
1663
+ **Reason:** the knowledge base had exactly one source - the article line, `output/biz-daily` - while conversations
1664
+ could be searched but never condensed into anything, which is the half of "a personal WeChat knowledge base" that
1665
+ was missing. Reusing `compile_wiki`'s input contract (frontmatter + `[[wikilinks]]`, `--source` already supported)
1666
+ means one concept format, one backlink mechanism, and no second aggregator to keep in step - the three shapes asked
1667
+ for (per-conversation cards, topic pages, people/timeline pages) fall out of one producer plus the aggregator that
1668
+ was already there. Two constraints follow from that choice rather than from taste: **the wikilinks are rendered by
1669
+ our code**, never by the model, because `scan_articles` collects every wikilink in a body and cannot tell who wrote
1670
+ it - a stray one would invent a concept, so free text from the model has `[[...]]` stripped; and **thin
1671
+ conversations get no card at all**, because a three-line chat yields a card that says "they said two things" while
1672
+ costing a model call and adding one contentless source to a concept page.
1673
+
1674
+ **Consequences:** running it over a wide window sends a lot of chat text - measured on this machine, 30 days is 37
1675
+ conversations / 2913 messages / 105,607 characters - so the preview, not a limit, is what the user steers by; the
1676
+ number is also why this is not offered as an assistant tool or over MCP (a model-initiated call of this size would
1677
+ be an egress nobody asked for). Cards are rolling snapshots (one file per conversation, rewritten with its recorded
1678
+ window) while concept pages accumulate: cards are the snapshot, concepts are the ledger. Fabrication is constrained
1679
+ in the prompt (compiler not author, contradictions stay side by side) rather than by post-hoc verification of every
1680
+ claim - the project keeps its judgement in a separate layer and a second implementation of "is this claim
1681
+ supported" was not wanted. Not copied on purpose from the reference implementation this borrowed its phrasing from
1682
+ (Tencent/WeKnora, read for this work): page revisions. The other half - a check over the produced pages - was recorded
1683
+ here as a known gap and **closed the same day** by `wiki lint` (`scripts/wiki_lint.py`, surfaced as `weflow-cli wiki
1684
+ lint`): it reports broken links (a `[[card.md]]` whose card is gone), orphan pages, empty pages and duplicate titles,
1685
+ locally and with no model calls. One judgement in it is worth keeping: it **separates "a concept the pages link to
1686
+ that has no page yet" from a break** - on the first real run that was 38 expansion candidates against 0 breaks -
1687
+ because reporting both as "40 dead links" teaches the reader to ignore the tool. A second judgement came from the
1688
+ first run too: card-side links count as inbound links, otherwise every page is reported as an orphan (the pages
1689
+ mostly link forward to concepts that do not exist yet, so page-to-page inlinks are nearly empty by construction).
1690
+
1691
+ ## D-048: There is no in-process plugin loader; extension is in-repo, and the boundary for outside code is MCP
1692
+
1693
+ **Status:** Active
1694
+
1695
+ **Decision:** Code that extends this project is added to this repository (fork + PR). Code kept outside it reaches the
1696
+ project only through MCP or the CLI's `--json` surface. No plugin directory, no adapter registry, no public import
1697
+ surface (`package.json` has `bin` and nothing else). `docs/EXTENDING.md` is the checklist for the in-repo path and states
1698
+ this position rather than leaving it to be inferred.
1699
+
1700
+ **Reason:** An in-process plugin would hold exactly the reach of the code it joins - the decrypted database path, the
1701
+ configuration (which contains every API key), the filesystem, and the message channel. The standing rule for this
1702
+ project is that sending, the assistant, MCP and cloud AI each keep an *explicit* permission boundary; a plugin loader
1703
+ removes the boundary by construction rather than crossing it, and no amount of manifest-declared "capabilities" repairs
1704
+ that, because the plugin is not sandboxed. MCP is the alternative that does keep a boundary: it is enumerable
1705
+ (`capabilities --json` lists what is exposed), scoped (the four tools that send user data to cloud models return a
1706
+ preview unless the caller passes `confirm: true`), and switchable off without touching this repository. The cost is
1707
+ accepted knowingly: third-party code cannot add a tool, and anything a plugin loader would have made convenient now
1708
+ needs a PR. The roadmap keeps a plugin/adapter mechanism as a future item with its own constraint already written down
1709
+ (extensions must not read configuration, databases or arbitrary files directly, and must be individually disableable) -
1710
+ so the door is not nailed shut, but nothing about it is implemented, and `docs/EXTENDING.md` lists it under "what does
1711
+ not exist yet" so a reader cannot mistake the gap for an oversight.
1712
+
1713
+ **Consequences:** Adding a capability means editing this source tree, which is why the registries were made declarative
1714
+ and guarded (`test/tool-registry.test.ts`, `test/config-keys.test.ts`) - a framework that can only be extended by
1715
+ editing it had better make "did I edit all the places?" answerable. The last unguarded extension point - the CLI's
1716
+ interactive menu, where a menu entry with no `switch` case does nothing when picked and `runCmd` silently no-ops on a
1717
+ renamed command - was covered on 2026-09-25 by `test/cli-menu.test.ts`, so every recipe in `docs/EXTENDING.md` now
1718
+ names the test that catches a missed step. Should a loader be built later, these guards are the parts that would have to
1719
+ be re-derived for the plugin path rather than deleted.
1720
+
297
1721
  ## Decision Template
298
1722
 
299
1723