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
package/docs/DECISIONS.md CHANGED
@@ -4,6 +4,9 @@
4
4
 
5
5
  > 编号说明:`D-009` 从未使用(D-008 直接跳到 D-010),保留为空号;
6
6
  > 历史上曾有两条决策共用 `D-016`,其中「architecture visuals」一条改号为 `D-028`。
7
+ > `D-056`~`D-058` 原本写在**另一条分支** `feat/assistant-skills-scenes-tool-surface` 上(技能包/场景/工具面那三条),
8
+ > 而 `D-059`(悬浮球的眼睛跟随)写在 `feat/panel-eye-follow` 上。本分支当时为避免合并时撞号,直接从 `D-055` 跳到 `D-059`。
9
+ > 两条分支已在同一次合并里并入 `master`(2026-09-30),所以现在 `D-055`~`D-059` 五个号都在,没有空号。
7
10
 
8
11
  ## D-001: Local-first data handling
9
12
 
@@ -1400,6 +1403,353 @@ which is what the gate is for.
1400
1403
  semantics are not reliable here), as is any automatic labelling: there is no gold standard for "is this
1401
1404
  draft right", so the feature records what it judged and says so rather than claiming calibration.
1402
1405
 
1406
+ ## D-063: Sending stays unreachable from the model, and becomes reachable from a human who names the candidate
1407
+
1408
+ **Status:** Active
1409
+
1410
+ **Decision.** `weflow-cli draft <talker>` already did the hard half - judge the intent, draft three deliberately
1411
+ different candidates, rank them with the decision model - and ended by saying the candidates are **only text** and that
1412
+ sending is the user's call. It now also carries that decision out, under three rules:
1413
+
1414
+ 1. **`--send` requires `--pick N`.** There is no "draft three, let it choose, let it send" path. The human names the
1415
+ candidate; the model never selects what goes out.
1416
+ 2. **Two-phase.** Without `--yes` there is no send: JSON mode returns `CONFIRMATION_REQUIRED` **with the text in the
1417
+ payload**, interactive mode asks "确定把第 N 条发给 X?". Both non-`--yes` paths are covered by tests that also assert
1418
+ **no send attempt reached the audit log**.
1419
+ 3. **One send policy, not two.** Blacklist → whitelist → rate limit → audit now lives in `src/services/outboundSend.ts`
1420
+ and is called by both `send` and `draft --send`. Before this, that chain existed only inside the `send` command's
1421
+ action; a second copy is how a "send" path ends up quietly skipping the whitelist.
1422
+
1423
+ **Why the model stays out of it.** `docs/EXTENDING.md` states that sending is *structurally unreachable from a
1424
+ model-driven path*, and that is not a mood: the assistant and the MCP surface have no send tool, and
1425
+ `test/draft-send-cli.test.ts` now asserts that `send`, `draft`, `send_message` and `publish_article` are absent from
1426
+ `TOOL_DEFS` (while `draft_reply` stays present - drafting is not sending). What changes here is only the **human's**
1427
+ side: after reading the candidates, the user no longer has to copy one into another terminal.
1428
+
1429
+ **Constraints that fell out of the measurements.** (a) `--pick` out of range and `--send` without `--pick` are
1430
+ **usage errors, so they are reported before any model call** - the first version validated them after the confirmation
1431
+ gate, which meant the model ran, and was paid for, before the command said "there is no candidate 9". (b) `--dry-run`
1432
+ means **zero egress**, and candidates can only come from a model, so `--dry-run` cannot preview "what candidate 2 would
1433
+ send"; it reports the character count the script would send and says why, rather than pretending. (c) Sending still
1434
+ requires the other side's `context_token` - the channel needs a message from them first - so "send to anyone" was never
1435
+ available, and the whitelist remains the real boundary.
1436
+
1437
+ **Consequences.** The tests do not call a model and do not need one: refusing early is exactly what they assert. The
1438
+ audit file (`~/.weflow-cli/audit-send.log`) is what they read, and the positive control that proves that read is not
1439
+ vacuous does **not** come from a real send - with a temporary home the channel is not logged in, so `send --yes` exits
1440
+ at `MESSAGE_CHANNEL_NOT_LOGGED_IN` before the audit line. It uses the **blacklist** branch instead, which audits before
1441
+ that check. That detail cost one debugging round and is written here because the next person will hit it too.
1442
+
1443
+ ## D-062: Relationship temperature stays on the command line, and its "who counts as a person" rule flags rather than drops
1444
+
1445
+ **Status:** Active
1446
+
1447
+ **Decision.** `scripts/bonds.py` reports three things from the local message database: relationships that are cooling
1448
+ (substantial history, then silence), relationships heating up (the last 7 days well above the long-run rate), and
1449
+ contacts whose history never mentions another channel (phone, email, meeting up) - i.e. the ones reachable *only*
1450
+ here. It is **read-only, local, and calls no model**. Two rules are pinned:
1451
+
1452
+ 1. **It is not in the tool table.** It is not exposed to the assistant, to MCP, or to any cloud model - not as a tool
1453
+ and not as context. Everything else this project computes about the user is either explicitly shareable or has a
1454
+ confirmation gate; this one is neither. Its output is an **inference about third parties** (which relationships the
1455
+ user is neglecting, who they would lose), derived from private messages, and the person it could most embarrass is
1456
+ not the person running it.
1457
+ 2. **"Who counts as a person" flags, it does not drop.** The rule is the **shape of the id** - anything containing
1458
+ `@` (`@chatroom` groups, `@openim` service accounts, `@weclaw` bots), `filehelper`, or a `gh_` official account, is
1459
+ not a person; everyone else is. Accounts that are structurally indistinguishable from a person but whose *name*
1460
+ looks like a shop or a hotline (普华口腔门诊部 is one on this machine, with `local_type = 1` and its own alias) are
1461
+ listed under "flagged, you decide", with a skip file (`output/bonds-skip.txt`) and `--skip` to act on it. And every
1462
+ account the run excluded is printed with the reason, so a filter can never quietly remove two friends.
1463
+
1464
+ **Reason.** Three measurements killed three simpler designs. (a) Ranking by message volume produced a leaderboard
1465
+ with no information - the people you message most are not news to you. (b) "Never mentions another channel" was
1466
+ **inverted**: it is trivially true of bots, so the first version of the irreplaceability ranking had 星巴克小助手,
1467
+ 天天神券福利君, 串掌门(南村店)and 文件传输助手 at the top. (c) `flag` looked like the clean human/bot marker - real
1468
+ friends mostly carry `flag = 3` - until the distribution was read: of 38 people, three differ, and only one of those
1469
+ (`微信ClawBot`, 2049) is a bot; 彪弟 (2051) and 平凡的世界 (2563) are people whose bit was set by however they were
1470
+ added. Shipping `flag == 3` would have silently deleted two friends, which is the failure this project keeps meeting:
1471
+ a filter that is 95% right and reports nothing.
1472
+
1473
+ **Consequences.** The thresholds are constants with tests and mutation checks (`COOL_DAYS`, `HOT_RATIO`, the minimum
1474
+ history required for "cooling"), because they are the whole judgement. The skip file lives under `output/` (gitignored,
1475
+ never committed). Adding it as a command in `bin/weflow-cli.ts` is fine and expected; adding it to the assistant or MCP
1476
+ tool tables is not, and that is the part of this decision worth keeping written down.
1477
+
1478
+ ## D-061: The MCP server can speak HTTP - so the boundary had to be written down, not just implemented
1479
+
1480
+ **Status:** Active
1481
+
1482
+ **Decision.** The MCP server serves its tools over **Streamable HTTP** as well as stdio, and the second transport is
1483
+ **off unless asked for** (`--http`, or `WEFLOW_MCP_HTTP`). The tool table is the same one (`MCP_TOOL_DEFS` derived from
1484
+ `TOOL_DEFS`), so the two transports cannot drift; only the carrying changes. Four rules are enforced in
1485
+ `mcp-server/httpConfig.ts` - pure functions with offline tests - rather than left to the caller:
1486
+
1487
+ 1. **Off by default.** No flag means stdio, byte for byte as before.
1488
+ 2. **A token is mandatory, with no default.** Missing or under 24 characters, the process refuses to start.
1489
+ 3. **Loopback only.** `0.0.0.0` and every other non-loopback address are rejected with the tunnel command in the
1490
+ error message.
1491
+ 4. **DNS-rebinding protection on.** The SDK's default is `false`; the realistic attack on a loopback server is a web
1492
+ page the user happens to visit reaching `127.0.0.1`, so the `Host` header must match and any `Origin` must be
1493
+ allowlisted.
1494
+
1495
+ **Why HTTP at all.** stdio can only be spoken by a client that is able to spawn a process. Serving HTTP is the norm
1496
+ in this ecosystem - `chatlog` is the de-facto standard and does exactly this - and it is what lets a resident
1497
+ service, a browser-side client, or several clients at once use the same query surface.
1498
+
1499
+ **Why the boundary is written down rather than only coded.** This is the first time this project opens a **listening
1500
+ socket that serves the user's private messages to whoever can reach it**. The failure mode is not a crash: it is a
1501
+ port that stays open, bound wider than the author intended, or opened with a token that looks configured and is not.
1502
+ So each rule is (a) in a pure function with its own test, (b) mutation-checked - disabling the rebinding protection,
1503
+ removing the token check, widening the loopback list, lowering the token floor, or making the check return `true`
1504
+ each turn the suite red - and (c) stated in `docs/MCP.md` where a user meets it.
1505
+
1506
+ **Why no remote bind.** The tempting convenience - `--host 0.0.0.0` so a laptop can reach the desktop - is exactly
1507
+ the change that moves private chat data onto the network. A tunnel is one command and keeps the exposure local; if
1508
+ someone later wants a real remote surface, that is a separate decision with its own threat model, not a flag here.
1509
+
1510
+ **Consequences.** `mcp-server/index.ts` now exports `buildServer()` and starts only when run as a script (importing
1511
+ it must not open a listener - the tests import it). The token is never logged. Requests are stateless (one server per
1512
+ request, no session id), so there is no cross-client state to leak. Nothing about the stdio path changed: an existing
1513
+ client configuration keeps working untouched.
1514
+
1515
+ ## D-060: A backfilled day records which topics it holds, because "the file exists" is not "the day is done"
1516
+
1517
+ **Status:** Active
1518
+
1519
+ **Decision.** `backfill_articles.py` can fetch one topic instead of a whole day (`--topic AI`), and a day written
1520
+ that way is **partial and says so**. It uses the classifier the script already had - `_guess_topic`, which reads only
1521
+ the title and the account name and never the article body - so filtering first selects exactly the articles a full run
1522
+ would have filed under that topic. There is no second criterion. What is new is the bookkeeping: `.articles.json`
1523
+ carries `topicFilter`, the README states which topics are missing, and `day_done` refuses to call that day complete,
1524
+ so a later full run still covers it.
1525
+
1526
+ **Reason: one truncation prevented, and one found already live.** Without the marker, a day holding only its AI
1527
+ articles looks finished - the md files exist, `.articles.json` exists, and `day_done` asked nothing more than "is the
1528
+ list non-empty". The other five topics would never be fetched and **nothing would report it**, which is the failure
1529
+ shape this repository has already paid for once: `--vault-sync` was keyed off a `backfilled` marker that only one of
1530
+ its two writers sets, so 12 days were never copied *and* never reported as skipped (2026-09-28, 187 → 199 days).
1531
+ Checking for this one turned up the same defect already shipped in `--limit-per-day`: the day is sliced, written, and
1532
+ then reads as done, so everything past the limit was silently out of scope forever. It is recorded in the same marker
1533
+ (`truncated`) because it is the same mechanism, and it is a **behaviour change** - a limited day is now redone on the
1534
+ next run rather than skipped.
1535
+
1536
+ The filtering is applied **inside `write_day`** as well as at the call site. If only the caller filtered, a caller
1537
+ that forgot to would write all six topic directories and stamp "only AI" on them; a marker that lies is worse than no
1538
+ marker, and this is the only way to make the two structurally unable to disagree.
1539
+
1540
+ **Consequences.** `.articles.json` gains two optional keys; a full run's output is byte-identical to before, so no
1541
+ existing output needs migrating, and `day_done` reads a missing `topicFilter` as "complete" - which is also what the
1542
+ days already on disk mean. `--topic` changes no criterion: over 2025-09-05 ~ 2026-03-02 the script's own classifier
1543
+ puts **1,758 of 9,207** in-scope articles in `AI` (5-day sample: 300 → 66, the same share the daily pipeline reports
1544
+ over the 3,498 articles already backfilled). That figure is the tool's **keyword** view and not the daily line's model view -
1545
+ these articles have never been through a model, which is why the script can pre-filter at all, and why the count is
1546
+ labelled as the keyword's. The scope is official accounts only (`gh_%`), which is what the script queries - an earlier
1547
+ count over every session in `Name2Id` read 10,880 and was wrong by 1,673 articles that live in non-official
1548
+ conversations and that this path never fetches. Anything scripted against a partially backfilled day must read `topicFilter` rather than
1549
+ infer completeness from the presence of `.articles.json`. An unknown `--topic` is rejected before the database is
1550
+ opened, since a typo would otherwise select 0 articles and report "nothing to do" - indistinguishable from a day that
1551
+ genuinely had none.
1552
+
1553
+ ## D-059: The ball's eyes follow the mouse - and that makes the cursor position a data flow, so it is written down
1554
+
1555
+ **Status:** Active
1556
+
1557
+ **Decision.** The mascot's irises track the cursor. The position comes from the **main process**
1558
+ (`screen.getCursorScreenPoint()`, sampled every 120 ms, pushed **only when the coordinates change**), because the
1559
+ renderer cannot see it: the ball is 96x96 and the pointer is outside the window almost all of the time, so
1560
+ `mousemove` never fires. The page eases toward the pushed point and **stops its rAF loop the moment it settles** -
1561
+ a permanently-running 60 fps transform would be a compositor cost paid for decoration, on a window that is always
1562
+ open.
1563
+
1564
+ **Why there are now two images instead of one.** The mascot PNG has its eyes **drawn into** it, so nothing in it can
1565
+ move. `mascot-base.png` is the same art with the iris region repainted in the sclera colour, and `mascot-iris.png` is
1566
+ the iris (with its pupil and highlight) as its own layer. Composited at zero offset the two are **pixel-identical**
1567
+ to `mascot.png` - a test decodes all three PNGs and asserts that pixel by pixel, because every weaker check (same
1568
+ size, present in the whitelist, present in `PANEL_FILES`) stays green if somebody re-cuts them one pixel off, and a
1569
+ one-pixel misalignment is visible to a human and to nothing else. `mascot.png` stays in the repo as the source the
1570
+ other four expressions are generated from, but it is **no longer what the ball shows** - pointing the ball's face at
1571
+ it while an iris layer moves on top would show two pupils, one of them stationary.
1572
+
1573
+ **The travel limits are measured, not chosen.** Scanning the source pixels along the iris: to its left there are
1574
+ about 7 px of sclera before the dark rim, and above it there is none at all (the upper lid sits on the iris). At the
1575
+ ball's 96 px that is roughly 2.6 px of usable travel sideways and essentially zero upward, so the clamp is an
1576
+ **ellipse** (2.6 sideways / 0.8 up / 2.6 down) rather than a circle. Past that the iris separates from the rim and
1577
+ the eye reads as a sticker sliding across the face. The constants are written for the 96 px ball and are scaled by
1578
+ the element's measured width, so changing `--ball-size` does not require re-deriving them.
1579
+
1580
+ **The eyes move only on the two faces that have any to move.** Four of the five faces have their eyes baked in;
1581
+ stacking a moving iris on those shows two pupils. The iris layer is therefore hidden for `busy` / `offline` /
1582
+ `quota` / `ball-happy`. That is deliberate, not a limitation: those are precisely the "not idle" states, and they
1583
+ already speak with the whole face.
1584
+
1585
+ **Why a data-flow entry.** Cursor coordinates are the panel's second input that does not come from the daemon (the
1586
+ first is the window geometry from `ball-position.cjs`). They travel **in-process only** - main process to this
1587
+ window's renderer - are narrowed in `preload.cjs` (two clamped integers; malformed payloads are dropped rather than
1588
+ handed to the page) along the same rule as every other channel, are never logged and never leave the machine, and
1589
+ the feed stops entirely while the window is hidden. "The app knows where your mouse is" is a data flow even when it
1590
+ never crosses the machine boundary, so it is recorded rather than left implicit.
1591
+
1592
+ **Not yet verified live.** The wiring (the feed, the clamp, the reduced-motion path, the pixel identity) is covered
1593
+ by the suite, but the effect itself has not been run on a real window - that window only exists on a machine with
1594
+ Electron, via `weflow-cli panel`. The amplitudes come from the pixel measurements above; whether 2.6 px reads as
1595
+ "looking at you" at 96 px is a judgement only a person in front of the screen can make.
1596
+
1597
+ ## D-058: The assistant reaches the MCP ceiling minus the exclusions - and two of the new capabilities carry a residual risk that is written down here rather than discovered later
1598
+
1599
+ **Status:** Active
1600
+
1601
+ **Decision.** The assistant's tool table was brought up to the same ceiling the MCP surface already had, minus
1602
+ everything the project has decided stays out. Three surfaces had drifted apart: the CLI exposes ~93 command
1603
+ declarations, `TOOL_DEFS` had 21 tools, and `mcp-server/index.ts` additionally hand-wrote 11 tools that the chat path
1604
+ could not reach at all (`get_review`, `get_concepts`, `format_article`, `list_themes`, `fetch_article`,
1605
+ `search_public`, plus the merged `get_stats`). Ten tools were added - `list_contacts`, `get_review`, `get_concepts`,
1606
+ `fetch_article`, `search_public`, `format_article`, `list_themes`, `lint_wiki`, `check_skills`, `set_todo_status` -
1607
+ the seven that existed only on the MCP side were **moved** into `TOOL_DEFS` (same tool names, same argument names,
1608
+ single implementation, so the hand-written table shrank from 11 entries to 4), and four existing tools were widened
1609
+ rather than duplicated: `get_daily_report` gained `full` (the human-readable daily) and cross-date search (which is
1610
+ what the MCP-only `search_articles` did), `get_todos` gained the item id and `group_by`, `get_weread` gained five
1611
+ `mode`s, `get_stats` gained the knowledge-base half it had been missing while the MCP copy had it.
1612
+
1613
+ **The direction is safe because it is not a new kind of access.** Every capability added here was already reachable
1614
+ by an MCP client; the change removes an inconsistency rather than widening what the project exposes. What did *not*
1615
+ move is the ceiling itself: sending, publishing, configuration, access-list writes, `evidence-review`, `vault promote`,
1616
+ scene reads and writes, `login-wechat`/`logout`/`listen`/`assistant run`, `sns capture-key`, `decide` and `init`/`dbkey`
1617
+ all stay unreachable from a model-driven path, each with the decision or comment that says so.
1618
+
1619
+ **Two capabilities carry a residual risk, and both were chosen deliberately by the user rather than slipped in.**
1620
+
1621
+ *Network egress from the chat path.* `fetch_article` and `search_public` are now callable from a WeChat message. The
1622
+ user was shown the asymmetry - the MCP path is documented as a trusted local integration, while a chat message is
1623
+ untrusted input that can carry a prompt injection - and chose to expose them **without** a confirmation gate, matching
1624
+ MCP. What each actually is: `fetch_article` accepts only credential-free HTTPS URLs on the exact `mp.weixin.qq.com`
1625
+ host and **re-checks that allowlist on every redirect hop** (the default `follow` would validate only the entry URL;
1626
+ the test asserts the `redirect: 'manual'` option, because a stubbed fetch cannot observe undici's internal follow -
1627
+ that hole was found by a mutation check). `search_public` is the weaker one and is recorded as such: it scrapes a
1628
+ third-party search page, its URL is a constant so no allowlist applies, and **the model's query leaves the machine
1629
+ with no preview**. Anyone reviewing that decision should read it as: a chat-driven model can cause an arbitrary string
1630
+ to be sent to a search engine, and that is accepted. The mitigation that exists is not a gate but the shape of the
1631
+ data: the query is a search phrase, never chat content.
1632
+
1633
+ *A bounded local write.* `set_todo_status` flips one existing todo between `done` and `pending`. It **cannot create,
1634
+ delete or edit** anything else - there is no `todos rm`, no `daily add/remove`, no `todos extract` in the tool table,
1635
+ and a test pins that (`工具表里没有任何删除待办的工具`). It carries **no** `ctx.requiresConfirm` gate, following the
1636
+ `export_chat` precedent (a write tool with no gate, because the gate set is machine-checked by
1637
+ `test/assistant-tools.test.ts` to be exactly `callsCloudModels` - adding a gate to a tool that does not send user data
1638
+ would either break that equality or require writing a false claim into `capabilities --json`). The residual risk is
1639
+ therefore stated plainly: **an MCP client does not get this tool at all** (`MCP_EXCLUDED`, because "mutating todos
1640
+ stays out" of that surface), while the chat/panel path can flip a status with no preview. Ambiguity is refused rather
1641
+ than resolved: a task-text match must be unique, and the reason is that a wrong guess looks like success while
1642
+ changing the wrong row.
1643
+
1644
+ **Reason.** Adding tools is not free: it makes the model choose among more near-duplicates, which is a failure mode
1645
+ this project has already measured once (`get_reading_stats`'s description had to be rewritten after it stole WeRead's
1646
+ question space and answered a reading question with official-account push counts). So the widenings in this entry are
1647
+ mostly *replacements*: cross-date article search went into `get_daily_report` rather than a second article-finder,
1648
+ `todos remind`'s grouping went in as a parameter rather than a tool, per-book WeRead data went in as `mode` values
1649
+ rather than five tools, and `get_concept` was deliberately **not** added because `search_knowledge` already returns
1650
+ the page plus its neighbour subgraph and a second, weaker lookup is a net loss for routing.
1651
+
1652
+ **Consequences.** Two injection points were added - `WEFLOW_ASSISTANT_BIZ_DAILY_DIR` and
1653
+ `WEFLOW_ASSISTANT_REVIEWS_DIR` - following `WEFLOW_ASSISTANT_EXPORT_ROOT`, and both are read **per call** rather than
1654
+ frozen at import so a test can swap fixtures inside one process. That closed the last uncovered tool branch: the test
1655
+ suite's coverage note had said for months that `get_daily_report` was never executed because its directory was a
1656
+ module-level constant with no injection point, and that the only possible assertion was a "either there is data or
1657
+ there is not" shape check - which is not an assertion. It is now asserted against a fixture, with its three branches
1658
+ (date, cross-date search, prose). The coverage claim is therefore "**all 31 tools are executed**" and the note says
1659
+ where each group lives. Locally-held identifiers were the other thing to get right: `list_contacts` returns names
1660
+ only (remark / display name / nickname - never `username`, never the avatar URL), because keeping wxids out of the
1661
+ model's context is a standing rule of this repository.
1662
+
1663
+ **Added 2026-09-30.** A third injection point, `WEFLOW_ASSISTANT_VAULT_DIR`, was added the same way and for the
1664
+ same reason: `get_concepts` read a module-level constant, so in the behaviour eval's temporary home it could only
1665
+ ever report "the knowledge base has not been compiled yet" - there was no fixture that could change that. With the
1666
+ root injectable it has a real case, as does `get_review` (its variable already existed, but the eval harness had no
1667
+ way to set environment variables at all, so it too had no case). **"That closed the last uncovered tool branch"
1668
+ above was true of the deterministic test suite, not of the behaviour eval** - the eval still had ten tools with no
1669
+ case at all, and those are what the two 2026-09-30 passes went after.
1670
+
1671
+ ## D-057: A scene is bound to a conversation by the one id that already exists, it is picked by three tiers that refuse to guess, and the model cannot write one
1672
+
1673
+ **Status:** Active
1674
+
1675
+ **Decision.** A scene is a named prompt preset - keywords, an extra instruction, an output spec, a required-skill list,
1676
+ an enabled flag - stored in its own versioned file (`~/.weflow-cli/assistant_scenes.json`, schema `weflow-scenes/v1`,
1677
+ unknown version quarantined rather than migrated, atomic write, same discipline as the memory file). A turn picks at
1678
+ most one scene through three tiers in order: an explicit **binding**, then a **keyword** hit in the incoming text, then
1679
+ the **last scene used** in that conversation. No tier matching means no scene section in the prompt - the default
1680
+ behaviour is byte-identical. When two scenes match a keyword of the same length the turn carries **no** scene and the
1681
+ trace records why, following `resolveUniqueTalker` and `resolvePanelUserId`: ambiguous input is refused, never guessed.
1682
+
1683
+ **The binding key is `userId`, which is the conversation on the path that has conversations.** On the WeChat path
1684
+ `resolveInboundRouting` sets `conversationId` to the `@chatroom` id for a group and the peer's wxid for a direct chat,
1685
+ and `assistantService` passes exactly that through as `userId`; so binding by `userId` *is* binding by conversation, and
1686
+ it also gives the panel path (one stable bucket) something coherent to bind to. The cost is written down rather than
1687
+ discovered later: the MCP path collapses every caller into the literal `'mcp'`, so **MCP has no conversation to bind a
1688
+ scene to** - the same collapse that already applies to memory. Keying scenes per `userId` inside the *memory* file was
1689
+ rejected outright: that file is rewritten by an LLM during compression and fact extraction, and it can be quarantined
1690
+ wholesale, neither of which is an acceptable fate for something the user authored by hand.
1691
+
1692
+ **The model cannot touch scenes, and that is the point.** There is no assistant tool that lists, reads, creates or
1693
+ binds them; scenes are administered by `weflow-cli scene …` and by one built-in chat command (`场景 <id>`, `场景 无`,
1694
+ `场景`). This is the same position the `隐私` built-in takes - a chat message must not be able to change configuration -
1695
+ and it is why `capabilities --json` reports `modelWritable: false` for the workflow. The built-in command is also the
1696
+ only prefix-matching command in the assistant, which is precisely the shape that silently eats ordinary sentences, so
1697
+ it is narrowed twice: the argument must name an existing scene id, and anything else (`场景切换怎么用`) falls through to
1698
+ the model untouched.
1699
+
1700
+ **Reason:** the roadmap's P3 item asks for per-conversation control of the assistant rather than one global
1701
+ personality, and the three tiers are ordered by how much the user meant them: a binding is an explicit statement, a
1702
+ keyword is an inference from one message, and "last used" is only a convenience that keeps a habit from decaying. The
1703
+ refusal to break a tie is the same rule the codebase already applies to names - a coin flip between two scenes looks
1704
+ like the feature working while producing an answer nobody asked for.
1705
+
1706
+ **Consequences:** `scene add`/`remove`/`bind`/`unbind`/`enable`/`disable` are two-phase (`--dry-run` then `--yes`),
1707
+ matching every other local mutation. The scene section enters the prompt wrapped by `frameLocalData`, because a
1708
+ scene's instruction is user-authored text that can contain `</weflow-local-data>`; scene ids are therefore validated
1709
+ against quotes and angle brackets before they reach the label, and sanitised again at render time. Verified end to end
1710
+ through the resident service with a stubbed model: the scene section appears in the system prompt, the trace carries a
1711
+ `note` step naming the scene and the tier that matched, a keyword tie produces no section plus an explanatory note, and
1712
+ a turn with no match produces no section at all.
1713
+
1714
+ ## D-056: A skill is material, not a plugin - it is referenced by path, it grants no tools, and a broken one is reported rather than skipped
1715
+
1716
+ **Status:** Active
1717
+
1718
+ **Decision.** The assistant can use skill packages: directories containing a `SKILL.md` with `name`/`description`
1719
+ frontmatter, scanned from a configurable list of roots (`skillDirs`, default `~/.claude/skills` and
1720
+ `~/.weflow-cli/skills`, overridable per-process by `WEFLOW_ASSISTANT_SKILL_DIRS`). Two read-only tools expose them -
1721
+ `list_skills` returns the catalogue (id, name, one-line description, version, state) and `read_skill` returns one
1722
+ body wrapped in `frameLocalData` - and the system prompt carries the catalogue so the model knows what exists. Skills
1723
+ are **not installed anywhere**: the prompt tells the model to read the file at its path, which is what the reference
1724
+ implementation does and is far cheaper than writing an installer per target agent. A referenced skill that is missing,
1725
+ disabled or unreadable is rendered as exactly that state, and the instruction is to continue and say which part was
1726
+ not done - never to silently drop the reference, and never to invent the capability.
1727
+
1728
+ **This is the constrained form of the roadmap's plugin/adapter item (P4), and it is deliberately the smallest one that
1729
+ is still useful.** Its acceptance criteria were already written down - extensions must not read configuration,
1730
+ databases or arbitrary files, and must be individually disableable - and a prompt-time document satisfies all of them by
1731
+ construction: `read_skill` can only return a file named `SKILL.md` that a scan already produced, so an id like
1732
+ `../config.json` is not a path at all, and disabling is one config key (`skillDisabled`) or one line in the skill's own
1733
+ frontmatter (`enabled: false`). Nothing here executes skill content, and installing a skill does not add a tool to the
1734
+ model's table. D-048 stands: the only way to add a *capability* is still to edit this source tree.
1735
+
1736
+ **Two rules were taken from real data rather than from the specification.** First, the id rule is **wider than
1737
+ Anthropic's**: that spec allows only lowercase letters, digits and single hyphens, which would reject five of the 28
1738
+ skills on the development machine (`clz_docx_to_mp`, `agent-dialog_management`, `clz_wechat_mp_ops`,
1739
+ `ylx_onehub_usage_monitor`, `ylx_research_evidence_synthesis`). Those are skills the user actually uses, so a
1740
+ non-conforming id is **warned about and kept**, and only the quoting characters that could escape a prompt label are
1741
+ refused. Second, the parser reads **only the leading `---` block** and understands block scalars: the same 28 files
1742
+ carry `categories:`/`created:`/`tags:` inside their bodies, so a whole-file scan reads body text as fields, and
1743
+ `description: >-` with indented continuation lines is how several of them are actually written - a naive line reader
1744
+ returns the two characters `>-` as the description.
1745
+
1746
+ **Consequences:** symlinked skill directories are **followed** (a deliberate choice, not an oversight): developing a
1747
+ skill in a git checkout and linking it into `~/.claude/skills` is a normal workflow, the link is placed by the user and
1748
+ not by an extension, and the only file reachable through it is one named `SKILL.md`. A skill with no frontmatter block
1749
+ is reported as unreadable by `skill check` instead of being skipped - a skill that vanishes silently is indistinguishable
1750
+ from one that was never installed. Verified against the real 28 skills with `skill check`: 0 unreadable, 0 collisions, 5
1751
+ spec-deviation warnings, 1 self-disabled.
1752
+
1403
1753
  ## D-055: The Vault's copy of an article is cleaned, the fetched original is not - and "which days does the copy run cover" was being decided by a marker that only one writer sets
1404
1754
 
1405
1755
  **Status:** Active
package/docs/EXTENDING.md CHANGED
@@ -6,7 +6,7 @@ one of two things, and they are not equivalent:
6
6
 
7
7
  1. **In-repo** - add the capability to this source tree (fork + PR). Every recipe below is about this path, and each
8
8
  one lists the places you must touch **and the test that will catch you if you miss one**.
9
- 2. **Out-of-process** - call the project from a program you keep elsewhere, through **MCP** (`mcp-server/`, 27 tools)
9
+ 2. **Out-of-process** - call the project from a program you keep elsewhere, through **MCP** (`mcp-server/`, **32 tools**: the 28 the assistant exposes minus the three it withholds from this path, plus four hand-written `wechat.*` ones - the table in `docs/MCP.md` is the list, and `test/tool-registry.test.ts` fails if it drifts)
10
10
  or the CLI's `--json` surface. This is the only boundary that exists for code that does not live in this repository.
11
11
 
12
12
  ## Why there is no plugin loader
@@ -176,12 +176,38 @@ These hold for any extension, and the full statements live in `AGENTS.md` and `C
176
176
  reporting a real change.
177
177
  - Do not record a guess as a verified fact. When something has not been measured, say so where the claim is made.
178
178
 
179
+ ## Skills are content, not an extension point
180
+
181
+ A skill is a directory containing `SKILL.md` (`name`/`description` frontmatter, optionally `version`, `triggers`,
182
+ `enabled`), scanned from `skillDirs` (default `~/.claude/skills`, `~/.weflow-cli/skills`). **It does not follow any
183
+ recipe above.** Nothing here is code that joins the process:
184
+
185
+ - **It adds no tool.** The model sees a catalogue in the system prompt and can call `read_skill` to read one body. The
186
+ tool table is unchanged, so installing a skill cannot widen what the assistant is able to do.
187
+ - **Its content is never executed.** No scripts, no dependencies, no installation step - the "installer" is the user
188
+ putting a directory where the scanner looks.
189
+ - **It cannot read anything.** `read_skill` only ever returns a file named `SKILL.md` that the directory scan already
190
+ produced; an id shaped like a path is not a path. That is the P4 acceptance criterion (extensions must not read
191
+ configuration, databases or arbitrary files) satisfied by construction rather than by a check.
192
+ - **It is disableable alone** (`config set skillDisabled <id>` or `enabled: false` in the file itself).
193
+ - Bodies enter the prompt through `frameLocalData`, exactly like memory and scene text, because a `SKILL.md` is
194
+ local material that can contain a closing frame tag.
195
+
196
+ The rules that *are* worth knowing when authoring one: read only the leading `---` block (bodies contain lines that look
197
+ like frontmatter), block scalars are supported (`description: >-` with indented lines), and ids should stay
198
+ lowercase-with-hyphens if the skill is meant to travel to another agent - this project keeps and warns about
199
+ non-conforming ids (`clz_docx_to_mp` is in use here) rather than refusing them. `weflow-cli skill check` reports
200
+ unreadable frontmatter, cross-root collisions, non-conforming ids and disabled skills.
201
+
179
202
  ## What does not exist yet
180
203
 
181
204
  Named here so that the gaps are not mistaken for omissions:
182
205
 
183
- - **A plugin loader / adapter interface.** Roadmap, not implemented (see above).
206
+ - **A plugin loader / adapter interface.** Roadmap, not implemented (see above). Skills exist and are deliberately
207
+ *not* it: they are prompt-time material with no code path into this process.
184
208
  - **A public import surface.** No `exports`, `main` or `types`; the published package is a CLI, and `src/` is shipped
185
209
  without a stability promise.
186
210
  - **A versioned extension contract.** The versioned contracts that exist are data contracts (`weflow-message/v1`,
187
- `weflow-sync/v1`), not code contracts.
211
+ `weflow-sync/v1`, `weflow-scenes/v1`), not code contracts.
212
+ - **A skill installer.** There is no install/update/uninstall path and no per-agent deployment; the roots are read,
213
+ never written.
package/docs/MCP.md CHANGED
@@ -50,6 +50,52 @@ The generated server starts with Node.js and `tsx`:
50
50
 
51
51
  Copy this entry into your MCP client's configuration and ensure `cwd` points to the cloned WeFlow CLI directory. Restart the client after saving the configuration.
52
52
 
53
+ ## HTTP transport (opt-in)
54
+
55
+ The server speaks **stdio by default** and stays that way unless you ask for the other transport. Adding `--http`
56
+ (or `WEFLOW_MCP_HTTP=1`) serves the **same tools** over Streamable HTTP - the tool table is derived from one place
57
+ (`TOOL_DEFS`), so the two transports cannot drift apart:
58
+
59
+ ```
60
+ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # make a token
61
+ npx tsx mcp-server/index.ts --http --token=<that token>
62
+ # WeFlow MCP 已监听 http://127.0.0.1:8790/mcp
63
+ ```
64
+
65
+ Point an HTTP-capable client at `http://127.0.0.1:8790/mcp` with `Authorization: Bearer <token>`.
66
+
67
+ Four boundaries, enforced in `mcp-server/httpConfig.ts` and `mcp-server/http.ts` (pure decisions, offline tests in
68
+ `test/mcp-http.test.ts`, each one mutation-checked):
69
+
70
+ | Boundary | Why |
71
+ | --- | --- |
72
+ | **Off unless asked** | No `--http` means stdio, byte for byte what it did before. |
73
+ | **A token is required, and there is no default** | A silently generated token looks configured. Without one the server refuses to start (exit 1) and tells you how to make one. |
74
+ | **Loopback only** (`127.0.0.1` / `localhost` / `::1`) | `0.0.0.0` is rejected on purpose. For another machine use a tunnel (`ssh -L 8790:127.0.0.1:8790 <host>`) or your own reverse proxy in front of it - widening the bind is not something this command should do for you. |
75
+ | **DNS-rebinding protection on** | The SDK defaults it to `false`. The realistic attack on a loopback server is a web page you happen to visit reaching `127.0.0.1`, so the `Host` header must match and any `Origin` must be allowlisted. |
76
+
77
+ Port defaults to 8790 (`--http=9100`, `--port=`, or `WEFLOW_MCP_HTTP_PORT`); `--http=0` lets the OS pick one, which
78
+ is what the tests do. Requests are stateless - one server per request, no session state to leak between clients -
79
+ and **the token is never written to a log**.
80
+
81
+ An HTTP-capable client points at the URL instead of a command. The server is one you start (and stop) yourself, so
82
+ the client never manages the process:
83
+
84
+ ```json
85
+ {
86
+ "mcpServers": {
87
+ "weflow": {
88
+ "type": "http",
89
+ "url": "http://127.0.0.1:8790/mcp",
90
+ "headers": { "Authorization": "Bearer <the token you started it with>" }
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ Keep the token out of a file you commit: put it in an environment variable your client expands, or in a
97
+ client-local config that is not in version control.
98
+
53
99
  ## Available tools
54
100
 
55
101
  | Tool | Purpose | Local data required |
@@ -81,6 +127,11 @@ Copy this entry into your MCP client's configuration and ensure `cwd` points to
81
127
  | `wechat.export_chat` | Export one conversation to HTML, txt, json or excel. | Decrypted WeChat database, **writes under `output/exports/`** |
82
128
  | `wechat.export_messages` | Return a conversation's local messages as `weflow-message/v1` JSON. Read-only; returns no database path, key or configuration. | Decrypted WeChat database |
83
129
  | `wechat.get_reading_stats` | Which sources push most and what the daily processes. | `output/biz-daily/` |
130
+ | `wechat.list_skills` | List skill packages installed on this machine (`SKILL.md` bundles). | Skill directories (`skillDirs` config) |
131
+ | `wechat.read_skill` | Read one skill's `SKILL.md` body. Read-only; only files under the configured skill directories, never arbitrary paths. | Skill directories (`skillDirs` config) |
132
+ | `wechat.check_skills` | Report skill problems: unreadable frontmatter, cross-directory collisions, non-conforming ids, disabled skills. | Skill directories (`skillDirs` config) |
133
+ | `wechat.list_contacts` | List contact names from the local address book. **Names only** - no `wxid`, alias or other local identifiers. | Decrypted WeChat database |
134
+ | `wechat.lint_wiki` | Knowledge-base health check: broken links, orphan pages, empty pages, duplicate titles. Read-only; changes nothing. | Vault Wiki output |
84
135
 
85
136
  This table lists the tools shipped today; `weflow-cli mcp-config` prints the authoritative list for the
86
137
  installed version. **`wechat.look_at_image` is deliberately not on it**: that tool works by handing the image to
@@ -92,9 +143,9 @@ offered on it (the same rule `unavailableToolReason` applies elsewhere). The cha
92
143
 
93
144
  - The server inherits the permissions of the MCP client. Only add it to a client you trust.
94
145
  - Article and knowledge-base tools read files under the current project directory. Keep `cwd` scoped to your intended WeFlow CLI checkout.
95
- - Chat-data tools (`list_sessions`, `get_messages`, `search_favorites`, `get_sns`, `get_todos`) read your locally decrypted WeChat database. Only run this MCP server on machines where that is acceptable, and never expose the stdio server over a network.
146
+ - Chat-data tools (`list_sessions`, `get_messages`, `search_favorites`, `get_sns`, `get_todos`, `list_contacts`) read your locally decrypted WeChat database. Only run this MCP server on machines where that is acceptable, and never expose the stdio server over a network.
96
147
  - `read_favorite`, `fetch_article`, and `search_public` make network requests; `read_favorite` rejects private/loopback URLs.
97
- - The MCP surface is **derived from the assistant's tool table minus `save_memory`**, not a hand-written read-only list - so it is *not* read-only, and this line used to claim it was. What it actually contains, declared in `capabilities --json` under `safety.mcpSurface`: one tool that writes (`export_chat`, new directories under `output/exports/` only) and four that send user data to cloud models (`who_owes_reply` and `search_chats` send chat text to the decision model, `search_semantic` sends the query for embedding and the hits for reranking, `draft_reply` sends a conversation to two models - and **all four** require `confirm: true`, not just `draft_reply`; that is what `safety.mcpSurface.requiresConfirm` lists, and it is now checked against the source rather than restated). Publishing, sending messages, mutating todos, changing configuration and writing assistant memory stay out.
148
+ - The MCP surface is **derived from the assistant's tool table minus the excluded tools** (`save_memory`, `look_at_image`, `set_todo_status`), not a hand-written read-only list - so it is *not* read-only, and this line used to claim it was. What it actually contains, declared in `capabilities --json` under `safety.mcpSurface`: one tool that writes (`export_chat`, new directories under `output/exports/` only) and four that send user data to cloud models (`who_owes_reply` and `search_chats` send chat text to the decision model, `search_semantic` sends the query for embedding and the hits for reranking, `draft_reply` sends a conversation to two models - and **all four** require `confirm: true`, not just `draft_reply`; that is what `safety.mcpSurface.requiresConfirm` lists, and it is now checked against the source rather than restated). Publishing, sending messages, mutating todos, changing configuration and writing assistant memory stay out.
98
149
  - **Four tools on this surface send user data to cloud models, and each of them requires an explicit
99
150
  confirmation from the machine caller.** They are `who_owes_reply` (every conversation's text, one request
100
151
  each), `search_chats` (your question plus words extracted from the chats - not the message bodies),
@@ -110,5 +161,8 @@ offered on it (the same rule `unavailableToolReason` applies elsewhere). The cha
110
161
  | Symptom | Check |
111
162
  | --- | --- |
112
163
  | Server fails to start | Run `npm install` in the configured `cwd`, then run `npx tsx mcp-server/index.ts`. |
164
+ | HTTP mode refuses to start | The message says why: no token (`--token=` / `WEFLOW_MCP_TOKEN`), a token under 24 characters, or a bind address that is not loopback. |
165
+ | HTTP client gets 401 | The `Authorization: Bearer <token>` header is missing or wrong - it must be the token the server was started with. |
166
+ | HTTP client cannot connect from another machine | Working as intended: the bind is loopback. Tunnel to it (`ssh -L 8790:127.0.0.1:8790 <host>`) rather than opening the port. |
113
167
  | No articles found | Generate a daily collection first, then confirm `output/biz-daily/` exists under `cwd`. |
114
168
  | Client cannot find `npx` | Configure an absolute Node.js command path or install Node.js 22.13+. |