weflow-cli 1.8.1 → 1.9.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 (100) hide show
  1. package/CHANGELOG.md +359 -0
  2. package/OPERATIONS.md +79 -0
  3. package/README.en.md +9 -2
  4. package/README.md +12 -2
  5. package/bin/weflow-cli-electron.cjs +2 -0
  6. package/bin/weflow-cli.ts +648 -79
  7. package/dist/bin/weflow-cli.js +675 -78
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/http.d.ts +14 -0
  10. package/dist/mcp-server/http.d.ts.map +1 -0
  11. package/dist/mcp-server/http.js +84 -0
  12. package/dist/mcp-server/http.js.map +1 -0
  13. package/dist/mcp-server/httpConfig.d.ts +50 -0
  14. package/dist/mcp-server/httpConfig.d.ts.map +1 -0
  15. package/dist/mcp-server/httpConfig.js +104 -0
  16. package/dist/mcp-server/httpConfig.js.map +1 -0
  17. package/dist/mcp-server/index.d.ts +13 -1
  18. package/dist/mcp-server/index.d.ts.map +1 -1
  19. package/dist/mcp-server/index.js +39 -373
  20. package/dist/mcp-server/index.js.map +1 -1
  21. package/dist/src/panel/server.d.ts.map +1 -1
  22. package/dist/src/panel/server.js +26 -1
  23. package/dist/src/panel/server.js.map +1 -1
  24. package/dist/src/services/assistantEval.d.ts +69 -0
  25. package/dist/src/services/assistantEval.d.ts.map +1 -1
  26. package/dist/src/services/assistantEval.js +270 -8
  27. package/dist/src/services/assistantEval.js.map +1 -1
  28. package/dist/src/services/assistantScenes.d.ts +81 -0
  29. package/dist/src/services/assistantScenes.d.ts.map +1 -0
  30. package/dist/src/services/assistantScenes.js +322 -0
  31. package/dist/src/services/assistantScenes.js.map +1 -0
  32. package/dist/src/services/assistantService.d.ts +14 -4
  33. package/dist/src/services/assistantService.d.ts.map +1 -1
  34. package/dist/src/services/assistantService.js +82 -6
  35. package/dist/src/services/assistantService.js.map +1 -1
  36. package/dist/src/services/assistantSkills.d.ts +94 -0
  37. package/dist/src/services/assistantSkills.d.ts.map +1 -0
  38. package/dist/src/services/assistantSkills.js +330 -0
  39. package/dist/src/services/assistantSkills.js.map +1 -0
  40. package/dist/src/services/assistantTools.d.ts +7 -0
  41. package/dist/src/services/assistantTools.d.ts.map +1 -1
  42. package/dist/src/services/assistantTools.js +770 -61
  43. package/dist/src/services/assistantTools.js.map +1 -1
  44. package/dist/src/services/assistantTrace.d.ts +4 -0
  45. package/dist/src/services/assistantTrace.d.ts.map +1 -1
  46. package/dist/src/services/assistantTrace.js +9 -0
  47. package/dist/src/services/assistantTrace.js.map +1 -1
  48. package/dist/src/services/configService.d.ts +7 -0
  49. package/dist/src/services/configService.d.ts.map +1 -1
  50. package/dist/src/services/configService.js +4 -2
  51. package/dist/src/services/configService.js.map +1 -1
  52. package/dist/src/services/outboundSend.d.ts +53 -0
  53. package/dist/src/services/outboundSend.d.ts.map +1 -0
  54. package/dist/src/services/outboundSend.js +90 -0
  55. package/dist/src/services/outboundSend.js.map +1 -0
  56. package/dist/src/services/pythonBridge.d.ts.map +1 -1
  57. package/dist/src/services/pythonBridge.js +1 -0
  58. package/dist/src/services/pythonBridge.js.map +1 -1
  59. package/dist/src/utils/python.js +2 -2
  60. package/dist/src/utils/python.js.map +1 -1
  61. package/docs/BRANCHES.md +37 -0
  62. package/docs/DECISIONS.md +350 -0
  63. package/docs/EXTENDING.md +29 -3
  64. package/docs/MCP.md +56 -2
  65. package/docs/PROJECT_STATE.md +121 -9
  66. package/mcp-server/http.ts +108 -0
  67. package/mcp-server/httpConfig.ts +126 -0
  68. package/mcp-server/index.ts +344 -681
  69. package/package.json +1 -1
  70. package/resources/panel/ball-position.cjs +48 -2
  71. package/resources/panel/index.html +6 -4
  72. package/resources/panel/main.cjs +137 -5
  73. package/resources/panel/mascot-base.png +0 -0
  74. package/resources/panel/mascot-iris.png +0 -0
  75. package/resources/panel/mascot-peek.png +0 -0
  76. package/resources/panel/panel.css +107 -63
  77. package/resources/panel/preload.cjs +26 -0
  78. package/resources/panel/renderer.js +167 -10
  79. package/scripts/_utils.py +47 -1
  80. package/scripts/backfill_articles.py +202 -21
  81. package/scripts/biz_daily.py +8 -31
  82. package/scripts/bonds.py +331 -0
  83. package/scripts/chat_notes.py +551 -470
  84. package/scripts/chat_stats.py +8 -7
  85. package/scripts/compile_wiki.py +24 -2
  86. package/scripts/export_chat_html.py +3 -1
  87. package/scripts/mcp_bridge.py +6 -7
  88. package/scripts/wechat_voice.py +9 -0
  89. package/scripts/wiki_lint.py +30 -7
  90. package/src/panel/server.ts +25 -1
  91. package/src/services/assistantEval.ts +323 -12
  92. package/src/services/assistantScenes.ts +342 -0
  93. package/src/services/assistantService.ts +81 -6
  94. package/src/services/assistantSkills.ts +364 -0
  95. package/src/services/assistantTools.ts +738 -59
  96. package/src/services/assistantTrace.ts +9 -0
  97. package/src/services/configService.ts +412 -403
  98. package/src/services/outboundSend.ts +126 -0
  99. package/src/services/pythonBridge.ts +1 -0
  100. package/src/utils/python.ts +2 -2
@@ -1,6 +1,6 @@
1
1
  # Project State
2
2
 
3
- > Last reviewed: 2026-09-28 (the Vault's copy of an article is cleaned as it is copied in - the eight WeChat interface phrases go, the frontmatter and any fenced code block stay, and the fetched original in `output/biz-daily` stays byte-for-byte (D-055); the rule that decides which days that copy covers had been keyed off a `backfilled` marker that only one of the two writers sets, so **12 days were never copied and never reported as skipped** - it now asks whether the day holds an article, 187 → 199 days; `wiki lint`'s duplicate-title report was **rebuilt**, not re-tuned: the old rule (a common substring of ≥5 characters covering ≥40% of the shorter title) had gone from useful at 5,062 pages to **424,697 groups** at 22,374, so it now reports two tiers - 314 groups that differ only in spacing/hyphen/case/plural and use the **same normaliser as `wiki compile --merge-duplicates`** (so the list is directly executable; measured, every group is one that tool would merge), and 378 groups where one name is the other plus a qualifier, labelled as a lead rather than a conclusion because hand-sampling puts its precision near half (a "hub" filter takes out the worst of it - `agent` alone is contained in 731 names). Per-directory, because the two concept directories are two knowledge bases and the merge tool runs per directory - the first version compared across both and so told the reader that two cross-line groups were mergeable when the tool could never touch them; the merge was then run on the resulting list, absorbing **341 pages** (338 article-line, 3 chat-line) into their duplicates, and `wiki lint` still reports **0 broken links** because `aliases` catch the old names. That run also exposed a latent defect in the merge itself: it carried `sources:` over from the surviving page only, so **all 315 merged groups** lost at least one card name from their frontmatter. That key drives `来源/文章|聊天|收藏` tag inference, so the fix is to union it, and the 315 pages were repaired from the pre-merge backup. Measured afterwards: the loss changed no tag, because **0 of 22,033 pages carry sources from more than one line** - latent, not live; the knowledge base now holds **35,463** article copies, **34,092** reading notes and **22,374** concept pages across both lines, most of the article ones built with no model call; previously, 2026-09-27 (the two knowledge bases - articles and conversations - were split into one Vault with two directories, `Wiki/Concepts` and `Chat/Concepts`, which meant teaching five readers that a second directory exists, since reading one of two halves the results silently rather than failing; `wiki lint` went from 7,872 broken links to 0; previously, 2026-09-26: the knowledge base has a health check and a gate on what deserves a page - 120 article notes produced 387 concepts of which 37 were durable, and the 108 resulting pages (86 from articles, 6 from conversations, 6 from WeChat favourites, 5 from the user's own notes - a fourth source added the same day, which reads hand-written notes read-only and marks its summaries as model readings) passed `wiki lint` with 0 breaks, and the generated vault now explains itself (generated-vs-hand-edited, `verified: false`, `知识/概念` and `来源/文章|聊天|收藏` nested tags, and the graph-filter string to use - conventions borrowed from a local Obsidian vault built for exam prep); a 1200-token cap was truncating the chat line's JSON on long conversations and is now 2000; the WeChat channel reports an expired token instead of retrying forever; conversations now feed the knowledge base as cards the existing wiki pipeline aggregates; drafted replies: text only, never sent, and never judged against the user's own last message; the assistant may not invent a reason for a tool failure; every extension point in docs/EXTENDING.md now names the test that catches a missed step; extension is in-repo or over MCP, by decision). This is the current maintenance snapshot, not a release note. Keep it factual and update it with meaningful project changes.
3
+ > Last reviewed: 2026-10-01 (the historical backfill can fetch one topic at a time - `--topic AI` uses the classifier the script already had, `_guess_topic`, which reads only an article's title and its account name and never the body, so it selects exactly the articles a full run would file under that topic: over 2025-09-05 ~ 2026-03-02 the untouched backlog is 9,207 articles across 156 days (official accounts only, `gh_%`), 1,758 of them AI, and a full run needs 3,929 network fetches against the AI run's 1,208 (the rest already carry enough text in the database); the window has since been fetched in full - **8,754** articles over 157 days in an archive whose index matches the directory on **every** day, then carried through reading notes and cards into the Vault, which now holds **49,953** concept pages with **0** broken links. A day written that way is **partial and says so** - `topicFilter` in `.articles.json`, the missing topics named in its README, and `day_done` refusing to treat it as finished, so a later full run still covers it; auditing that turned up the same defect already shipped in `--limit-per-day`, where a sliced day read as complete and everything past the limit was silently out of scope, which now carries its own marker and is redone on the next run (D-060); previously, 2026-09-28 (the Vault's copy of an article is cleaned as it is copied in - the eight WeChat interface phrases go, the frontmatter and any fenced code block stay, and the fetched original in `output/biz-daily` stays byte-for-byte (D-055); the rule that decides which days that copy covers had been keyed off a `backfilled` marker that only one of the two writers sets, so **12 days were never copied and never reported as skipped** - it now asks whether the day holds an article, 187 → 199 days; `wiki lint`'s duplicate-title report was **rebuilt**, not re-tuned: the old rule (a common substring of ≥5 characters covering ≥40% of the shorter title) had gone from useful at 5,062 pages to **424,697 groups** at 22,374, so it now reports two tiers - 314 groups that differ only in spacing/hyphen/case/plural and use the **same normaliser as `wiki compile --merge-duplicates`** (so the list is directly executable; measured, every group is one that tool would merge), and 378 groups where one name is the other plus a qualifier, labelled as a lead rather than a conclusion because hand-sampling puts its precision near half (a "hub" filter takes out the worst of it - `agent` alone is contained in 731 names). Per-directory, because the two concept directories are two knowledge bases and the merge tool runs per directory - the first version compared across both and so told the reader that two cross-line groups were mergeable when the tool could never touch them; the merge was then run on the resulting list, absorbing **341 pages** (338 article-line, 3 chat-line) into their duplicates, and `wiki lint` still reports **0 broken links** because `aliases` catch the old names. That run also exposed a latent defect in the merge itself: it carried `sources:` over from the surviving page only, so **all 315 merged groups** lost at least one card name from their frontmatter. That key drives `来源/文章|聊天|收藏` tag inference, so the fix is to union it, and the 315 pages were repaired from the pre-merge backup. Measured afterwards: the loss changed no tag, because **0 of 22,033 pages carry sources from more than one line** - latent, not live; the knowledge base now holds **35,463** article copies, **34,092** reading notes and **22,374** concept pages across both lines, most of the article ones built with no model call; previously, 2026-09-27 (the two knowledge bases - articles and conversations - were split into one Vault with two directories, `Wiki/Concepts` and `Chat/Concepts`, which meant teaching five readers that a second directory exists, since reading one of two halves the results silently rather than failing; `wiki lint` went from 7,872 broken links to 0; previously, 2026-09-26: the knowledge base has a health check and a gate on what deserves a page - 120 article notes produced 387 concepts of which 37 were durable, and the 108 resulting pages (86 from articles, 6 from conversations, 6 from WeChat favourites, 5 from the user's own notes - a fourth source added the same day, which reads hand-written notes read-only and marks its summaries as model readings) passed `wiki lint` with 0 breaks, and the generated vault now explains itself (generated-vs-hand-edited, `verified: false`, `知识/概念` and `来源/文章|聊天|收藏` nested tags, and the graph-filter string to use - conventions borrowed from a local Obsidian vault built for exam prep); a 1200-token cap was truncating the chat line's JSON on long conversations and is now 2000; the WeChat channel reports an expired token instead of retrying forever; conversations now feed the knowledge base as cards the existing wiki pipeline aggregates; drafted replies: text only, never sent, and never judged against the user's own last message; the assistant may not invent a reason for a tool failure; every extension point in docs/EXTENDING.md now names the test that catches a missed step; extension is in-repo or over MCP, by decision). This is the current maintenance snapshot, not a release note. Keep it factual and update it with meaningful project changes.
4
4
 
5
5
  ## Purpose
6
6
 
@@ -23,24 +23,88 @@ WeFlow CLI is a local-first command-line tool and MCP server for user-authorized
23
23
  | --- | --- | --- |
24
24
  | Local chat data | Query sessions, contacts, messages, favorites, Moments cache, and exports. HTML chat exports decode NT compressed media records, locally cached WeChat 4.x V2 image containers, entity-escaped emoji XML, and remote emoticons; app cards preserve links and embed available公众号/Bilibili covers. Media matching prefers server-message resources, exact local-message-ID plus timestamp pairs, and a unique local-ID fallback; high-bit encoded image rows are resolved through the same conversation cache. | `sessions`, `contacts`, `messages`, `export`, `fav`, `sns` |
25
25
  | Initialization | Verify and reuse existing local database access by default; refresh only when needed. Missing-key tests can run without changing saved configuration. | `init`, `init --refresh`, `init --test-missing-keys`, `config forget-keys`, `check` |
26
+ | Relationship temperature | Who is going quiet, who is heating up, and who is reachable **only** here. Reads the local message database directly: read-only, no model, no network (D-062). Three groups - `cooling` (substantial history then silence, ranked by messages x silent days), `heating` (last 7 days at least twice the long-run daily rate), `only-wechat` (no phone/email/meeting-up ever appears in the history). Only 1:1 chats, only text messages, and only accounts whose **id shape** is a person: anything with `@` (`@chatroom`/`@openim`/`@weclaw`), `filehelper` or `gh_` is out - ranking by volume alone produced no information, and the first irreplaceability rule was inverted (it is trivially true of bots, so it put a coffee-shop bot first). Accounts that look like a person but whose name reads as a shop are **flagged, not dropped**, and every exclusion is printed with its reason. Deliberately **not** in the assistant or MCP tool tables: its output is an inference about third parties. | `bonds` |
26
27
  | Official-account daily | Filter configured sources, preserve source categories, backfill incomplete yesterday output before an unqualified today run, fetch articles, classify them, create summaries, generate a local HTML reader, and synchronize reader favorites into local files. Topic (6-way), relevance (3-level) and "does this belong in today's report" (yes/no) are decided by TypeSafe's Jev when `typesafeApiKey` is configured - concurrently, before the serial summary loop (12 articles in 3.3s at 6 workers against ~12s one at a time), and by the previous LLM-parse path otherwise; `--classifier llm` forces the latter. The report's admission rule is applied on both loader paths and can be bypassed with `--include-all`. | `daily`, `daily favorites`, `daily-stats`, `daily-server` |
27
- | Knowledge workflows | Wiki compilation (`compile_wiki` aggregates `[[concept]]` links into Vault pages; **the article corpus produced none as of 2026-09-26** (the 1,633 article notes then in the Vault carried only their own topic link and `## 关联网络` path links, measured over the whole corpus), so an article-side concept-extraction step was missing; `article-notes` (added the same day) is that step, and it works: 30 article notes produced 110 concepts and 10 concept pages on this machine, which are now the first knowledge pages the assistant can search (`vault search`, `search_knowledge`). The corpus has grown since - 7,119 concept cards, and 22,374 concept pages across both lines as of 2026-09-28. The degeneracy reported in that trial was an artefact of a 120-card sample: with all 710 September articles carded, the topic field splits into 学术/新闻/政治 (62/33/25 of 108 pages), so the earlier "the classifier puts everything in one bucket" judgement is withdrawn. Two upstream noise sources showed up in that trial. The first - WeChat reader UI text inside article summaries - is **fixed**, and the backfill this line used to defer has since been run: measured over the corpus it was 1,484 of 1,633 notes (the corpus has grown to 34,092 since), the cleaner runs on both write paths, and `create_reading_notes --clean-summaries` took the existing notes to **0**; the Vault's `Sources/WeChat` copies were cleaned on 2026-09-28 as well (D-055), so the residue now survives only in `output/biz-daily`, which is the archive. The second is **measured but not fixed**: of 224 notes whose titles read as news, 40 are labelled `学术` - that classifier belongs to the daily pipeline and changing its criteria without labels would be a guess. Fed by both sources: `output/biz-daily` articles and, since 2026-09-26, `output/chat-notes` conversation cards produced by the confirmed `chat-notes` command; **the graph is also kept free of duplicate nodes by `wiki compile --merge-duplicates`** (74 groups merged, 80 pages removed, and 98 links that pointed at the removed names still resolve through `aliases` - see D-053); **most of the article line's pages were never written by a model** - `wiki compile --pages-from-cards` assembles a page from material the cards already carry (the definition is the card's own `desc`, the related concepts are the other concepts from the same article, the source is that article) and marks it `summary_by: card`. That label is deliberate and is not the same as "no model was involved": the `desc` line was written by `article_notes`, so the quality is that line's quality, and a few read as meta-descriptions of where the term appeared rather than as definitions. 17,383 pages at ¥0; the model-based route was priced at ¥31 and rejected, because every one of those concepts is mentioned by exactly one article (D-054); **the pages accumulate only if `wiki compile --refresh-sources` is run** - `wiki compile` itself only builds pages for concepts that do not have one, so an existing page never gains an edge (measured: one run added 3,164 edges across 324 article-line pages; it rewrites the body only, never the frontmatter, and never deletes a source line no current card accounts for - see D-052), semantic search (two-stage: similarity, then a decision-model rerank over the top 20 candidates; `--no-rerank` reverts) - the index covers 90 days of chat and 30 date-directories of articles **by default**, and `search-index --days / --article-days` changes that (the numbers were hardcoded at the call sites until 2026-09-25, and neither the command nor its preview said so), RAG, WeRead sync, reviews, reading notes, and staged Vault promotion. | `wiki`, `vault`, `search`, `chat`, `weread`, `review` |
28
+ | Knowledge workflows | Wiki compilation (`compile_wiki` aggregates `[[concept]]` links into Vault pages; **the article corpus produced none as of 2026-09-26** (the 1,633 article notes then in the Vault carried only their own topic link and `## 关联网络` path links, measured over the whole corpus), so an article-side concept-extraction step was missing; `article-notes` (added the same day) is that step, and it works: 30 article notes produced 110 concepts and 10 concept pages on this machine, which are now the first knowledge pages the assistant can search (`vault search`, `search_knowledge`). The corpus has grown since - 7,119 concept cards, and 22,374 concept pages across both lines as of 2026-09-28. The degeneracy reported in that trial was an artefact of a 120-card sample: with all 710 September articles carded, the topic field splits into 学术/新闻/政治 (62/33/25 of 108 pages), so the earlier "the classifier puts everything in one bucket" judgement is withdrawn. Two upstream noise sources showed up in that trial. The first - WeChat reader UI text inside article summaries - is **fixed**, and the backfill this line used to defer has since been run: measured over the corpus it was 1,484 of 1,633 notes (the corpus has grown to 34,092 since), the cleaner runs on both write paths, and `create_reading_notes --clean-summaries` took the existing notes to **0**; the Vault's `Sources/WeChat` copies were cleaned on 2026-09-28 as well (D-055), so the residue now survives only in `output/biz-daily`, which is the archive. The second is **measured but not fixed**: of 224 notes whose titles read as news, 40 are labelled `学术` - that classifier belongs to the daily pipeline and changing its criteria without labels would be a guess. Fed by both sources: `output/biz-daily` articles and, since 2026-09-26, `output/chat-notes` conversation cards produced by the confirmed `chat-notes` command - which, since 2026-10-01, carries voice messages as **text** rather than a bare `[语音]`: it reads the local faster-whisper transcript cache (content-addressed, `output/.voice-cache` - one definition, in `wechat_voice`) and labels each one `[语音·本机转写·未校对]`, because a machine transcript of someone's speech may be wrong and the model must not quote it as fact. Measured on the 30-day window: **54 of 125** voice messages carry text that used to be a placeholder; the ones still missing are counted and printed, and `--transcribe-voice` fills them locally (the cache is the progress, so an interrupted run resumes); **the graph is also kept free of duplicate nodes by `wiki compile --merge-duplicates`** (74 groups merged, 80 pages removed, and 98 links that pointed at the removed names still resolve through `aliases` - see D-053); **most of the article line's pages were never written by a model** - `wiki compile --pages-from-cards` assembles a page from material the cards already carry (the definition is the card's own `desc`, the related concepts are the other concepts from the same article, the source is that article) and marks it `summary_by: card`. That label is deliberate and is not the same as "no model was involved": the `desc` line was written by `article_notes`, so the quality is that line's quality, and a few read as meta-descriptions of where the term appeared rather than as definitions. 17,383 pages at ¥0; the model-based route was priced at ¥31 and rejected, because every one of those concepts is mentioned by exactly one article (D-054); **the pages accumulate only if `wiki compile --refresh-sources` is run** - `wiki compile` itself only builds pages for concepts that do not have one, so an existing page never gains an edge (measured: one run added 3,164 edges across 324 article-line pages; on 2026-10-01, after the whole
29
+ pre-2026-03-03 window had been carded, another run added **8,551** edges across **3,506** pages - verified against a
30
+ snapshot of all 48,426 pages: the file count changed, the frontmatter that changed is **0**, and the files that lost
31
+ a source line are **0**, so the rewrite is what it claims to be, additive; it rewrites the body only, never the frontmatter, and never deletes a source line no current card accounts for - see D-052), semantic search (two-stage: similarity, then a decision-model rerank over the top 20 candidates; `--no-rerank` reverts) - the index covers 90 days of chat and 30 date-directories of articles **by default**, and `search-index --days / --article-days` changes that (the numbers were hardcoded at the call sites until 2026-09-25, and neither the command nor its preview said so), RAG, WeRead sync, reviews, reading notes, and staged Vault promotion. The article line was extended on 2026-10-01 with the **whole**
32
+ pre-2026-03-03 backlog, not only its AI part: **8,754** articles over 157 days (1,702 AI + 7,052 others, of which
33
+ 2,709 needed fetching), **8,670** reading notes (free and local - 94 fewer than the index because a note file is named
34
+ `<date>-<title>` and one title republished by two accounts the same day makes a single file; measured per day, that
35
+ accounts for all 94), **8,670 new cards** at ¥0.0018 each (**~¥16** - the base now holds 17,041 in all; the first pass at 6 workers drew **HTTP 429** on
36
+ 4,884 of 6,968 articles, and the same command at **2 workers** then carded all 4,883 with none - concurrency, not
37
+ quota), and **24,525** concept pages at ¥0, taking the base to **49,953 pages** across both lines with **0 broken
38
+ links**. That build exposed a limit of `--pages-from-cards`: the file name is the concept name sanitised and cut to 60
39
+ characters, while `existing` is snapshotted **once before the loop**, so two concept names differing only in case
40
+ (`DeepSeek`/`Deepseek`, `EarthAi`/`EarthAI`, `vLLM`/`vllm`) are both built, the second overwrites the first on a
41
+ case-insensitive filesystem, and **both are counted** - it reported 4,934 built and wrote 4,890, and the same
42
+ arithmetic explains `--dry-run`'s "already have 28,934" against 28,791 files on disk. Measured over the whole card
43
+ corpus: **211 case-insensitive collisions, 243 names sharing a page**; a case-sensitive comparison sees **0**, which
44
+ is why nothing catches it. **Fixed on 2026-10-01** by grouping names on the file name they produce - one page per
45
+ group, the other spellings written as `aliases`, which is what `--merge-duplicates` already did for one concept
46
+ written two ways. **53 pages had been lost this way** in that day's two builds (44 AI, 9 non-AI); the 233 existing
47
+ pages that were missing a spelling were given it back (264 aliases), so `[[AI agent]]` and `[[AI Agent]]` now reach
48
+ the same page. Only spellings that actually occur are aliased - a name nobody wrote is not invented. The same lossy
49
+ transform had a second symptom, **fixed on 2026-10-01**: when a concept name
50
+ contains `:` (or `/`, `?`, `*`, `"`, `<`, `>`, `|`) the file name got `_` instead, so the card's `[[Qwen3.5:9B]]`
51
+ matched no stem - and because card-side links are **counted but never checked for brokenness** (only a page's own
52
+ outgoing links are), that link was neither an inbound edge nor a broken link, leaving the page silently unlinked;
53
+ in Obsidian `[[Qwen3.5:9B]]` would not open `Qwen3.5_9B.md` either. The builder now writes the original name into
54
+ `aliases:` and `wiki_lint` counts an alias as an inbound edge - it already accepted one as "exists", which is why no
55
+ broken link had ever been reported. Chasing the last of these exposed a worse one underneath: `parse_frontmatter`
56
+ split list values with `item.strip().strip('"\'')`, which removes every leading/trailing quote rather than a matched
57
+ pair, so `AI 长出"手脚"` read back as `AI 长出"手脚`; `wiki_lint` then stripped quotes a second time off values the
58
+ parser had already unwrapped. One character, silently, for any frontmatter list or scalar whose value ends in a quote.
59
+ Both now unwrap a matched pair only. Measured afterwards: **28 orphan pages → 1** (that one, `DESIGN`, is mentioned by
60
+ no card at all) and still 0 broken links. | `wiki`, `vault`, `search`, `chat`, `weread`, `review` |
28
61
  | Incremental read | `sync run` reads a time window, deduplicates against the previous checkpoint and writes coverage state; `sync status` reports it without touching the database; `sync verify` re-reads the recorded window and compares bounds and counts. Every run reports `coverage` as `complete`, `unverified` or `partial`, and a partial run never advances `lastSuccessfulRun`. The window lower bound is offered to the NT backend as a pushdown hint (`messages --from`), but `syncService` still applies the window itself, so backends without range support stay correct - the pushdown is asymptotic, and it measured no faster on a 925-message conversation. It does **not** offer a stable cursor - see D-027. | `sync run`, `sync status`, `sync verify` |
29
62
  | Calibration | `scripts/quality_eval.py` samples real articles, scores each through the production path, and writes a three-field labelling file (`topic`/`relevance`/`include`); `score` reports topic agreement (Jev vs the stored labels), relevance agreement plus mean-level error, a calibration table by probability bucket, and **two** threshold sweeps (the inclusion cut and the relevance cuts - both were guessed, and both are now older than the first labelled set). The printed sheet is **blind**: it does not show Jev's answers, because seeing them first inflates agreement by an unmeasurable amount. `consistency` is the half that needs **no** labels: it reports how often the two questions answer the same thing two ways. Labels stay in `~/.weflow-cli/labels/`. A **dry-run** label file made by the assistant exists in that directory (`DRYRUN-by-claude-*.json`) so the scoring path could be exercised before any human label existed; its numbers are **not accuracy** - annotator and model are the same kind of model, and nothing in the data separates agreement-because-right from agreement-because-kin. Do not treat it as a baseline. | `python scripts/quality_eval.py sample`, `score <file>`, `consistency <file>` |
30
63
  | Criteria A/B | `python scripts/jev_probe.py --criteria-ab --limit 24 --yes` asks the same articles twice - current criteria (Chinese, abstract score bins) vs an English variant whose score bins are concrete scenes, following `jev-chat-jarvis`'s two stated rules. It measures **what a wording change would move**, not whether it improves anything. Measured locally (24 + 12 articles): topic agreement 79% / 67%, relevance raw-score mean absolute difference 0.17 / 0.24, and the disagreements concentrate on the **AI↔学术** and **文学↔新闻** boundaries. | `python scripts/jev_probe.py --criteria-ab` |
31
64
  | Report uncertainty | The daily report ends with a `我拿不准的` section: entries whose inclusion probability sits on the threshold (admitted and excluded listed separately) and entries whose topic confidence is low. Computed locally from frontmatter, no extra model call. States outright when the articles predate the probability fields, so an absent section is not read as confidence. | `python scripts/generate_ai_report.py --date <date>` |
32
65
  | Media coverage | HTML export writes `<prefix>_media.json` with a status and reason per media item, and reports the remote-fetch budget it used. A media miss is recorded rather than shown as a bare placeholder. | `export <talker> html` |
33
- | MCP | Local stdio MCP server exposes bounded read and local transformation tools, including the `wechat.export_messages` versioned message contract. Publishing, messaging, memory writes, todo mutation, configuration changes, and deletion are excluded, The surface is **derived from the assistant tool table minus `save_memory` and `look_at_image`**, so it is not read-only and `capabilities --json` says so (`safety.mcpDefaultReadOnly: false` plus a `safety.mcpSurface` breakdown). `export_chat` writes files; `who_owes_reply`, `search_chats`, `search_semantic` and `draft_reply` send user data to cloud models (the first three to the decision model, `search_semantic` also to a cloud embedding service). `look_at_image` is excluded because that transport returns text only - the image would go nowhere and the reply still claimed the model could see it. The four tools that send user data to cloud models (`who_owes_reply`, `search_chats`, `search_semantic`, `draft_reply`) each need an explicit confirmation from a machine caller: without `confirm: true` they return a preview only (what would go out, and to whom) and nothing leaves the machine (D-047). | `mcp-config`, `mcp-server/index.ts` |
66
+ | MCP | Local MCP server over **stdio by default, or Streamable HTTP when asked for** (`--http`, off unless given) -
67
+ both transports serve the **same** table because it is derived from one place, so they cannot drift; the HTTP path
68
+ requires a token (no default, refuses to start without one), binds **loopback only**, and turns on the SDK's
69
+ DNS-rebinding protection, which defaults to off (D-061). It exposes bounded read and local transformation tools, including the `wechat.export_messages` versioned message contract. Publishing, messaging, memory writes, todo mutation, configuration changes, and deletion are excluded, The surface is **derived from the assistant tool table minus `save_memory` and `look_at_image`**, so it is not read-only and `capabilities --json` says so (`safety.mcpDefaultReadOnly: false` plus a `safety.mcpSurface` breakdown). `export_chat` writes files; `who_owes_reply`, `search_chats`, `search_semantic` and `draft_reply` send user data to cloud models (the first three to the decision model, `search_semantic` also to a cloud embedding service). `look_at_image` is excluded because that transport returns text only - the image would go nowhere and the reply still claimed the model could see it. The four tools that send user data to cloud models (`who_owes_reply`, `search_chats`, `search_semantic`, `draft_reply`) each need an explicit confirmation from a machine caller: without `confirm: true` they return a preview only (what would go out, and to whom) and nothing leaves the machine (D-047). | `mcp-config`, `mcp-server/index.ts` |
34
70
  | Judgement primitive | `decide` turns `{state, questions}` into typed answers with probabilities plus the call's cost, in one request (~1s regardless of question count). `--request` reads no local data; `--over <glob> --ask <text>` batch mode expands one glob times N questions into a single request and reads the matched files, echoing back how many characters of each it carried. Registered in `capabilities --json` as `primitives.decide`, deliberately **not** exposed over MCP. | `decide --request <file> --dry-run`, `decide --request <file> --yes` |
35
71
  | Reply debt | `awaiting` asks one decision request per conversation whether a reply is owed, and prints a ranked list with probabilities, debt age, conversation kind, and the strength of the evidence behind each verdict. `--html` writes a self-contained page that carries those same fields and **no chat text**, so it is shareable by construction. Reads chat text and sends it to the decision model, so it follows the `search` discipline: `--dry-run` previews (no egress), `--yes` runs. Registered in `capabilities --json`. | `awaiting --dry-run --json`, `awaiting --yes` |
36
- | Drafted replies | `draft <会话>` asks a decision model seven typed questions about one conversation (intent, what the other side needs, best action type, whether substance is owed, risk 0-9, money, unfulfilled commitment), **refuses** when the answer is money or risk >= 7, and otherwise generates candidate replies and ranks them with a ninth question. The judgement is **injected into the drafting prompt** and the user's own past messages are offered as a tone sample - both are things the reference implementation (`jev-chat-jarvis`) does not do, read from its source. It produces **text only**: no input-box filling, no key simulation, no focus stealing, and `capabilities --json` reports `sendsNothing: true` (D-047). The risk **threshold** is picked rather than calibrated, so the 0-9 band explains the refusal and does not decide alone; `commitment` constrains the prompt instead of refusing (`--gate-commitment` makes it hard). **The judgement step is degradable** (Jev's free period ended 2026-09-25): when that call fails the tool drafts on DeepSeek alone and returns `judged: false` with the reason, because the gate exists only as long as a judgement does - the CLI and the assistant tool both print "no judgement this time, the gate did not run" ahead of the candidates, and `deepseekApiKey` is now the only required key. The criteria are English while `reply_debt`'s money/commitment questions stay Chinese - two wording conventions against one model with **no A/B run**, recorded as an open question. Verified on real data: one live run produced three candidates, and a mutating check confirmed the tests catch a refusal that starts with `(` and a transcript that is not time-ordered. Reachable as the assistant tool `draft_reply`, which masks every message through `privacyGate` before handing the transcript to the script over stdin and **refuses outright in strict mode**. | `draft <会话> --dry-run --json`, `draft <会话> --yes` |
72
+ | Drafted replies | `draft <会话>` asks a decision model seven typed questions about one conversation (intent, what the other side needs, best action type, whether substance is owed, risk 0-9, money, unfulfilled commitment), **refuses** when the answer is money or risk >= 7, and otherwise generates candidate replies and ranks them with a ninth question. The judgement is **injected into the drafting prompt** and the user's own past messages are offered as a tone sample - both are things the reference implementation (`jev-chat-jarvis`) does not do, read from its source. It produces **text only** and never fills an input box, simulates a key or steals focus, and `capabilities --json` reports `sendsNothing: true` (D-047) - **except that since 2026-10-01 the CLI command can carry out the user's own choice** (`--pick N --send`, D-063): `--send` requires `--pick`, so the model never selects what goes out; it is two-phase (`--yes`, otherwise `CONFIRMATION_REQUIRED` with the text in the payload); and it shares one blacklist -> whitelist -> rate -> audit implementation with `send` (`src/services/outboundSend.ts`). The **assistant tool** `draft_reply` still cannot send anything at all, and no send tool exists on either model-driven surface (a test asserts it). `--dry-run` means zero egress, so it cannot preview a candidate (candidates only come from a model) - it reports the character count and says so. The risk **threshold** is picked rather than calibrated, so the 0-9 band explains the refusal and does not decide alone; `commitment` constrains the prompt instead of refusing (`--gate-commitment` makes it hard). **The judgement step is degradable** (Jev's free period ended 2026-09-25): when that call fails the tool drafts on DeepSeek alone and returns `judged: false` with the reason, because the gate exists only as long as a judgement does - the CLI and the assistant tool both print "no judgement this time, the gate did not run" ahead of the candidates, and `deepseekApiKey` is now the only required key. The criteria are English while `reply_debt`'s money/commitment questions stay Chinese - two wording conventions against one model with **no A/B run**, recorded as an open question. Verified on real data: one live run produced three candidates, and a mutating check confirmed the tests catch a refusal that starts with `(` and a transcript that is not time-ordered. Reachable as the assistant tool `draft_reply`, which masks every message through `privacyGate` before handing the transcript to the script over stdin and **refuses outright in strict mode**. | `draft <会话> --dry-run --json`, `draft <会话> --yes`, `draft <会话> --pick N --send --yes` |
37
73
  | Local conversation search | `scripts/route_cards.py` answers "which conversation was I talking about X in" without a model seeing the conversations. Jev picks the real query words out of the candidate n-grams the question produces (measured: keeps 会议 at 0.77, rejects 7 fragments at 0.11-0.40); **ranking is local**, by how many messages in each session literally contain those words. Retrieval reads WeChat's own `message_fts.db`, whose `acontent` is plaintext and whose integer `session_id` **is the rowid of its `name2id` table** - `LIKE` over 60k rows is instant, while `MATCH` is unavailable (`no such tokenizer: MMFtsTokenizer`). Only the candidate words and the question leave the machine; `--keyword` asks nothing at all. See D-036. | `python scripts/route_cards.py build`, `ask "…" --dry-run`, `ask "…" --yes` |
38
74
  | Assistant trace | Every turn writes one record to `~/.weflow-cli/assistant_trace.jsonl`: the fast-route's decision, each tool call with a **redacted argument summary**, round trips, why it stopped, and any reasoning the model returned. Read it with `assistant trace` on the machine, or by sending `轨迹` in WeChat (last turn only, and **no `userId` in that rendering**). The audit is unchanged and stays narrow: it records events and byte counts and never content, because it is the egress record; the trace is a local debugging artifact, which is why argument summaries live here. Reasoning comes from the provider's `reasoning_content` and is empty with the default `deepseek-chat` - the trace says so rather than implying it thought. "Did this step produce anything" is a **convention** (`(`-prefixed results), not a field; its one exception is a whitelist guarded by a test, and the ~40-site alternative was priced and declined (D-043). | `assistant trace`, `轨迹` in chat |
39
- | Assistant behaviour eval | `npm run eval:assistant` runs nineteen synthetic cases against the **real** model (one of them multi-turn) and asserts floor conditions: was the tool that could answer actually called, and were tools avoided when none was needed; did tool usage stay bounded; **is a failing tool reported as failing** (exit code 2 produces a reply quoting the error and offering an alternative, not "no one is waiting"); is an unknown contact ever given invented content; is memory judged by the fact landing in long-term memory rather than by whether `save_memory` was called; does an ambiguous contact name get a question back instead of a guess; are two asks in one message both served; and is a refused fetch (an internal address in favourites) reported as a refusal rather than summarised. Synthetic data, temporary home directory (the real audit and memory files stay untouched - a second writer would clobber the daemon's), and only the model call may leave the process. The expectations are the author's, **not human labels**, so this measures floors and not quality; a first green run proves little (cases and behaviour share an author) and the value is in re-running it after a change. Assertions must key off the tool's own diagnostics, never the model's wording - this suite's own first version asserted "查不到" and produced two false failures on "查不了". The criterion that follows from it, applied once more in `todos-never-extracted`: an expectation that moves with the model's phrasing is a budget, not a floor. That case first asserted "the reply mentions extraction" as a hard floor and passed three clean runs; putting the old tool behaviour back (mutation) still passed once before failing once - the model has its own ways of saying it - so the assertion now rides the soft channel, and the deterministic guard for the distinction lives in `test/assistant-tools-branches.test.ts`. Not in `npm test`: it needs network and money. | `npm run eval:assistant`, `--only=<case>`, `--json` |
40
- | Assistant | Optional WeChat Bot-channel assistant: 19 local tools (12 originally; plus chat search, reply debt, semantic search and chat export, with export in html/txt/json/excel, Moments "who posts most", favourites without a keyword, and drafted replies). The single write tool (`export_chat`) can only create a new directory under `output/exports/` - the model supplies no path and an existing name gets a numeric suffix, so "never overwrite" is enforced in code. `draft_reply` writes nothing and **sends nothing** (D-047). `evidence-review`, `vault promote`, sending messages and every configuration change are deliberately **not** reachable from a chat message: the first two have their own human-facing confirm gates, sending is irreversible, and configuration is meant to be changed on the machine. 19 local tools, three-layer memory (working window / rolling summary / long-term facts), PII redaction before any cloud call, and a 100-message/day quota. It denies **every** sender until an allowlist is configured. The **single-round fast path** (D-035) is shipped but **off by default** (`assistantFastRoute` = `off` | `log` | `on`): it resolves "needs local data? which capability?" in one decision call and pre-dispatches that tool, so the loop's first request already sees the result (two round trips become one). Only the six tools with closed or empty arguments are routable; everything else falls back, and falling back is byte-identical to the previous behaviour. Measured on 17 questions against the real decision layer: 0 wrong routes, 10 routed concretely, ~1.33 s per decision, 2 fell back (`scripts/assistant_route_probe.ts`; those expectations are the author's, not human labels). Covered by synthetic harnesses (stubbed `callLLM`, stubbed `chatService`/`wereadService` singletons, a replaced `fetch`, a stubbed message channel, an injected route decider, temp `HOME`): per-message logic, 18 of the 19 tool branches (only `get_daily_report` is never executed - it reads a module-level path constant with no injection point, so which branch it takes depends on the machine; see the coverage note in `test/assistant-tools-branches.test.ts`), the resident loop itself - who is denied, the three group controls, the serial queue, the 100/day quota with its date rollover - the **privacy wiring** (which `aiEngine` values count as local inference, where `engineConfig` sends the request, and `reviewEvidence` refusing to send chat text to the cloud without `--allow-cloud`), and the fast path (correct / wrong / uncertain / log-only / no dispatch without the audit line). `get_todos` is executed through `pythonBridge`'s injectable runner (empty list, urgency and deadline rendering, the 15-row cap, a classified failure whose `stderr` is redacted, and "no JSON" not being read as "no todos"). It also keeps "no pending todos" apart from "extraction has never been run" (D-044): the emptiness has a cause of its own on a machine where `todos extract` was never invoked, so the tool names that cause and the command that would fill the list instead of reporting "nothing to do". The list is extracted from chat logs only by an explicit, confirmed command; nothing schedules it. Note on that claim's history: an earlier revision of this line - and the commit message for the `pythonBridge` refactor - asserted this coverage before any such test existed; it was written in prose only, and the file's own header comment said the opposite. The tests are real as of this revision. The assistant is time-aware: the system prompt opens with the current local date and weekday, and `get_messages` takes `since`/`until` (a date or `3d`/`2w`/`12h`) so a specific past day is reachable - before this, a day far enough back could not be fetched at all. An identical tool call repeated within one turn is answered with a note instead of being re-executed. The tool list is built per machine: a tool whose prerequisite is missing (semantic search without `dashscopeApiKey`, WeRead without `wereadApiKey`) is not offered at all, and the fast path - which dispatches directly - checks the same predicate; the filter only drops tools that *cannot* run, never ones that merely have no data yet. Name resolution searches the recent sessions first and then falls back to the contact book (remark / display name / nickname / alias), refusing anything that is not unique - measured on 378 real contacts outside the recent sessions: 362 resolved, 0 resolved to the wrong person. | `login-wechat`, `assistant` |
41
- | 本机面板 (local panel) | A second entrance to the same assistant, for when logging into the Bot channel is not worth it: `weflow-cli panel` opens an always-on-top window (Electron) or a chromeless Edge/Chrome `--app` window when Electron is absent. **The window is only a client** - every turn goes through the daemon's own `runTurn`, so the allowlist path, the 100/day quota counter, the serial queue and the memory file all stay single-writer (D-045). The daemon exposes a **loopback-only** HTTP endpoint (`127.0.0.1:8766`, not configurable) that is deliberately stricter than the daily reader: **every** request needs a per-run token (including `/api/status`), Origin is a second gate, POSTs must be JSON, and only one turn may be in flight (a second one gets 429 rather than being queued, because queueing means it will run and spend quota). The token lives in `~/.weflow-cli/assistant_endpoint.json` (atomic write; readers probe `kill(pid,0)` and verify `service` + `startedAt`, because SIGTERM leaves the file behind) and never reaches a command line: the Electron shell reads it itself and installs it as an `HttpOnly` cookie, while the browser fallback uses a one-time 60-second code from `/api/pair`. The memory bucket decides whether the panel is the same brain as WeChat - it resolves to the single allowlisted id, else it says on screen that it uses a separate bucket (`assistantPanelUser` pins it). Verified live: the browser fallback opened, the pair-code flow returned the page with an `HttpOnly` cookie, a replayed code 401s, and a real question through the endpoint answered from local data while the shared quota counter went 0 -> 3. The floating ball was verified on Windows 11 with Electron 42 (76x76, no caption, always-on-top, renderer authenticated via the cookie, ball visible on screen); with Electron absent the same command falls back to a chromeless Edge/Chrome window and says it cannot give the ball. Ball-to-chat resize and the global hotkey were then exercised **without a click** (DevTools protocol; a differential registration test), which is how two bugs surfaced that a screenshot could not have shown - a non-resizable window ignoring `setSize` on Windows (collapse left it at the expanded size) and a `ready-to-show` listener attached too late (the window sometimes stayed hidden). **Right-clicking in the panel opens a quick-reply menu listing the contacts in `quickReplyContacts` (a config key added for it; the list rides on `/api/status`, which the page already polls every 30 seconds, so no new endpoint and no new IPC). Picking a name asks the assistant to draft for that person through the existing `/api/ask`, so quota, the serial queue, memory and the copy button all come along unchanged. The menu states its own cost (drafting sends that conversation to two cloud models), an empty list shows the command to run instead of an empty box, and the menu is a **native** one (built in the main process from `quick-menu.cjs`) so it draws outside the window - the ball is 96x96 and never has to expand, which is what the first attempt got wrong; the browser fallback does not take over right-click at all, because the native menu's Copy belongs to the user there. That menu's **last item hides the ball** (the only item in it that reaches no model), and it is the one item still present when the list is empty - otherwise a user who never configured the key would get a menu of two grey lines with nothing clickable, including no way to dismiss the ball. It hides rather than quits, so it is deliberately reversible, and the label names the way back (`关闭悬浮球(托盘图标能再打开)`) because the ball runs with `setSkipTaskbar(true)` and would leave nothing on the taskbar to click; the exit actions stay in the tray menu, which already separates them from show/expand (no test ever drives a real right-click, so the menu's *contents* are covered by executing every item's callback in `test/panel-quick-menu.test.ts`). Dragging the ball used to **grow the window by the drag distance** whenever it happened in the chat window: `dragMove` re-read the current size and wrote it back, and that read lags (measured with a probe driving the same IPC sequence: 0 drift with `resizable: false`, exactly the drag delta with `resizable: true`). The extra width showed up as a gap between the ball and the bubble, and expanding re-asserted the exact size - which is why clicking the ball appeared to reset it. The size is now captured once at `dragStart`. **Pressing the ball switches it to a second, closed-eyes face, and releasing reverts it immediately** (a 900ms hold after release was tried first and the user called it stuck; the generated face is composited in only inside an eye-shaped mask, because the endpoint repainted the whole canvas and returned no alpha). The ball stays put and the conversation unfolds beside it** - clicking it opens a bubble to its left (flipping to the right when there is no room, top-aligning when the bubble would run off the top of the work area) and clicking it again closes it; the ball is a toggle and never fades out. The window is "ball + gap + bubble" as one piece (508x560), with the ball pinned to its own corner so it does not move at all; `bubbleLayout` and `ballRectInWindow` in `ball-position.cjs` compute that geometry (pure, tested with synthetic displays, including a round-trip that expand-then-collapse returns the ball to where it started). The mode has one source of truth (the main process announces `{mode, side, anchorY}`; the page only requests), so the ball, the collapse button, the tray and the hotkey take the same path, and `prefers-reduced-motion` stops both halves. Measured on a real window with the real page at 150% scaling: the ball's offset inside the window is exactly 0 at every sample and its screen position varies by at most 1px across a whole expand and collapse (23 and 30 samples), with identical resting positions before and after. Two bugs came out of that measurement rather than out of reading the code: the tween rounded each edge independently so the edge the ball hangs on wobbled 1-2px (since fixed by pinning the corner), and collapsing placed the ball at the window's top-left instead of the corner it sits on, which would have made it jump to the bubble's opposite corner. The platform wrinkle behind some of this: at 150% scaling a request for a 76x76 window yields a ~79px client width (Windows enforces a minimum), which is why the remembered position is the window's, not the ball's visual edge. That arithmetic is a pure module tested against **synthetic** monitor layouts (negative-x second displays, a removed display, a rect larger than the work area) - this machine has one display, so a real second monitor remains unperformed. **The ball's coloured halo is now painted only in the three non-idle states** (`busy` / `offline` / `quota`); while idle the ball is just the mascot, with nothing behind it. It had been on permanently - which was the original request (an animated background) - but that made the mascot PNG's own transparency invisible and, because the same layer is the status light, made offline / busy / quota-out look identical. The `@keyframes drift` and `@property --hue` machinery stays (it drives the busy state); what changed is when the layer is drawn. The test was **rewritten to the new contract, not weakened**: the first version searched the whole stylesheet for each `body.<state> #ball .glow`, which also matches that state's separate `animation: none` rule, so removing a state from the gradient selector still passed - caught by a mutation check, and the assertion now requires the state to appear in the selector of the rule that actually carries the `radial-gradient`. **The right-click menu now has a second section of eight one-click functions** (谁在等我回话 / 待办 / 日报 / 统计 / 阅读统计 / 会话 / 朋友圈 / 微信读书), admitted by a single rule: the action must be completable by **one fixed sentence**, because a native menu cannot collect input - so search/export/read-one-message are excluded rather than shipped as items that do nothing. Each prompt names its tool, which is a request to the model and **not a hard binding** (the assistant still chooses from 19 tools; forcing it is a decision for when a wrong pick is actually observed). The prompt travels with the menu item and the page keeps no id→prompt table (two tables drift into "the menu says A, clicking does B", silently - a test asserts `renderer.js` contains no action id); the cost line is **derived** from the action list rather than written down, because a stale cost warning is worse than none, and it now names which items are cloud calls and which are local reads. The pick payload is `{kind:'contact'|'action', …}` and the page ignores anything else, so a menu from the previous version cannot be dispatched as a draft nobody asked for. All of it verified by executing every item's callback and by two mutation checks (dispatching an action as a draft, and the page keeping its own id table). | `panel`, `panel --status`, `panel --ask` |
75
+ | Assistant behaviour eval | `npm run eval:assistant` runs 37 synthetic cases against the **real** model (one of them multi-turn) - the tool surface was widened to 31 on 2026-09-29 and **six cases were added on 2026-09-30** for the ten tools that widening introduced, which until then had **no assertions at all** (only `list_contacts` was touched, and only incidentally by other cases): `format-for-wechat` (the two-hop `list_themes` -> `format_article`), `themes-listed` (asks only for the list, and must not format anything), `skills-health` (`check_skills`, as opposed to `list_skills`), `wiki-health` (`lint_wiki`, as opposed to `search_knowledge`), `todo-done` (the write path) and `todo-ambiguous-refused` (two todos match, so nothing may be written). Two more cases followed the same day, for the two tools that had no injectable data at all: `daily-review` (`get_review`, distinct from `get_daily_report`) and `knowledge-overview` (`get_concepts` - the catalogue of the knowledge base, as opposed to searching its contents). Making those testable needed a third per-call injection point, `WEFLOW_ASSISTANT_VAULT_DIR`, alongside the two that already existed; until then `get_concepts` read a module-level constant and could only ever be asserted as "not compiled yet" in a temp home. `listContacts` also turned out to have never been stubbed, so `list_contacts` was hitting the real service (always empty in a temp home) while this entry claims synthetic data - stubbed now, with contacts derived from the case's own sessions. Last full run with the six cases: **29 passed, 0 failed**; the two added after that each pass with a single, correct tool call (`get_review`, `get_concepts`). **2026-10-01**: the
76
+ 31 tools were walked one by one against the case file, which found five with no coverage at all and one stub that made
77
+ its case unable to test anything real - `getSnsTimeline` returned a hard-coded empty timeline, so `get_sns` could only
78
+ ever be asserted as "no cached data" (the same defect `listContacts` had). Five cases were added (`sns-timeline`,
79
+ `skill-body` for `read_skill`, `library-stats` for `get_stats`' knowledge-base half, `export-chat-bounded`, and
80
+ `strict-draft-refused`, a guard for the time `aiEngine=ollama` still handed the conversation to the script), the `sns`
81
+ fixture became per-case, and the limitation list inside the case file was rewritten - two of its claims had gone stale
82
+ (it still said `get_concepts`/`get_review` were untestable and that the harness could not set environment variables,
83
+ both false since 2026-09-30). `get_weread` was on that list too, on the grounds that its config key cannot be injected reliably - that
84
+ mistook a **missing mechanism** for a **tool that cannot be tested**: the harness had been overriding
85
+ `assistantPrivacy` per case all along, it just had not generalised it. With a general `config` override
86
+ (`weread-notebooks`, asserting the book title really reaches the answer, because reading `b.title` off the
87
+ notebooks response yields `undefined` while the output still looks fine), what genuinely remains uncovered is
88
+ **one** tool: `search_semantic` (needs `dashscopeApiKey` **and** a built index - neither exists on this machine). That
89
+ list is no longer prose that can rot: it is the `EVAL_UNCOVERED` map, and `test/assistant-eval.test.ts` asserts both
90
+ directions - every tool is either claimed by some case's assertions or registered there with a reason, and no
91
+ registered name has stopped existing - so adding a tool means writing a case or writing a reason, and skipping both
92
+ fails. The same walk caught two cases whose comments promised a distinction they never asserted (`daily-review`:
93
+ "get_review, not get_daily_report"; `skills-health`: `check_skills` vs `list_skills`), both now asserted and verified
94
+ against the real model. The new cases were then run against the real model the same day: the first five took it to **36 passed, 0
95
+ failed, 0 skipped**, and the full suite after `weread-notebooks` was added ran **37 passed, 0 failed, 0 skipped**
96
+ (one soft note: `knowledge-two-hops` did not phrase the hop structure the way the case looks for). One of them is worth recording for what it did *not* do - `strict-draft-refused`
97
+ called `get_messages` and refused without ever reaching for `draft_reply`, so an expectation of `mustCall:
98
+ ['draft_reply']` (the first version of that case) would have failed on the first run and sent someone chasing a
99
+ defect that does not exist; asserting the **outcome** instead is what the memory cases already argued for
100
+ (`memory-recall` judges "was it used", not "was `save_memory` called"). Two of the ten are **out of scope here by design**: `fetch_article` and `search_public` leave the machine, and the harness rejects any egress other than the model call ("a second exit means something leaked"). `get_concepts` and `get_review` were in that gap too and are now covered (see below), which required the harness to be able to lay down fixture files and set the environment variables that point the tools at them. The write cases also brought a new floor, `neverSucceeds`: if the named tool was called at all, that call must have produced nothing (read from the trace), so both "asked the user instead of writing" and "called the tool and was refused" pass while an actual write fails - the first live run of `todo-ambiguous-refused` failed because the expectation first written was too narrow (`mustCall: set_todo_status`), and the model had done the better thing (asked which of the two todos to mark). One case (`ambiguous-contact`) runs over its efficiency budget of 3 most times - measured over four runs on 2026-09-30 it used **6, 6, 2 and 4** tool calls, so it varies rather than having shifted once (the earlier note that "a single run cannot say" is now answered by those four runs). The floors pass every time and the traces show it **re-querying the same thing** (`list_sessions` two or three times) rather than failing, so this is a routing-efficiency question, not a broken promise. **A fixture gap found the same day did not explain it**: `listContacts` had never been stubbed, so `list_contacts` in the eval hit the real service against a temp home with no contacts and always came back empty - two cases were therefore running on "the real service happens to find nothing" while this entry claims synthetic data, and a change in that service would have moved the eval silently. The stub was added (contacts derive from the case's own sessions), and four more runs of the case gave **2, 6, 2 and 2** - the spread is unchanged, so the extra calls are the model exploring routes, not the fixture (the slow runs also reach for `search_chats`). Recorded as a negative result rather than a fix and asserts floor conditions: was the tool that could answer actually called, and were tools avoided when none was needed; did tool usage stay bounded; **is a failing tool reported as failing** (exit code 2 produces a reply quoting the error and offering an alternative, not "no one is waiting"); is an unknown contact ever given invented content; is memory judged by the fact landing in long-term memory rather than by whether `save_memory` was called; does an ambiguous contact name get a question back instead of a guess; are two asks in one message both served; and is a refused fetch (an internal address in favourites) reported as a refusal rather than summarised. Synthetic data, temporary home directory (the real audit and memory files stay untouched - a second writer would clobber the daemon's), and only the model call may leave the process. The expectations are the author's, **not human labels**, so this measures floors and not quality; a first green run proves little (cases and behaviour share an author) and the value is in re-running it after a change. Assertions must key off the tool's own diagnostics, never the model's wording - this suite's own first version asserted "查不到" and produced two false failures on "查不了". The criterion that follows from it, applied once more in `todos-never-extracted`: an expectation that moves with the model's phrasing is a budget, not a floor. That case first asserted "the reply mentions extraction" as a hard floor and passed three clean runs; putting the old tool behaviour back (mutation) still passed once before failing once - the model has its own ways of saying it - so the assertion now rides the soft channel, and the deterministic guard for the distinction lives in `test/assistant-tools-branches.test.ts`. Not in `npm test`: it needs network and money. | `npm run eval:assistant`, `--only=<case>`, `--json` |
101
+ | Assistant | Optional WeChat Bot-channel assistant: 31 local tools (12 originally; plus chat search, reply debt, semantic search and chat export, with export in html/txt/json/excel, Moments "who posts most", favourites without a keyword, and drafted replies). The single write tool (`export_chat`) can only create a new directory under `output/exports/` - the model supplies no path and an existing name gets a numeric suffix, so "never overwrite" is enforced in code. `draft_reply` writes nothing and **sends nothing** (D-047). `evidence-review`, `vault promote`, sending messages and every configuration change are deliberately **not** reachable from a chat message: the first two have their own human-facing confirm gates, sending is irreversible, and configuration is meant to be changed on the machine. 31 local tools, three-layer memory (working window / rolling summary / long-term facts), PII redaction before any cloud call, and a 100-message/day quota. It denies **every** sender until an allowlist is configured. The **single-round fast path** (D-035) is shipped but **off by default** (`assistantFastRoute` = `off` | `log` | `on`): it resolves "needs local data? which capability?" in one decision call and pre-dispatches that tool, so the loop's first request already sees the result (two round trips become one). Only the six tools with closed or empty arguments are routable; everything else falls back, and falling back is byte-identical to the previous behaviour. Measured on 17 questions against the real decision layer: 0 wrong routes, 10 routed concretely, ~1.33 s per decision, 2 fell back (`scripts/assistant_route_probe.ts`; those expectations are the author's, not human labels). Covered by synthetic harnesses (stubbed `callLLM`, stubbed `chatService`/`wereadService` singletons, a replaced `fetch`, a stubbed message channel, an injected route decider, temp `HOME`): per-message logic, **all 31 tool branches** (2026-09-29: the last uncovered one, `get_daily_report`, got the `WEFLOW_ASSISTANT_BIZ_DAILY_DIR` injection point its own coverage note had been asking for - read per call, so a test can swap fixtures; it is now asserted against a fixture instead of a "there may or may not be data" shape check), the resident loop itself - who is denied, the three group controls, the serial queue, the 100/day quota with its date rollover - the **privacy wiring** (which `aiEngine` values count as local inference, where `engineConfig` sends the request, and `reviewEvidence` refusing to send chat text to the cloud without `--allow-cloud`), and the fast path (correct / wrong / uncertain / log-only / no dispatch without the audit line). `get_todos` is executed through `pythonBridge`'s injectable runner (empty list, urgency and deadline rendering, the 15-row cap, a classified failure whose `stderr` is redacted, and "no JSON" not being read as "no todos"). It also keeps "no pending todos" apart from "extraction has never been run" (D-044): the emptiness has a cause of its own on a machine where `todos extract` was never invoked, so the tool names that cause and the command that would fill the list instead of reporting "nothing to do". The list is extracted from chat logs only by an explicit, confirmed command; nothing schedules it. Note on that claim's history: an earlier revision of this line - and the commit message for the `pythonBridge` refactor - asserted this coverage before any such test existed; it was written in prose only, and the file's own header comment said the opposite. The tests are real as of this revision. The assistant is time-aware: the system prompt opens with the current local date and weekday, and `get_messages` takes `since`/`until` (a date or `3d`/`2w`/`12h`) so a specific past day is reachable - before this, a day far enough back could not be fetched at all. An identical tool call repeated within one turn is answered with a note instead of being re-executed. The tool list is built per machine: a tool whose prerequisite is missing (semantic search without `dashscopeApiKey`, WeRead without `wereadApiKey`) is not offered at all, and the fast path - which dispatches directly - checks the same predicate; the filter only drops tools that *cannot* run, never ones that merely have no data yet. Name resolution searches the recent sessions first and then falls back to the contact book (remark / display name / nickname / alias), refusing anything that is not unique - measured on 378 real contacts outside the recent sessions: 362 resolved, 0 resolved to the wrong person. | `login-wechat`, `assistant` |
102
+ | 本机面板 (local panel) | A second entrance to the same assistant, for when logging into the Bot channel is not worth it: `weflow-cli panel` opens an always-on-top window (Electron) or a chromeless Edge/Chrome `--app` window when Electron is absent. **The window is only a client** - every turn goes through the daemon's own `runTurn`, so the allowlist path, the 100/day quota counter, the serial queue and the memory file all stay single-writer (D-045). The daemon exposes a **loopback-only** HTTP endpoint (`127.0.0.1:8766`, not configurable) that is deliberately stricter than the daily reader: **every** request needs a per-run token (including `/api/status`), Origin is a second gate, POSTs must be JSON, and only one turn may be in flight (a second one gets 429 rather than being queued, because queueing means it will run and spend quota). The token lives in `~/.weflow-cli/assistant_endpoint.json` (atomic write; readers probe `kill(pid,0)` and verify `service` + `startedAt`, because SIGTERM leaves the file behind) and never reaches a command line: the Electron shell reads it itself and installs it as an `HttpOnly` cookie, while the browser fallback uses a one-time 60-second code from `/api/pair`. The memory bucket decides whether the panel is the same brain as WeChat - it resolves to the single allowlisted id, else it says on screen that it uses a separate bucket (`assistantPanelUser` pins it). Verified live: the browser fallback opened, the pair-code flow returned the page with an `HttpOnly` cookie, a replayed code 401s, and a real question through the endpoint answered from local data while the shared quota counter went 0 -> 3. The floating ball was verified on Windows 11 with Electron 42 (76x76, no caption, always-on-top, renderer authenticated via the cookie, ball visible on screen); with Electron absent the same command falls back to a chromeless Edge/Chrome window and says it cannot give the ball. Ball-to-chat resize and the global hotkey were then exercised **without a click** (DevTools protocol; a differential registration test), which is how two bugs surfaced that a screenshot could not have shown - a non-resizable window ignoring `setSize` on Windows (collapse left it at the expanded size) and a `ready-to-show` listener attached too late (the window sometimes stayed hidden). **Right-clicking in the panel opens a quick-reply menu listing the contacts in `quickReplyContacts` (a config key added for it; the list rides on `/api/status`, which the page already polls every 30 seconds, so no new endpoint and no new IPC). Picking a name asks the assistant to draft for that person through the existing `/api/ask`, so quota, the serial queue, memory and the copy button all come along unchanged. The menu states its own cost (drafting sends that conversation to two cloud models), an empty list shows the command to run instead of an empty box, and the menu is a **native** one (built in the main process from `quick-menu.cjs`) so it draws outside the window - the ball is 96x96 and never has to expand, which is what the first attempt got wrong; the browser fallback does not take over right-click at all, because the native menu's Copy belongs to the user there. That menu's **last item hides the ball** (the only item in it that reaches no model), and it is the one item still present when the list is empty - otherwise a user who never configured the key would get a menu of two grey lines with nothing clickable, including no way to dismiss the ball. It hides rather than quits, so it is deliberately reversible, and the label names the way back (`关闭悬浮球(托盘图标能再打开)`) because the ball runs with `setSkipTaskbar(true)` and would leave nothing on the taskbar to click; the exit actions stay in the tray menu, which already separates them from show/expand (no test ever drives a real right-click, so the menu's *contents* are covered by executing every item's callback in `test/panel-quick-menu.test.ts`). Dragging the ball used to **grow the window by the drag distance** whenever it happened in the chat window: `dragMove` re-read the current size and wrote it back, and that read lags (measured with a probe driving the same IPC sequence: 0 drift with `resizable: false`, exactly the drag delta with `resizable: true`). The extra width showed up as a gap between the ball and the bubble, and expanding re-asserted the exact size - which is why clicking the ball appeared to reset it. The size is now captured once at `dragStart`. **Pressing the ball switches it to a second, closed-eyes face, and releasing reverts it immediately** (a 900ms hold after release was tried first and the user called it stuck; the generated face is composited in only inside an eye-shaped mask, because the endpoint repainted the whole canvas and returned no alpha). The ball stays put and the conversation unfolds beside it** - clicking it opens a bubble to its left (flipping to the right when there is no room, top-aligning when the bubble would run off the top of the work area) and clicking it again closes it; the ball is a toggle and never fades out. The window is "ball + gap + bubble" as one piece (508x560), with the ball pinned to its own corner so it does not move at all; `bubbleLayout` and `ballRectInWindow` in `ball-position.cjs` compute that geometry (pure, tested with synthetic displays, including a round-trip that expand-then-collapse returns the ball to where it started). The mode has one source of truth (the main process announces `{mode, side, anchorY}`; the page only requests), so the ball, the collapse button, the tray and the hotkey take the same path, and `prefers-reduced-motion` stops both halves. Measured on a real window with the real page at 150% scaling: the ball's offset inside the window is exactly 0 at every sample and its screen position varies by at most 1px across a whole expand and collapse (23 and 30 samples), with identical resting positions before and after. Two bugs came out of that measurement rather than out of reading the code: the tween rounded each edge independently so the edge the ball hangs on wobbled 1-2px (since fixed by pinning the corner), and collapsing placed the ball at the window's top-left instead of the corner it sits on, which would have made it jump to the bubble's opposite corner. The platform wrinkle behind some of this: at 150% scaling a request for a 76x76 window yields a ~79px client width (Windows enforces a minimum), which is why the remembered position is the window's, not the ball's visual edge. That arithmetic is a pure module tested against **synthetic** monitor layouts (negative-x second displays, a removed display, a rect larger than the work area) - this machine has one display, so a real second monitor remains unperformed. **The ball's coloured halo is now painted only in the three non-idle states** (`busy` / `offline` / `quota`); while idle the ball is just the mascot, with nothing behind it. It had been on permanently - which was the original request (an animated background) - but that made the mascot PNG's own transparency invisible and, because the same layer is the status light, made offline / busy / quota-out look identical. The `@keyframes drift` and `@property --hue` machinery stays (it drives the busy state); what changed is when the layer is drawn. The test was **rewritten to the new contract, not weakened**: the first version searched the whole stylesheet for each `body.<state> #ball .glow`, which also matches that state's separate `animation: none` rule, so removing a state from the gradient selector still passed - caught by a mutation check, and the assertion now requires the state to appear in the selector of the rule that actually carries the `radial-gradient`. **The right-click menu now has a second section of eight one-click functions** (谁在等我回话 / 待办 / 日报 / 统计 / 阅读统计 / 会话 / 朋友圈 / 微信读书), admitted by a single rule: the action must be completable by **one fixed sentence**, because a native menu cannot collect input - so search/export/read-one-message are excluded rather than shipped as items that do nothing. Each prompt names its tool, which is a request to the model and **not a hard binding** (the assistant still chooses from 19 tools; forcing it is a decision for when a wrong pick is actually observed). The prompt travels with the menu item and the page keeps no id→prompt table (two tables drift into "the menu says A, clicking does B", silently - a test asserts `renderer.js` contains no action id); the cost line is **derived** from the action list rather than written down, because a stale cost warning is worse than none, and it now names which items are cloud calls and which are local reads. The pick payload is `{kind:'contact'|'action', …}` and the page ignores anything else, so a menu from the previous version cannot be dispatched as a draft nobody asked for. All of it verified by executing every item's callback and by two mutation checks (dispatching an action as a draft, and the page keeping its own id table). **The ball's eyes now follow the mouse** (D-059): the main process samples `screen.getCursorScreenPoint()` every 120 ms and pushes the position **only when it changes**, the page eases toward it and stops its rAF loop once it settles. The renderer cannot see the cursor itself - the ball is 96x96 and the pointer is outside it almost always - which is why this needs the main process at all. The ball's face is now **two layers**: `mascot-base.png` (the same art with the iris region repainted as sclera) plus `mascot-iris.png` (the iris, pupil and highlight), composited at zero offset **pixel-identical** to `mascot.png` - a test decodes all three PNGs and asserts that, because same-size / whitelisted / listed-in-`PANEL_FILES` all stay green if the layers are re-cut one pixel off, and a misalignment is visible only to a person. Travel is clamped to an **ellipse** (about 2.6 px sideways, 0.8 up, 2.6 down at the 96 px ball) because those are the measured sclera margins - past them the iris separates from the rim and the eye reads as a sticker sliding. The iris layer is hidden on `busy` / `offline` / `quota` / `ball-happy`, whose faces have their eyes baked in (otherwise there would be two pupils). `prefers-reduced-motion` leaves the eyes still. The cursor position is in-process only, narrowed in `preload.cjs`, never logged and never sent anywhere. **Not yet verified live** - the wiring and the pixel identity are covered by the suite, but the effect has not been run on a real window. **The ball can also be pushed to a screen edge to hide behind it**: drag it until it touches a monitor's left or right edge and let go, and it swaps to a dedicated drawing of the cat **peeking out from behind that edge** - head and front paws out, body hidden, the drawing's left side being a straight cut that lands exactly on the screen edge (`mascot-peek.png`; the right edge reuses it mirrored). The window stays **fully on screen**, flush against the edge - the illusion comes from the artwork, so nothing goes off-screen, the whole ball stays clickable, and a restart simply puts the ball back at the edge rather than losing it. The first click (or drag) brings the normal ball back, and that click deliberately does **not** expand the conversation - on a hidden ball a click means "come back", not "let's talk". Only left and right: top and bottom fight the taskbar and Windows' edge snapping. Two rules carry the effect and both are pinned by tests because everything else stays green without them: the ball's circular clip must be off in this state (a circle would slice the straight cut into an arc) and hover-zoom must be off as well (scale 1.02 lifts the cut 1px off the edge - a visible seam). The snap threshold is deliberately below the normal 24px edge margin, so a ball merely parked is never mistaken for one being pushed off; a test pins that boundary, because raising it past the margin would hide the ball on every release while every other test stayed green. | `panel`, `panel --status`, `panel --ask` |
42
103
 
43
- The 642-test TypeScript regression suite (counted from `npm test` on this revision; it grows with every capability) covers home-path expansion, custom NT data-root discovery, bounded daily-favorites synchronization, confirmed no-AI daily generation, no-AI Vault promotion, Vault content mutation previews, Vault initialization and synchronization confirmation, MCP configuration write confirmation, source/compiled Python resource lookup, outbound PII redaction, local-inference bypass, strict message-body masking, MCP path/date/URL validation and live tool discovery, message-contract preservation, local-date export bounds, export result metadata, evidence-package safety, assistant routing and lifecycle confirmation, message-channel authentication confirmation, todo and access-control mutation confirmation, message-send preview/confirmation, local-reader port validation, redacted configuration status, access-list JSON, database-key reset confirmation, human-gated initialization and key capture, content-free account-scan and assistant-log diagnostics, confirmed Vault RAG, confirmed semantic search and RAG chat, drafted-reply confirmation and its refusal path, structured todo reminders, strict knowledge limits, report previews, and cover-image signature validation. The panel's copy button is executed rather than read: `test/panel-copy-button.test.ts` boots `renderer.js` in a jsdom page, goes through the real `submit -> ask() -> fetch -> addTurn` path, clicks the button, and asserts both the success label and that a **failed** clipboard write says so instead of claiming success - and says in its own header that jsdom cannot reproduce Chromium's drag-region behaviour, so it proves the handler and not the OS-level click. Core automation entry points now include `capabilities --json`, `config show --json`, `whitelist list --json`, `blacklist list --json`, `check --json`, `init --dry-run --json`, `scan --json`, `dbkey --dry-run --json`, `sessions --json`, `messages --json`, `contacts --json`, `export ... --json`, `fav export ... --json-result`, `sns ... --json`, `daily-stats --json`, `daily --no-ai --dry-run --json`, `daily-server --status --json`, `assistant status --json`, `assistant log --json`, `todos list --json`, `todos remind --json`, `evidence --json`, `sync run --dry-run --json`, `sync run --yes --json`, `sync status --json`, `sync verify --json`, `evidence-review --dry-run --json`, Vault mutation previews, confirmed `vault rag`, confirmed `search` and `chat`, daily-favorite previews, `search-index --dry-run --json`, `pipeline run --dry-run --json`, report-generator previews, login/logout previews, and `mcp-config --output <file> --dry-run --json-result`. CI runs three jobs: `node` (build plus the TypeScript suite on Node 22.13), `python` (the Python suites on 3.11), and `release-consistency` (package.json and package-lock.json version agreement, plus a check that the npm package carries no outputs, models, databases or keys). The jobs are separate because a Node failure previously stopped the Python tests from running at all, which let them fail unnoticed.
104
+ | Assistant tool surface | The chat assistant now reaches the same ceiling the MCP surface has, minus the exclusions. Ten tools were added on 2026-09-29 (`list_contacts`, `get_review`, `get_concepts`, `fetch_article`, `search_public`, `format_article`, `list_themes`, `lint_wiki`, `check_skills`, `set_todo_status`), seven MCP-only hand-written tools were **moved** into the shared table (same names and arguments, one implementation; the hand-written table went 11 entries to 4), and four tools were **widened** instead of duplicated (`get_daily_report` gained the human-readable daily plus cross-date search, `get_todos` gained the item id and grouping, `get_weread` gained five modes, `get_stats` gained the knowledge-base half). What stays out is unchanged and named in D-058. Two residual risks are recorded there rather than left to be discovered: `search_public` sends a **model-authored query** to a third-party search page with no preview (the user chose this explicitly; `fetch_article` by contrast is allowlisted to `mp.weixin.qq.com` and re-validates every redirect hop), and `set_todo_status` can flip one existing todo between done/pending with no confirmation gate (bounded: no create, no delete - and excluded from MCP entirely). | `list_contacts`, `get_review`, `get_concepts`, `fetch_article`, `search_public`, `format_article`, `list_themes`, `lint_wiki`, `check_skills`, `set_todo_status` |
105
+ | 技能与场景 (skills & scenes) | Two assistant features that give a conversation shape without giving the model new powers. **Skills** are `SKILL.md` packages scanned from a configurable root list (`skillDirs`, default `~/.claude/skills` and `~/.weflow-cli/skills`) and surfaced two ways: the system prompt carries the catalogue (id, name, one-line description, version, state) and two read-only tools, `list_skills` and `read_skill`, return the catalogue and one body. Nothing is installed and nothing is executed - a skill is **material**, the prompt points at its path and asks the model to read it first. Referencing a skill that is missing, disabled or unreadable renders that state and instructs the model to continue and say which part it could not do, rather than dropping the reference or implying the capability exists (`{{skill:<id>}}` in a scene instruction resolves the same way). Disabling is one config key (`skillDisabled`) or one line in the skill's own frontmatter (`enabled: false`). **Scenes** are named prompt presets (keywords, extra instruction, output spec, required skills, enabled) in `~/.weflow-cli/assistant_scenes.json` (`weflow-scenes/v1`, unknown version quarantined rather than migrated). A turn picks at most one through three tiers - explicit binding, keyword hit, last used in that conversation - and a tie between two equally long keyword matches produces **no** scene plus a trace note explaining why, rather than a coin flip. Managed by `weflow-cli scene …` (two-phase for every mutation) and by one built-in chat command (`场景 <id>` / `场景 无` / `场景`), which accepts only an argument that names an existing scene so ordinary sentences beginning with 场景 fall through to the model. **The model cannot write scenes at all** - there is no such tool, and `capabilities --json` reports `modelWritable: false`; a scene enters the prompt wrapped by `frameLocalData`, and ids are refused if they contain quotes or angle brackets (they would close the `source="…"` attribute). Verified on the real machine: `skill check` reports 28 skills / 0 unreadable / 0 collisions / 5 non-conforming-id warnings / 1 self-disabled, and end-to-end tests with a stubbed model assert the scene section reaches the system prompt and the trace records the matching tier. | `skill list/show/check`, `scene list/show/add/remove/enable/disable/bind/unbind`, `场景 <id>` in chat |
106
+
107
+ The 776-test TypeScript regression suite (counted from `npm test` on this revision; it grows with every capability) covers home-path expansion, custom NT data-root discovery, bounded daily-favorites synchronization, confirmed no-AI daily generation, no-AI Vault promotion, Vault content mutation previews, Vault initialization and synchronization confirmation, MCP configuration write confirmation, source/compiled Python resource lookup, outbound PII redaction, local-inference bypass, strict message-body masking, MCP path/date/URL validation and live tool discovery, message-contract preservation, local-date export bounds, export result metadata, evidence-package safety, assistant routing and lifecycle confirmation, message-channel authentication confirmation, todo and access-control mutation confirmation, message-send preview/confirmation, local-reader port validation, redacted configuration status, access-list JSON, database-key reset confirmation, human-gated initialization and key capture, content-free account-scan and assistant-log diagnostics, confirmed Vault RAG, confirmed semantic search and RAG chat, drafted-reply confirmation and its refusal path, structured todo reminders, strict knowledge limits, report previews, skill parsing and its path confinement, scene matching precedence, the three-surface tool alignment (contact listing that keeps wxids out, per-hop redirect re-validation, refusal on ambiguous todo text, cross-date article search), and cover-image signature validation. The panel's copy button is executed rather than read: `test/panel-copy-button.test.ts` boots `renderer.js` in a jsdom page, goes through the real `submit -> ask() -> fetch -> addTurn` path, clicks the button, and asserts both the success label and that a **failed** clipboard write says so instead of claiming success - and says in its own header that jsdom cannot reproduce Chromium's drag-region behaviour, so it proves the handler and not the OS-level click. Core automation entry points now include `capabilities --json`, `config show --json`, `whitelist list --json`, `blacklist list --json`, `check --json`, `init --dry-run --json`, `scan --json`, `dbkey --dry-run --json`, `sessions --json`, `messages --json`, `contacts --json`, `export ... --json`, `fav export ... --json-result`, `sns ... --json`, `daily-stats --json`, `daily --no-ai --dry-run --json`, `daily-server --status --json`, `assistant status --json`, `assistant log --json`, `todos list --json`, `todos remind --json`, `evidence --json`, `sync run --dry-run --json`, `sync run --yes --json`, `sync status --json`, `sync verify --json`, `evidence-review --dry-run --json`, Vault mutation previews, confirmed `vault rag`, confirmed `search` and `chat`, daily-favorite previews, `search-index --dry-run --json`, `pipeline run --dry-run --json`, report-generator previews, login/logout previews, and `mcp-config --output <file> --dry-run --json-result`. CI runs three jobs: `node` (build plus the TypeScript suite on Node 22.13), `python` (the Python suites on 3.11), and `release-consistency` (package.json and package-lock.json version agreement, plus a check that the npm package carries no outputs, models, databases or keys). The jobs are separate because a Node failure previously stopped the Python tests from running at all, which let them fail unnoticed.
44
108
 
45
109
  The daily workflow supports `dailyAiEnabled=false` for a persistent no-AI mode, or `daily --no-ai` for a single run. Both the CLI and direct Python entry points honor the setting. Fetching, HTML generation, and local indexes remain available in that mode. Machine execution requires `daily --yes --json`; it keeps progress logs on stderr and returns a machine-readable completion result on stdout after checking the required local artifacts.
46
110
 
@@ -94,7 +158,7 @@ Documentation was synchronized with the current source baseline on 2026-09-19. C
94
158
  - Search reranking is fail-soft by design: no key, a network failure or an overloaded service (`HTTP 529` was observed live) all return the first-stage order with a warning. Its one real hazard is candidate numbering - a model that answers question `c3` against candidate `c5` sinks the best hit with no error at all - so every candidate is labelled in the state and a separate single-choice question cross-checks the argmax, warning on disagreement. Verified on real data before shipping, but only on one planted query.
95
159
  - `build_index` had never indexed any article (`glob(' marriage*.md')` matched 0 of 188 files), so semantic search covered chat messages only. The glob is fixed; **the article corpus will enter the index on the next rebuild**, which spends embedding calls, so it is a deliberate action rather than a side effect.
96
160
  - Every config key a script reads is declared in `CliConfig`, so `config set` can set it; `test/config_keys_declared_test.py` enforces this with a justification-required exception list. Two keys were found by that audit: `dashscopeApiKey` (read by two scripts, absent from the schema entirely) and `favPassphrase` (a first-class encrypted secret that the allowlist refused). Both are settable now, and `dashscopeApiKey` became encrypted.
97
- - `bizKey` / `bizSalt` are read by three scripts and written by nothing, so that branch is unreachable and `biz_message_0.db` is always opened via the key derived from `favPassphrase`. Recorded rather than "fixed": adding config surface for a value nothing produces would be inventing a knob, not restoring one.
161
+ - `bizKey` / `bizSalt` are read by three scripts and written by nothing, so that branch is unreachable. **This entry used to add that `biz_message_0.db` "is always opened via the key derived from `favPassphrase`", and that half was never checked against the three callers**: only `biz_daily.py` had the fallback - `chat_stats.py` and `mcp_bridge.py` errored out with "missing key" and pointed the user at `config set bizKey`, a key that is not in the CLI's writable allowlist, so following their own advice reached `INVALID_CONFIG_KEY`. `biz_daily.py` additionally computed the same key **twice in a row** (a `bizKey` pass immediately overwritten by a passphrase pass), which is what a fork in the logic leaves behind. **Fixed on 2026-10-01 rather than left recorded**: the derivation now exists once, in `_utils.biz_message_db()`, all three call it, and `test/biz_message_db_test.py` asserts that no other script reads `bizKey` - a pattern check, so a script added later is covered too. **No configuration surface was added**: the dead branch was deleted rather than given a knob to turn, which is exactly what the original note was protecting against. Verified by running the path that used to fail (`daily-stats` now returns per-account counts; a daily dry run opens the database).
98
162
  - No script reads a `configService.ENCRYPTED_KEYS` member without decrypting it; `test/encrypted_config_test.py` enforces this statically (accepting the `*_enc` hold-then-decrypt convention). Four scripts used to read `deepseekApiKey` raw. They worked only because that one key is plaintext on the development machine - every other encrypted key on disk is `lock:` ciphertext, so a routine `config set` of that key would have broken all four at once with an error that named the wrong cause.
99
163
  - **`assistant start` had never once started the daemon on the machine this was developed against.** The child was spawned without `--yes` while `assistant run` confirms through `inquirer` on a stdin the daemon had set to `ignore`, so the child died on the prompt while the parent printed「✓ 守护进程已启动 (pid …)」and wrote a pid file - and `~/.weflow-cli/` contained **no `assistant.log` and no `assistant.pid` at all**, which is how the documented `assistant start` flow turned out never to have completed. The env marker that appeared to guard this was read by nothing and was built as an env **key** containing `=` (`'WEFLOW_ASSISTANT_DAEMON=1'`). Both are gone (D-039): the parent confirms and passes `--yes` explicitly, and success is now **observed** - the child is watched for 700 ms, a dead one is reported with its exit code and the log tail, and no pid file is written. Verified live at the time: it reported `子进程启动后立即退出 (code 1)` with a log tail naming the real blocker - the message channel was not logged in (`wechatOcToken` unset). **That blocker no longer stops the daemon**: since the local panel (D-045) the channel is optional, `assistant start` comes up in local-entry mode when no token is configured, and the sender allowlist being empty is reported by `assistant status --json` as `channelActive: false` rather than as a failed start.
100
164
  - **Local data is framed and escaped before it enters the system prompt**, and facts are injected by
@@ -180,6 +244,33 @@ image-carrying outputs is needed for the knowledge base, and the difference is n
180
244
  `wechat-vault/Sources/WeChat/<date>/` (`pipeline.py:133`), so the same 35,463 articles would cost ~65 GB the daily
181
245
  way and ~0.6 GB as text. That "identical copy" describes the **daily** path only - since 2026-09-28 the backfill path's copy goes through `strip_wx_ads_document`, so it is no longer byte-identical to `biz-daily` (the original stays there, and D-055 explains why the cleaning had to live inside the copy step rather than the fetch). That measurement is what settled the design; the 10-minute timeout and the serial fetch loop
182
246
  were symptoms of the same mismatch, that this path was built for one day at a time.
247
+ `--topic` narrows a run to one topic, and it is the script's **own** classifier that does the narrowing - `_guess_topic`
248
+ reads a title and an account name, never the body, which is what makes pre-filtering possible at all and also what
249
+ bounds the claim: the AI set is the keyword's, not the daily line's model's, and these articles have never been
250
+ through a model. Partially backfilled days are marked (`topicFilter`, plus `truncated` for a `--limit-per-day`
251
+ slice) so `day_done` will not mistake one for a finished day - D-060. **Run the same day**: the AI subset is
252
+ fetched and on disk - **1,702 of the 1,758** in-scope articles across 156 days (96.8%), needing 1,208 network fetches;
253
+ `output/biz-daily/<day>/AI/` holds them, no other topic directory was created, every one of those days carries
254
+ `topicFilter`, and a full run would skip **0** of them. The 56 not pulled are not a fetch defect: re-probing all 84
255
+ failures individually shows **28 were transient** (the retry produced the body, and they are inside the 1,702),
256
+ **44 are gone for good** (37 "deleted by the publisher", 7 "removed for violation"), 9 serve a page with neither
257
+ content nor a deletion notice (8 of the 9 from one account, so it reads as that account's back catalogue having been
258
+ withdrawn), and 3 are image-only. Two things that surfaced here and matter before trusting `day_done`: a failed fetch
259
+ is **permanent for that day**, because the day is marked done by whether it holds any article at all; and `--refresh`
260
+ rewrites `.articles.json` from that run's results, so an article failing a *second* time drops out of the index while
261
+ its md stays on disk - 4 of the 35 re-run days did exactly that, and their entries were put back by hand from the
262
+ run's snapshot (add-only, and only for md files that exist). All 156 days now have exactly as many index entries as
263
+ article files. **The other five topics were then fetched the same way**, which is what exposed the last defect of
264
+ `--topic`: `write_day` rewrote `.articles.json` from that run's results, so the second pass dropped the first pass's
265
+ entries while its files stayed on disk. The index now describes the **day**, not the run - other topics' entries are
266
+ carried over and merged (de-duplicated on the name the file actually has), `topicFilter` becomes the union, and it is
267
+ **removed** once that union covers all six topics, since otherwise the day could never converge and every later full
268
+ run would refetch the window. `truncated` is sticky, an unreadable index is kept as `.articles.json.bad`, and
269
+ `--topic` takes repeated flags as well as commas (the same flag in `article_notes.py` is repeatable, so
270
+ `--topic AI --topic 学术` had been silently acting on the last one only). Verified over the window: **8,754** entries
271
+ across 157 days, **0** days where the index and the directory disagree, **0** entries lost across the two passes, and
272
+ 155 of the 157 days now count as complete - the two that do not are days that hold no articles of the topic they are
273
+ missing (one is AI-only, one has no AI at all), so nothing is pending on them.
183
274
 
184
275
  **Concept extraction is the one step that costs money per article, and its cost is now measured rather than
185
276
  modelled.** `article-notes --topic` filters the corpus, because measured over the 3,498 backfilled articles news is
@@ -267,6 +358,27 @@ because nobody built it" look identical to a reader.
267
358
 
268
359
  - **Topic exclusion is a display-layer switch, and that is a measurement result rather than a preference.** `dailyExcludeTopics` (or `--exclude-topics`) removes a topic from the daily report and the reader page; the article is still fetched, judged and archived. Filtering *before* the fetch was the original ask and was rejected on evidence: from a source name plus a title and the platform digest - everything that exists pre-fetch - the type judgement agreed with the source-level configuration on only **60%** of 217 articles, with 48 false positives, and there is no gold standard either way. A dropped fetch is irreversible (nothing is archived, and the page is not the same weeks later) while an unwanted article costs only attention, so the decision is deliberately placed where it can be undone by regenerating. The exclusion is orthogonal to `--include-all` and to the inclusion score, and **all three lists in 我拿不准的 are filtered** - passing the exclusion set to `admits()` in the "not admitted" list had the opposite effect and surfaced the excluded articles (that list is populated by exactly the articles being excluded). One predicate (`is_excluded_topic`) now serves both directions. Excluding the focus topic is refused with a WARN, because the result is not an empty report but a misleading one: it exits with "未找到文章,请先运行 biz_daily.py". The key is registered in all four `configService` literals plus `bin`'s `configurableKeys`, which `test/config-keys.test.ts` now enforces - a key missing from defaults or `clear()` survives `config set` and then quietly disappears.
269
360
  - **The source-level prior is recorded, never acted on.** Every daily appends `(source, topic)` counts for the articles it archived to `~/.weflow-cli/source_topics.json`, and names the sources whose history is stable and lands in an excluded topic (`来源先验: 甲号 → 新闻(20 篇里 19 篇 = 95%)`). **Nothing is skipped.** The table has to be measured rather than authored - the pre-fetch guess it replaces was already measured at 60% - so recording is a prerequisite for a future skip decision, not that decision. It lives outside the repository because it holds 公众号 names (a test asserts this), counts are append-only, and only articles that landed on disk are recorded, so the table and `output/` describe the same corpus. Thresholds (8 samples, 80% share) are **provisional and uncalibrated**. `stable_source_topic` returns `None` for "not enough history yet", and callers must not read that as a default topic - skipping a source *because* it is unknown is the worst failure available here. Whether a stable source is ever skipped before the fetch remains the user's call, to be made on the printed numbers.
361
+ - Skills are **not** an extension mechanism in the capability sense: installing one adds no tool, executes no code, and
362
+ reaches no configuration, database or arbitrary file. `read_skill` can only return a file named `SKILL.md` that a
363
+ directory scan already produced, so a traversal-shaped id (`../config.json`) is not a path at all - asserted case by
364
+ case. Symlinked skill directories **are** followed, deliberately (developing a skill in a checkout and linking it in
365
+ is a normal workflow, and the link is placed by the user); this is a known and accepted behaviour, not a gap. The id
366
+ rule is wider than the Anthropic Agent Skills specification on purpose - that spec would reject 5 of the 28 skills on
367
+ the development machine - so an id like `clz_docx_to_mp` is usable and warned about rather than refused. There is no
368
+ installer, no update path and no per-agent deployment: the roots are read, not written. `read_skill` truncates a body
369
+ at 8 KB and says so.
370
+
371
+ - Scene matching deliberately covers **three** tiers, not four: the reference implementation's sender-fingerprint tier
372
+ (Jaccard over the people who speak in a group) is not implemented, because this project has no service-layer query
373
+ that lists a conversation's participants - `wcdbCore.getGroupMembers` exists but has no caller, so the tier would cost
374
+ a database read on every turn, and the explicit binding already covers the stable case. An ambiguous keyword match is
375
+ refused rather than resolved. Scenes are keyed by `userId`, which equals the conversation id on the WeChat path but
376
+ collapses to the literal `'mcp'` on the MCP path, so there is no per-conversation binding there.
377
+
378
+ - The assistant can now cause two kinds of outbound effect from an untrusted input (a chat message). `fetch_article` is allowlisted to credential-free HTTPS on `mp.weixin.qq.com` **and re-checks that allowlist on every redirect hop** (asserted on the `redirect: 'manual'` option, because a stubbed fetch cannot observe the runtime following a redirect internally - that gap was found by a mutation check). `search_public` has **no guard and no confirmation gate**: its URL is a constant, so no allowlist applies, and the model's query leaves the machine to a third-party search engine with no preview. That is an accepted risk, chosen explicitly, and recorded in D-058 - do not describe this surface as "no egress" anywhere.
379
+
380
+ - `set_todo_status` is the only model-callable write besides `export_chat`, and it is deliberately **not** on the MCP surface ("mutating todos stays out"): the chat/panel path can flip one existing todo between done and pending with no preview, while an MCP client cannot reach it at all. It cannot create, delete or edit any other field, and an ambiguous task-text match is refused rather than resolved.
381
+
270
382
  ## Current Priorities
271
383
 
272
384
  1. Keep initialization and database access compatible with supported WeChat 4.x variants, with reproducible issue diagnostics that contain no secrets.
@@ -0,0 +1,108 @@
1
+ /**
2
+ * MCP 的 **HTTP 传输**(Streamable HTTP)。stdio 那条路一个字没动,这是**另一条**路。
3
+ *
4
+ * 为什么要它:stdio 只能被"能起进程"的客户端用(Claude Code、IDE 插件)。HTTP 那条路在
5
+ * 生态里已是常态——chatlog 就是拿 Streamable HTTP / SSE 把同一批查询能力摊出来的,于是浏览器型
6
+ * 客户端、常驻服务、多个客户端同时连都能用。工具集**完全一样**(同一张 `TOOL_DEFS` 派生表),
7
+ * 变的只是搬运动作的方式。
8
+ *
9
+ * 三道边界(判定在 `httpConfig.ts`,纯函数、有测试):
10
+ * 1. **默认不开**:不给 `--http` 就是 stdio,行为与以前逐字相同。
11
+ * 2. **必须带令牌**,且**只绑回环**(远程走 SSH 隧道,见 docs/MCP.md)。
12
+ * 3. **打开 DNS 重绑定防护**:SDK 的默认是 `false`,而本机 HTTP 服务最现实的攻击面就是
13
+ * "你正在浏览的某个网页往 127.0.0.1 发请求"。打开后 Host 头必须对得上,带 Origin 的
14
+ * 浏览器请求也必须在白名单里。
15
+ *
16
+ * 采用**无状态**模式(每个请求一套 server+transport):这一层只有只读查询,没有要跨请求维持的
17
+ * 会话,无状态就没有"会话之间串了"这类问题。
18
+ */
19
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
20
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
21
+ import type { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js'
22
+ import {
23
+ allowedHostsFor,
24
+ allowedOriginsFor,
25
+ isAuthorized,
26
+ type HttpOptions,
27
+ } from './httpConfig.js'
28
+
29
+ export const MCP_HTTP_PATH = '/mcp'
30
+
31
+ export interface RunningHttpServer {
32
+ /** 实际绑定的地址(端口传 0 时是内核挑的,所以以这里为准) */
33
+ url: string
34
+ port: number
35
+ close: () => Promise<void>
36
+ }
37
+
38
+ function json(res: ServerResponse, code: number, body: unknown): void {
39
+ res.writeHead(code, { 'content-type': 'application/json' })
40
+ res.end(JSON.stringify(body))
41
+ }
42
+
43
+ /**
44
+ * 起 HTTP 传输。`buildServer` 每次调用都必须返回一个**新的** Server(无状态模式每个请求一套)。
45
+ */
46
+ export async function startHttpServer(
47
+ opts: HttpOptions,
48
+ buildServer: () => McpServer,
49
+ log: (line: string) => void = () => {},
50
+ ): Promise<RunningHttpServer> {
51
+ let allowedHosts: string[] = []
52
+ let allowedOrigins: string[] = []
53
+
54
+ const handler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
55
+ const url = req.url ?? '/'
56
+ if (url !== MCP_HTTP_PATH && !url.startsWith(MCP_HTTP_PATH + '?')) {
57
+ json(res, 404, { error: 'not found', hint: `MCP 端点在 ${MCP_HTTP_PATH}` })
58
+ return
59
+ }
60
+ // 令牌先判、再谈别的:没令牌的请求连"MCP 认不认识这个方法"都不该知道。
61
+ if (!isAuthorized(req.headers.authorization, opts.token)) {
62
+ json(res, 401, { jsonrpc: '2.0', error: { code: -32001, message: '未授权:缺少或不对的 Bearer 令牌' }, id: null })
63
+ return
64
+ }
65
+
66
+ const transport = new StreamableHTTPServerTransport({
67
+ sessionIdGenerator: undefined, // 无状态
68
+ enableJsonResponse: true, // 不强制 SSE:普通客户端拿 JSON 就好
69
+ enableDnsRebindingProtection: true, // SDK 默认 false,必须显式开
70
+ allowedHosts,
71
+ allowedOrigins,
72
+ })
73
+ const server = buildServer()
74
+ res.on('close', () => { void transport.close(); void server.close().catch(() => {}) })
75
+ try {
76
+ await server.connect(transport)
77
+ await transport.handleRequest(req, res)
78
+ } catch (error: any) {
79
+ log(`[mcp-http] 请求处理失败: ${error?.message ?? error}`)
80
+ if (!res.headersSent) {
81
+ json(res, 500, { jsonrpc: '2.0', error: { code: -32603, message: '内部错误' }, id: null })
82
+ }
83
+ }
84
+ }
85
+
86
+ const httpServer: Server = createServer((req, res) => { void handler(req, res) })
87
+
88
+ await new Promise<void>((resolve, reject) => {
89
+ httpServer.once('error', reject)
90
+ httpServer.listen(opts.port, opts.host, () => {
91
+ httpServer.removeListener('error', reject)
92
+ resolve()
93
+ })
94
+ })
95
+
96
+ const address = httpServer.address()
97
+ const port = typeof address === 'object' && address ? address.port : opts.port
98
+ // **绑定之后**才算白名单:端口传 0 时真实端口此刻才知道,而白名单必须用真实端口。
99
+ allowedHosts = allowedHostsFor(opts.host, port)
100
+ allowedOrigins = allowedOriginsFor(opts.host, port)
101
+
102
+ const shownHost = opts.host === '::1' ? '[::1]' : opts.host
103
+ return {
104
+ url: `http://${shownHost}:${port}${MCP_HTTP_PATH}`,
105
+ port,
106
+ close: () => new Promise<void>((resolve) => { httpServer.close(() => resolve()) }),
107
+ }
108
+ }