weflow-cli 1.8.2 → 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 +342 -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 +95 -60
  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
@@ -6,6 +6,8 @@ import { chatService } from './chatService.js';
6
6
  import { configService } from './configService.js';
7
7
  import { runPythonJson } from './pythonBridge.js';
8
8
  import { exportService } from './exportService.js';
9
+ import { frameLocalData } from './assistantMemory.js';
10
+ import { disabledIds, readSkillBody, scanSkills, skillRoots, skillState } from './assistantSkills.js';
9
11
  import { privacyGate } from './assistantPrivacy.js';
10
12
  import { existsSync, readFileSync, readdirSync } from 'fs';
11
13
  import { basename, join } from 'path';
@@ -13,17 +15,45 @@ import { basename, join } from 'path';
13
15
  import { resolvePackageRoot } from '../utils/packageRoot.js';
14
16
  import { clipWithMarker } from '../utils/text.js';
15
17
  import { resolveSince, resolveUntil } from '../utils/dateRange.js';
18
+ import { isAllowedWeChatArticleUrl, safeDate } from '../utils/mcpSecurity.js';
16
19
  const PKG_ROOT = resolvePackageRoot(import.meta.url);
17
- const BIZ_DAILY_DIR = join(PKG_ROOT, 'output', 'biz-daily');
20
+ /**
21
+ * 日报目录。**可以用 `WEFLOW_ASSISTANT_BIZ_DAILY_DIR` 指到别处**。
22
+ *
23
+ * 为什么加这个注入点:`get_daily_report` 一直是本文件里**唯一从没被执行过的工具分支**,
24
+ * 原因写在测试文件头上——它读的是模块级常量路径,没有注入点,断言只能写成"没数据/有数据皆可",
25
+ * 那种测试看着像覆盖、其实什么也没钉住。同 `WEFLOW_ASSISTANT_EXPORT_ROOT` 的先例(见 `export_chat`)。
26
+ */
27
+ // 做成函数而不是模块常量:环境变量在**每次调用**时读,测试才能在同一进程里换夹具
28
+ // (同 `export_chat` 的 `WEFLOW_ASSISTANT_EXPORT_ROOT` 做法)。常量只在 import 时求值,
29
+ // 那会让「目录不存在」这类分支永远测不到——写出来的断言就只能是摆设。
30
+ const bizDailyDir = () => process.env.WEFLOW_ASSISTANT_BIZ_DAILY_DIR || join(PKG_ROOT, 'output', 'biz-daily');
31
+ /** 学习回顾目录。同样可注入:否则 `get_review` 的断言只能依赖这台机器上有没有跑过 `review`。 */
32
+ const reviewsDir = () => process.env.WEFLOW_ASSISTANT_REVIEWS_DIR || join(PKG_ROOT, 'output', 'reviews', 'Daily');
18
33
  /** 单条聊天消息进上下文的字数上限(见 get_messages:引用消息要放得下正文+被引原文) */
19
34
  const MSG_BODY_CHARS = 160;
20
35
  // 知识页住在哪几个目录。**两个是有意的**:文章知识库与聊天知识库分开(用户 2026-09-27
21
36
  // 要求),所以读的一方要把**两个都读**——只读一个,分出去的那一半就静默地搜不到。
22
37
  // 与 Python 的 `_utils.CONCEPT_DIRS` 是同一份清单,有测试钉住两者一致(跨语言没法共用常量)。
23
- const VAULT_WIKI_DIRS = [
24
- join(PKG_ROOT, 'output', 'wechat-vault', 'Wiki', 'Concepts'),
25
- join(PKG_ROOT, 'output', 'wechat-vault', 'Chat', 'Concepts'),
26
- ];
38
+ /**
39
+ * 总览索引在哪:**在概念目录的上一层**——`Wiki/Concepts/` 的兄弟是 `Wiki/00-Overview.md`。
40
+ *
41
+ * 抽成函数是为了能直接测:这段路径写错过一次(写成 `join(dir, '00-Overview.md')`,于是一直读不到),
42
+ * 而当时那条测试只能断言「有内容或没有」这种形状——真实机器上有索引、CI 上没有,两条路都绿。
43
+ */
44
+ export function wikiIndexPath(conceptDir) {
45
+ return join(conceptDir, '..', '00-Overview.md');
46
+ }
47
+ /**
48
+ * 知识库那两条线的 `Concepts` 目录。**同样可注入**(`WEFLOW_ASSISTANT_VAULT_DIR`),
49
+ * 与上面 `bizDailyDir` / `reviewsDir` 是同一条理由:评测跑在临时家目录里,包内那个
50
+ * `output/wechat-vault/` 必然为空,`get_concepts` 就只能测到「还没编译过」那一支——
51
+ * 要给它写用例,就得能把这个根换掉。(做成函数而不是常量:环境变量要在**每次调用**时读。)
52
+ */
53
+ const vaultDirs = () => {
54
+ const root = process.env.WEFLOW_ASSISTANT_VAULT_DIR || join(PKG_ROOT, 'output', 'wechat-vault');
55
+ return [join(root, 'Wiki', 'Concepts'), join(root, 'Chat', 'Concepts')];
56
+ };
27
57
  /**
28
58
  * 概念名 → 文件名。**与 `compile_wiki` 是同一套规则**(非法字符换下划线、截到 60 字)。
29
59
  *
@@ -71,7 +101,7 @@ function relatedNames(pagePath) {
71
101
  * 读不完的图等于没给。截断了要**说出来**(`truncated`),不能默默少给;
72
102
  * - **没建页的邻居照旧报数**(`missing`),不静默吞掉。
73
103
  */
74
- export function conceptSubgraph(pageName, depth = 2, maxNodes = 30, wikiDirs = VAULT_WIKI_DIRS) {
104
+ export function conceptSubgraph(pageName, depth = 2, maxNodes = 30, wikiDirs = vaultDirs()) {
75
105
  // 同 `conceptNeighbors`:传字符串会静默地按字符遍历,所以这里响,不猜。
76
106
  if (typeof wikiDirs === 'string') {
77
107
  throw new TypeError('conceptSubgraph 的 wikiDirs 要的是目录数组,不是单个字符串');
@@ -142,7 +172,7 @@ export function conceptBrief(text) {
142
172
  *
143
173
  * 有页的邻居连定义一起给;没有页的只报个数(`missing`),免得模型去追一个取不到的名字。
144
174
  */
145
- export function conceptNeighbors(pageName, wikiDirs = VAULT_WIKI_DIRS) {
175
+ export function conceptNeighbors(pageName, wikiDirs = vaultDirs()) {
146
176
  // **传字符串会静默地按字符遍历**(`for (const dir of 'D:\vault')` 挨个字符看过去),
147
177
  // 于是 `join('D', '某概念.md')` 不存在,函数安静地回一句「没有邻居」。2026-09-27 把参数
148
178
  // 从单个目录改成目录数组时,测试里正好踩到这个——类型拦得住 `src/`(`tsc` 覆盖它),
@@ -393,16 +423,90 @@ export function extractText(html) {
393
423
  return stripTags(seg);
394
424
  }
395
425
  /** 确保数据库已连接 (connect 幂等, 已连接时直接返回) */
426
+ /**
427
+ * 抓页面 HTML:微信内置浏览器 UA + Referer 绕 WAF,最多试两次。`read_favorite` 与 `fetch_article` 共用。
428
+ *
429
+ * `maxRedirects > 0` 时**自己跟跳转并逐跳复检白名单**:默认的 `redirect: 'follow'` 只看入口,
430
+ * 一次 302 就能把白名单绕过去(拿到一个被允许的入口,跳到内网地址)。`fetch_article`
431
+ * 收的是用户/模型给的任意 URL,所以要这道闸;`read_favorite` 收的是用户**自己收藏**的链接,
432
+ * 保持原行为不动(那里用 denylist,且收藏里本来就有各种站)。
433
+ */
434
+ async function fetchArticleHtml(url, opts = {}) {
435
+ const maxRedirects = opts.maxRedirects ?? 0;
436
+ let current = url;
437
+ let lastStatus = 0;
438
+ for (let hop = 0; hop <= maxRedirects; hop++) {
439
+ if (maxRedirects > 0 && opts.allowHop && !opts.allowHop(current)) {
440
+ return { status: lastStatus, html: '' }; // 某一跳出了白名单:宁可空手而归
441
+ }
442
+ let html = '';
443
+ let status = 0;
444
+ let followed = false;
445
+ for (let attempt = 0; attempt < 2; attempt++) {
446
+ try {
447
+ const res = await fetch(current, {
448
+ headers: {
449
+ 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.38(0x18002633) NetType/WIFI Language/zh_CN',
450
+ 'Referer': 'https://mp.weixin.qq.com/',
451
+ 'Accept': 'text/html,application/xhtml+xml',
452
+ 'Accept-Language': 'zh-CN,zh;q=0.9',
453
+ },
454
+ ...(maxRedirects > 0 ? { redirect: 'manual' } : {}),
455
+ signal: AbortSignal.timeout(20_000),
456
+ });
457
+ status = res.status;
458
+ if (maxRedirects > 0 && status >= 300 && status < 400) {
459
+ const location = res.headers.get('location');
460
+ if (!location)
461
+ return { status, html: '' };
462
+ current = new URL(location, current).toString();
463
+ followed = true;
464
+ break; // 换一跳,重来
465
+ }
466
+ html = (await res.text()).slice(0, 500_000);
467
+ // 正文验证: 必须含 js_content (否则是验证页, 重试)
468
+ if (res.ok && html.includes('js_content'))
469
+ break;
470
+ }
471
+ catch { /* 超时/网络错误 → 重试 */ }
472
+ }
473
+ if (html)
474
+ return { status, html };
475
+ lastStatus = status;
476
+ // 没跟跳转就走完了两次尝试:就是抓不到,别再拿同一个地址重试下去
477
+ if (!followed)
478
+ return { status, html: '' };
479
+ }
480
+ return { status: lastStatus, html: '' };
481
+ }
482
+ /** 读响应体但设上限:第三方搜索页可能很大,不让它把内存吃掉(超了就截断,不报错) */
483
+ async function readTextLimited(response, maxBytes) {
484
+ const buf = await response.arrayBuffer();
485
+ return Buffer.from(buf).subarray(0, maxBytes).toString('utf8');
486
+ }
487
+ /**
488
+ * 从抓到的 HTML 里取正文:优先正常页面的 `js_content`,取不到再走 WAF 挑战页的 `content_noencode`。
489
+ * 返回的文本已按 `maxChars` 截断。
490
+ */
491
+ function articleTextFromHtml(html, maxChars) {
492
+ let text = html.includes('js_content') ? extractText(html) : '';
493
+ if (!text || (text.match(/[一-鿿]/g) || []).length < 50) {
494
+ const decoded = extractFromChallengePage(html);
495
+ if (decoded)
496
+ text = stripTags(decoded);
497
+ }
498
+ return text.slice(0, maxChars);
499
+ }
396
500
  async function ensureDb() {
397
501
  await chatService.connect();
398
502
  }
399
503
  /** 最近一份**有内容**的日报是哪天,以及那是几天前。算不出来就回空串——不猜。 */
400
504
  function lastDailyNote() {
401
505
  try {
402
- const days = readdirSync(BIZ_DAILY_DIR)
506
+ const days = readdirSync(bizDailyDir())
403
507
  .filter(name => /^\d{4}-\d{2}-\d{2}$/.test(name)).sort().reverse().slice(0, 30);
404
508
  for (const day of days) {
405
- const file = join(BIZ_DAILY_DIR, day, '.articles.json');
509
+ const file = join(bizDailyDir(), day, '.articles.json');
406
510
  if (!existsSync(file))
407
511
  continue;
408
512
  const payload = JSON.parse(readFileSync(file, 'utf8'));
@@ -417,6 +521,14 @@ function lastDailyNote() {
417
521
  }
418
522
  class ToolInputError extends Error {
419
523
  }
524
+ /**
525
+ * 把待办 id 露给模型。**不露出来它就指不到具体哪一条**——`set_todo_status` 只能靠 id
526
+ * 或唯一的任务文字定位,而任务文字常常是长句、几条之间又很像。
527
+ * 给完整 id(CLI 那边支持 id 前缀,但前缀要唯一才安全,这里不赌)。
528
+ */
529
+ function idTag(t) {
530
+ return t?.id ? ` [id: ${t.id}]` : '';
531
+ }
420
532
  export function boundedToolInteger(value, fallback, maximum, field = 'limit') {
421
533
  if (value === undefined || value === null || value === '')
422
534
  return fallback;
@@ -571,13 +683,19 @@ export const TOOL_DEFS = [
571
683
  type: 'function',
572
684
  function: {
573
685
  name: 'get_daily_report',
574
- description: '获取公众号日报: 某天推送了哪些文章(标题/来源/分类/AI摘要)。适合「今天/某天公众号推了什么」「最近有哪些AI文章」类问题。',
686
+ description: '获取公众号日报: 推送了哪些文章(标题/来源/分类/AI摘要)。'
687
+ + '适合「今天/某天公众号推了什么」「最近有哪些AI文章」「搜一下关于X的文章」类问题。'
688
+ + '**给 date 就是那一天的;不给 date 只给关键词/分类就是跨日期搜**(默认最近 7 天,用 days 调)。'
689
+ + '要当天那份**人读的日报正文**(不是条目清单)加 full。',
575
690
  parameters: {
576
691
  type: 'object',
577
692
  properties: {
578
- date: { type: 'string', description: '日期 YYYY-MM-DD, 默认最新一期' },
693
+ date: { type: 'string', description: '日期 YYYY-MM-DD;省略则最新一期(或配合关键词跨日期搜)' },
579
694
  topic: { type: 'string', description: '分类过滤: AI | 学术 | 新闻 | 文学 | 投资' },
580
- keyword: { type: 'string', description: '标题/摘要关键词过滤' },
695
+ keyword: { type: 'string', description: '标题/摘要/来源关键词。不给 date 时跨日期搜' },
696
+ days: { type: 'number', description: '跨日期搜的时间窗(天), 默认 7, 上限 60' },
697
+ full: { type: 'boolean', description: '返回当天日报的正文(人读的那份),而不是条目清单' },
698
+ max_chars: { type: 'number', description: 'full 时正文上限, 默认 3000, 上限 6000' },
581
699
  limit: { type: 'number', description: '返回条数, 默认15' },
582
700
  },
583
701
  },
@@ -601,12 +719,16 @@ export const TOOL_DEFS = [
601
719
  type: 'function',
602
720
  function: {
603
721
  name: 'get_weread',
604
- description: '查看用户微信读书数据: 书架(shelf)、笔记(notebooks)、搜索书(search)。适合「我在读什么书」「某本书的笔记」类问题。',
722
+ description: '查看用户微信读书数据。mode: shelf(书架) | notebooks(有笔记的书) | search(按书名搜) '
723
+ + '| stats(近期阅读时长) | book(某本书详情+我的进度) | review(某本书的热门书评) '
724
+ + '| discover(推荐/相似书) | profile(累计阅读画像)。适合「我在读什么书」「某本书讲什么」「读了多少」类问题。'
725
+ + 'book / review 需要先用 search 拿到 book_id。',
605
726
  parameters: {
606
727
  type: 'object',
607
728
  properties: {
608
- mode: { type: 'string', description: 'shelf | notebooks | search, 默认 shelf' },
729
+ mode: { type: 'string', description: 'shelf | notebooks | search | stats | book | review | discover | profile, 默认 shelf' },
609
730
  keyword: { type: 'string', description: 'search 模式的书名关键词' },
731
+ book_id: { type: 'string', description: 'book / review 模式的书籍 id(search 或 shelf 的结果里有)' },
610
732
  },
611
733
  },
612
734
  },
@@ -615,11 +737,13 @@ export const TOOL_DEFS = [
615
737
  type: 'function',
616
738
  function: {
617
739
  name: 'get_todos',
618
- description: '查看用户从聊天记录提取的待办任务清单(含优先级和截止时间)。适合「我最近有什么待办」「有什么紧急的事」类问题。',
740
+ description: '查看用户从聊天记录提取的待办任务清单(含优先级和截止时间)。适合「我最近有什么待办」「有什么紧急的事」类问题。'
741
+ + '每条后面方括号里的 id 用来改某一条的状态(set_todo_status 需要它)。按紧急度分组看用 group_by。',
619
742
  parameters: {
620
743
  type: 'object',
621
744
  properties: {
622
745
  status: { type: 'string', description: 'pending | done, 默认 pending' },
746
+ group_by: { type: 'string', description: '传 urgency 就按紧急度分组列出' },
623
747
  },
624
748
  },
625
749
  },
@@ -752,7 +876,8 @@ export const TOOL_DEFS = [
752
876
  type: 'function',
753
877
  function: {
754
878
  name: 'get_stats',
755
- description: '获取用户本地微信数据统计(会话数/收藏总数等)。',
879
+ description: '本地数据总览:会话数、收藏总数、日报文章数与覆盖天数、主题分布、概念页数。'
880
+ + '适合「我一共有多少数据」这类总览问题;单看公众号推送用 get_reading_stats。',
756
881
  parameters: { type: 'object', properties: {} },
757
882
  },
758
883
  },
@@ -805,6 +930,172 @@ export const TOOL_DEFS = [
805
930
  },
806
931
  },
807
932
  },
933
+ {
934
+ type: 'function',
935
+ function: {
936
+ name: 'list_skills',
937
+ description: '列出本机装好的技能包(名字+一句话说明+版本)。技能是一份说明文档,'
938
+ + '用 read_skill 读它的正文再照着做;不要凭名字猜它怎么用。',
939
+ parameters: {
940
+ type: 'object',
941
+ properties: {
942
+ query: { type: 'string', description: '可选:按关键词过滤(匹配 id、名称、说明和触发词)' },
943
+ },
944
+ },
945
+ },
946
+ },
947
+ {
948
+ type: 'function',
949
+ function: {
950
+ name: 'read_skill',
951
+ description: '读取某个技能包的完整说明(SKILL.md 正文)。要用某个技能前先读它。'
952
+ + 'id 用 list_skills 查到的那个。',
953
+ parameters: {
954
+ type: 'object',
955
+ properties: {
956
+ id: { type: 'string', description: '技能 id,如 wechat-article-extract' },
957
+ },
958
+ required: ['id'],
959
+ },
960
+ },
961
+ },
962
+ {
963
+ type: 'function',
964
+ function: {
965
+ name: 'list_contacts',
966
+ description: '列出通讯录里的联系人(显示名、备注、昵称)。**不含 wxid 等本机标识**,'
967
+ + '只有能叫得出名字的人。'
968
+ + '(要看最近跟谁聊过用 list_sessions;要按名字查某个会话的聊天用 get_messages)',
969
+ parameters: {
970
+ type: 'object',
971
+ properties: {
972
+ keyword: { type: 'string', description: '按名字过滤(可省略)' },
973
+ limit: { type: 'number', description: '最多几条, 默认 50, 上限 200' },
974
+ },
975
+ },
976
+ },
977
+ },
978
+ {
979
+ type: 'function',
980
+ function: {
981
+ name: 'get_review',
982
+ description: '读某一天的学习回顾(对当天日报文章做的二次整理:要点、联系、值得回看的)。'
983
+ + '**与 get_daily_report 不是一回事**:那个是当天日报的原始条目清单,这个是复盘。',
984
+ parameters: {
985
+ type: 'object',
986
+ properties: {
987
+ date: { type: 'string', description: '日期 YYYY-MM-DD,省略则取最近一份' },
988
+ },
989
+ },
990
+ },
991
+ },
992
+ {
993
+ type: 'function',
994
+ function: {
995
+ name: 'get_concepts',
996
+ description: '读知识库的两份总览索引(文章线、聊天线各一份)。这是**目录**,'
997
+ + '用来回答"我的知识库里都有什么";要按名字找**某一页**的内容用 search_knowledge。',
998
+ parameters: {
999
+ type: 'object',
1000
+ properties: {
1001
+ limit: { type: 'number', description: '每份索引最多返回多少行, 默认 40' },
1002
+ },
1003
+ },
1004
+ },
1005
+ },
1006
+ {
1007
+ type: 'function',
1008
+ function: {
1009
+ name: 'fetch_article',
1010
+ description: '抓取一篇公众号文章的正文(需要完整链接)。只接受 https 的 mp.weixin.qq.com 链接。'
1011
+ + '与 read_favorite 的区别:那个从你自己的收藏里找,这个由用户给出链接。',
1012
+ parameters: {
1013
+ type: 'object',
1014
+ properties: {
1015
+ url: { type: 'string', description: '公众号文章完整链接' },
1016
+ max_chars: { type: 'number', description: '正文上限, 默认 3000, 上限 6000' },
1017
+ },
1018
+ required: ['url'],
1019
+ },
1020
+ },
1021
+ },
1022
+ {
1023
+ type: 'function',
1024
+ function: {
1025
+ name: 'search_public',
1026
+ description: '在公开的公众号文章里按关键词搜标题与摘要(走第三方搜索页)。'
1027
+ + '只搜公开文章,搜不到你自己的收藏、聊天记录或知识库。',
1028
+ parameters: {
1029
+ type: 'object',
1030
+ properties: {
1031
+ keyword: { type: 'string', description: '搜索词' },
1032
+ limit: { type: 'number', description: '最多几条, 默认 8, 上限 20' },
1033
+ },
1034
+ required: ['keyword'],
1035
+ },
1036
+ },
1037
+ },
1038
+ {
1039
+ type: 'function',
1040
+ function: {
1041
+ name: 'format_article',
1042
+ description: '把 Markdown 转成能直接粘进公众号编辑器的 HTML 片段(纯本地转换,不联网、不写文件)。'
1043
+ + '主题名用 list_themes 查。',
1044
+ parameters: {
1045
+ type: 'object',
1046
+ properties: {
1047
+ // 参数名保持 `content` —— 这个名字在 MCP 那条路上已经被外部客户端用了,
1048
+ // 改名的代价是别人的调用直接失效(工具名不变、参数变了,最难查的那种坏法)
1049
+ content: { type: 'string', description: '要排版的 Markdown 正文' },
1050
+ theme: { type: 'string', description: '主题名(省略用 default)' },
1051
+ },
1052
+ required: ['content'],
1053
+ },
1054
+ },
1055
+ },
1056
+ {
1057
+ type: 'function',
1058
+ function: {
1059
+ name: 'list_themes',
1060
+ description: '列出可用的公众号排版主题(配合 format_article 用)。',
1061
+ parameters: { type: 'object', properties: {} },
1062
+ },
1063
+ },
1064
+ {
1065
+ type: 'function',
1066
+ function: {
1067
+ name: 'lint_wiki',
1068
+ description: '知识库体检:断链、孤儿页、空页、同名页。**只读**,不改任何页。',
1069
+ parameters: { type: 'object', properties: {} },
1070
+ },
1071
+ },
1072
+ {
1073
+ type: 'function',
1074
+ function: {
1075
+ name: 'check_skills',
1076
+ description: '技能体检:报出读不出来的 SKILL.md、跨目录同名、id 不合规范、被禁用的技能。'
1077
+ + '与 list_skills 的区别:那个是清单,这个是**问题清单**。',
1078
+ parameters: { type: 'object', properties: {} },
1079
+ },
1080
+ },
1081
+ {
1082
+ type: 'function',
1083
+ function: {
1084
+ name: 'set_todo_status',
1085
+ description: '把一条待办标记为已完成 / 取消完成。**不能删除待办、不能新增**。'
1086
+ + '要精确指到那一条:优先用 get_todos 给出的 id;也可以给一段任务文字,'
1087
+ + '但**匹配到零条或多条都会被拒绝**(猜错条目比拒绝更糟)。',
1088
+ parameters: {
1089
+ type: 'object',
1090
+ properties: {
1091
+ id: { type: 'string', description: '待办 id(get_todos 输出里的那个)' },
1092
+ task: { type: 'string', description: '或用任务文字精确匹配(必须唯一)' },
1093
+ status: { type: 'string', description: 'done = 已完成, pending = 取消完成' },
1094
+ },
1095
+ required: ['status'],
1096
+ },
1097
+ },
1098
+ },
808
1099
  ];
809
1100
  /**
810
1101
  * MCP 那条路看得见的工具表。
@@ -825,6 +1116,10 @@ export const TOOL_DEFS = [
825
1116
  export const MCP_EXCLUDED = {
826
1117
  save_memory: 'MCP 没有微信用户身份,挂的记忆桶是单独一条,让它写没有意义',
827
1118
  look_at_image: 'MCP 只把工具返回的文本交给客户端,它挂进侧信道的图没人接,回话会变成一句假话',
1119
+ // 这条是**故意留在外面的**(docs/MCP.md 的天花板:「mutating todos ... stay out」):
1120
+ // 助手在微信/面板里可以替你翻转一条待办状态,但外部 MCP 客户端不行——
1121
+ // 那条路上没有"你",只有调用方;一条机器指令顺手改掉你的待办,不该是默认能力。
1122
+ set_todo_status: '待办是本地状态,MCP 那条路上没有用户可确认;改状态请在微信/面板里说,或用 CLI 的两段式 todos done',
828
1123
  };
829
1124
  export const MCP_TOOL_DEFS = TOOL_DEFS.filter(tool => !(tool.function.name in MCP_EXCLUDED));
830
1125
  /** 脚本类工具的失败回话:桥接层已经分好类(超时/退出码/没有 JSON/脚本自己报错),
@@ -1116,38 +1411,12 @@ export async function executeTool(name, args, ctx) {
1116
1411
  }
1117
1412
  if (!isSafeUrl(art.link))
1118
1413
  return `(链接不安全, 拒绝抓取: ${art.link.slice(0, 60)})`;
1119
- // 微信内置浏览器 UA + Referer 绕 WAF (与 biz_daily.py 同策略)
1120
- let html = '';
1121
- let status = 0;
1122
- for (let attempt = 0; attempt < 2; attempt++) {
1123
- try {
1124
- const res = await fetch(art.link, {
1125
- headers: {
1126
- 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.38(0x18002633) NetType/WIFI Language/zh_CN',
1127
- 'Referer': 'https://mp.weixin.qq.com/',
1128
- 'Accept': 'text/html,application/xhtml+xml',
1129
- 'Accept-Language': 'zh-CN,zh;q=0.9',
1130
- },
1131
- signal: AbortSignal.timeout(20_000),
1132
- });
1133
- status = res.status;
1134
- html = (await res.text()).slice(0, 500_000);
1135
- // 正文验证: 必须含 js_content (否则是验证页, 重试)
1136
- if (res.ok && html.includes('js_content'))
1137
- break;
1138
- }
1139
- catch { /* 超时/网络错误 → 重试 */ }
1140
- }
1414
+ // 微信内置浏览器 UA + Referer 绕 WAF(与 biz_daily.py 同策略);
1415
+ // 这段与 fetch_article 是同一份实现——两处各写一份必然会漂
1416
+ const { status, html } = await fetchArticleHtml(art.link);
1141
1417
  if (!html)
1142
1418
  return `(抓取失败 HTTP ${status})`;
1143
- // 路径1: 正常页面 js_content; 路径2: WAF 挑战页 content_noencode 兜底
1144
- let text = html.includes('js_content') ? extractText(html) : '';
1145
- if (!text || (text.match(/[\u4e00-\u9fff]/g) || []).length < 50) {
1146
- const decoded = extractFromChallengePage(html);
1147
- if (decoded)
1148
- text = stripTags(decoded);
1149
- }
1150
- text = text.slice(0, maxChars);
1419
+ let text = articleTextFromHtml(html, maxChars);
1151
1420
  if (html.includes('已被发布者删除'))
1152
1421
  return `(「${art.title}」已被发布者删除)`;
1153
1422
  if (!text.trim() || (text.match(/[\u4e00-\u9fff]/g) || []).length < 20) {
@@ -1156,17 +1425,76 @@ export async function executeTool(name, args, ctx) {
1156
1425
  return `「${art.title || '无标题'}」(${art.source_name || '未知来源'}) 正文:\n${text}`;
1157
1426
  }
1158
1427
  case 'get_daily_report': {
1159
- const dates = existsSync(BIZ_DAILY_DIR)
1160
- ? readdirSync(BIZ_DAILY_DIR).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse()
1428
+ const dates = existsSync(bizDailyDir())
1429
+ ? readdirSync(bizDailyDir()).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse()
1161
1430
  : [];
1162
1431
  if (!dates.length)
1163
1432
  return '(还没有日报数据, 先运行 biz_daily 生成)';
1164
- const date = String(args.date || dates[0]);
1165
- const articlesFile = join(BIZ_DAILY_DIR, date, '.articles.json');
1166
- if (!existsSync(articlesFile))
1433
+ const readArticles = (date) => {
1434
+ const file = join(bizDailyDir(), date, '.articles.json');
1435
+ if (!existsSync(file))
1436
+ return null;
1437
+ try {
1438
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
1439
+ return Array.isArray(raw) ? raw : (raw.articles || Object.values(raw));
1440
+ }
1441
+ catch {
1442
+ return null;
1443
+ }
1444
+ };
1445
+ const fmt = (a) => {
1446
+ const summary = (a.summary || '').replace(/\s+/g, ' ').slice(0, 80);
1447
+ const where = a.date ? `${a.date} ` : '';
1448
+ return `· ${where}[${a.topic}] ${a.title} (${a.source})${summary ? `\n ${summary}` : ''}`;
1449
+ };
1450
+ const limit = boundedToolInteger(args.limit, 15, 30);
1451
+ // 不给 date 但给了关键词 → 跨日期搜(MCP 那边原本叫 search_articles,两个工具搜的
1452
+ // 是同一批文章、只是范围不同,模型无从判断该用哪个,所以合并成一个)。
1453
+ const wantDate = args.date ? safeDate(args.date) : null;
1454
+ if (args.date && !wantDate)
1455
+ return `(日期格式不对: ${args.date},要 YYYY-MM-DD)`;
1456
+ if (!wantDate && (args.keyword || args.topic)) {
1457
+ const days = boundedToolInteger(args.days, 7, 60);
1458
+ const window = dates.slice(0, days);
1459
+ const hits = [];
1460
+ let scanned = 0;
1461
+ for (const d of window) {
1462
+ const arts = readArticles(d);
1463
+ if (!arts)
1464
+ continue;
1465
+ scanned += arts.length;
1466
+ for (const a of arts) {
1467
+ if (args.topic && a.topic !== args.topic)
1468
+ continue;
1469
+ if (args.keyword) {
1470
+ const kw = String(args.keyword).toLowerCase();
1471
+ const hay = `${a.title || ''} ${a.summary || ''} ${a.source || ''}`.toLowerCase();
1472
+ if (!hay.includes(kw))
1473
+ continue;
1474
+ }
1475
+ hits.push({ ...a, date: d });
1476
+ }
1477
+ }
1478
+ if (!hits.length) {
1479
+ return `(最近 ${window.length} 天的日报里(共 ${scanned} 篇)没有匹配`
1480
+ + `${args.keyword ? `「${args.keyword}」` : `主题 ${args.topic}`} 的文章)`;
1481
+ }
1482
+ return `最近 ${window.length} 天里匹配 ${hits.length} 篇(共扫 ${scanned} 篇):\n`
1483
+ + hits.slice(0, limit).map(fmt).join('\n');
1484
+ }
1485
+ const date = wantDate || dates[0];
1486
+ const all = readArticles(date);
1487
+ if (!all)
1167
1488
  return `(${date} 没有日报。可用日期: ${dates.slice(0, 5).join(', ')})`;
1168
- const raw = JSON.parse(readFileSync(articlesFile, 'utf8'));
1169
- const all = Array.isArray(raw) ? raw : (raw.articles || Object.values(raw));
1489
+ // `full`:给的是当天那份人读的日报正文(README.md),不是条目清单。
1490
+ // 不加这个参数,模型想"把那天的日报念一段"就只能拿到标题列表。
1491
+ if (args.full) {
1492
+ const readme = join(bizDailyDir(), date, 'README.md');
1493
+ if (!existsSync(readme))
1494
+ return `(${date} 没有日报正文文件,只有条目)`;
1495
+ const maxChars = boundedToolInteger(args.max_chars, 3000, 6000);
1496
+ return `${date} 日报正文(${all.length} 篇):\n${readFileSync(readme, 'utf8').slice(0, maxChars)}`;
1497
+ }
1170
1498
  let list = all;
1171
1499
  if (args.topic)
1172
1500
  list = list.filter(a => a.topic === args.topic);
@@ -1174,7 +1502,6 @@ export async function executeTool(name, args, ctx) {
1174
1502
  const kw = String(args.keyword).toLowerCase();
1175
1503
  list = list.filter(a => (a.title || '').toLowerCase().includes(kw) || (a.summary || '').toLowerCase().includes(kw));
1176
1504
  }
1177
- const limit = boundedToolInteger(args.limit, 15, 30);
1178
1505
  if (!list.length)
1179
1506
  return `(${date} 日报共 ${all.length} 篇, 过滤后无匹配)`;
1180
1507
  const byTopic = {};
@@ -1182,10 +1509,7 @@ export async function executeTool(name, args, ctx) {
1182
1509
  byTopic[a.topic] = (byTopic[a.topic] || 0) + 1;
1183
1510
  const topicStat = Object.entries(byTopic).map(([t, n]) => `${t}${n}篇`).join(' ');
1184
1511
  return `${date} 日报共 ${all.length} 篇 (${topicStat}), 匹配 ${list.length} 篇:\n` +
1185
- list.slice(0, limit).map(a => {
1186
- const summary = (a.summary || '').replace(/\s+/g, ' ').slice(0, 80);
1187
- return `· [${a.topic}] ${a.title} (${a.source})${summary ? `\n ${summary}` : ''}`;
1188
- }).join('\n');
1512
+ list.slice(0, limit).map(fmt).join('\n');
1189
1513
  }
1190
1514
  case 'get_sns': {
1191
1515
  await ensureDb();
@@ -1240,6 +1564,60 @@ export async function executeTool(name, args, ctx) {
1240
1564
  return `有笔记的书 ${r.data.books.length} 本:\n` +
1241
1565
  r.data.books.slice(0, 15).map((b) => `· ${titleOf(b)} (${authorOf(b)}) — ${b.noteCount || 0} 条笔记`).join('\n');
1242
1566
  }
1567
+ if (mode === 'profile') {
1568
+ const r = await wereadService.profile();
1569
+ if (!r.ok || !r.data)
1570
+ return `(读不到阅读画像: ${r.error || ''})`;
1571
+ const p = r.data;
1572
+ return `阅读画像: ${p.name || '(未给昵称)'}\n`
1573
+ + `累计阅读 ${Math.round((p.totalReadTime || 0) / 60)} 分钟、${p.totalReadDays || 0} 天\n`
1574
+ + `书架 ${p.totalBooks || 0} 本,读完 ${p.totalFinished || 0} 本`;
1575
+ }
1576
+ if (mode === 'stats') {
1577
+ const r = await wereadService.readData('monthly');
1578
+ if (!r.ok || !r.data)
1579
+ return `(读不到阅读统计: ${r.error || ''})`;
1580
+ const d = r.data;
1581
+ const top = (d.readLongest || []).slice(0, 5).map((x) => `· ${x?.book?.title || x?.albumInfo?.title || '(未知)'} ${Math.round((x?.readTime || 0) / 60)} 分钟`);
1582
+ return `近期阅读 ${Math.round((d.totalReadTime || 0) / 60)} 分钟、读了 ${d.readDays || 0} 天`
1583
+ + `,日均 ${Math.round((d.dayAverageReadTime || 0) / 60)} 分钟`
1584
+ + (top.length ? `\n读得最久的:\n${top.join('\n')}` : '');
1585
+ }
1586
+ if (mode === 'book') {
1587
+ const bookId = String(args.book_id || '').trim();
1588
+ if (!bookId)
1589
+ return '(book 模式需要 book_id —— 先用 search 模式按书名找到它)';
1590
+ const info = await wereadService.bookInfo(bookId);
1591
+ if (!info.ok || !info.data)
1592
+ return `(读不到这本书: ${info.error || ''})`;
1593
+ const b = info.data;
1594
+ const prog = await wereadService.getProgress(bookId);
1595
+ const pct = prog.ok && prog.data
1596
+ ? `\n我的进度: ${prog.data.progress}%${prog.data.chapterTitle ? `(${prog.data.chapterTitle})` : ''}`
1597
+ : '';
1598
+ return `《${b.title}》${b.author || ''}\n评分 ${b.rating ?? '未知'}(${b.ratingCount || 0} 人)`
1599
+ + `| ${b.category || '未分类'} | ${b.wordCount || '未知'}字${pct}`;
1600
+ }
1601
+ if (mode === 'review') {
1602
+ const bookId = String(args.book_id || '').trim();
1603
+ if (!bookId)
1604
+ return '(review 模式需要 book_id —— 先用 search 模式按书名找到它)';
1605
+ const r = await wereadService.reviews(bookId, 5);
1606
+ if (!r.ok || !r.data?.reviews?.length)
1607
+ return `(这本书还没有热门书评: ${r.error || ''})`;
1608
+ return `热门书评 ${r.data.reviews.length} 条:\n`
1609
+ + r.data.reviews.map((v) => `· ${String(v.content || '').replace(/\s+/g, ' ').slice(0, 120)}`
1610
+ + `(${v.user?.name || '匿名'},${v.rating ? `评 ${v.rating} 分` : '未评分'},${v.likeCount || 0} 赞)`).join('\n');
1611
+ }
1612
+ if (mode === 'discover') {
1613
+ const bookId = String(args.book_id || '').trim();
1614
+ const r = bookId ? await wereadService.similar(bookId, 6) : await wereadService.recommend(8);
1615
+ if (!r.ok || !r.data?.books?.length)
1616
+ return `(读不到推荐: ${r.error || ''})`;
1617
+ return (bookId ? '与这本书相似的' : '为你推荐的') + `书 ${r.data.books.length} 本:\n`
1618
+ + r.data.books.map((b) => `· 《${b.title}》${b.author || ''}${b.rating ? ` 评分 ${b.rating}` : ''}`
1619
+ + (b.intro ? `\n ${String(b.intro).replace(/\s+/g, ' ').slice(0, 80)}` : '')).join('\n');
1620
+ }
1243
1621
  if (mode === 'search') {
1244
1622
  const kw = String(args.keyword || '');
1245
1623
  if (!kw)
@@ -1281,8 +1659,19 @@ export async function executeTool(name, args, ctx) {
1281
1659
  }
1282
1660
  return `(没有${status === 'done' ? '已完成' : '待办'}任务)`;
1283
1661
  }
1662
+ // 分组:`todos remind` 其实就是同一个文件按紧急度重排,所以不另开一个工具,
1663
+ // 加个参数就够(多一个近义工具只会让模型在两者之间瞎挑)
1664
+ if (String(args.group_by || '') === 'urgency') {
1665
+ const order = { 高: 0, 中: 1, 低: 2 };
1666
+ const sorted = [...todos].sort((a, b) => (order[a.urgency] ?? 3) - (order[b.urgency] ?? 3));
1667
+ const groups = {};
1668
+ for (const t of sorted)
1669
+ (groups[t.urgency || '未标'] = groups[t.urgency || '未标'] || []).push(t);
1670
+ return `${status === 'done' ? '已完成' : '待办'} ${todos.length} 项(按紧急度分组):\n` +
1671
+ Object.entries(groups).map(([u, list]) => `【${u}】${list.length} 项\n` + list.slice(0, 10).map((t) => `· ${t.task || t.content || t.text || t.title}${t.deadline && t.deadline !== '未提及' ? ` (截止 ${t.deadline})` : ''}${idTag(t)}`).join('\n')).join('\n');
1672
+ }
1284
1673
  return `${status === 'done' ? '已完成' : '待办'} ${todos.length} 项:\n` +
1285
- todos.slice(0, 15).map((t) => `· [${t.urgency || '中'}] ${t.task || t.content || t.text || t.title}${t.deadline && t.deadline !== '未提及' ? ` (截止 ${t.deadline})` : ''}`).join('\n');
1674
+ todos.slice(0, 15).map((t) => `· [${t.urgency || '中'}] ${t.task || t.content || t.text || t.title}${t.deadline && t.deadline !== '未提及' ? ` (截止 ${t.deadline})` : ''}${idTag(t)}`).join('\n');
1286
1675
  }
1287
1676
  case 'search_chats': {
1288
1677
  // 跨会话检索:本机 message_fts 索引 + 判断模型挑查询词(**只发候选词与问题,不发聊天内容**)
@@ -1516,7 +1905,7 @@ export async function executeTool(name, args, ctx) {
1516
1905
  const kw = String(args.keyword || '');
1517
1906
  if (!kw)
1518
1907
  return '(缺少 keyword 参数)';
1519
- const liveDirs = VAULT_WIKI_DIRS.filter(dir => existsSync(dir));
1908
+ const liveDirs = vaultDirs().filter(dir => existsSync(dir));
1520
1909
  if (!liveDirs.length)
1521
1910
  return '(知识库尚未生成, 先运行 weflow-cli wiki compile)';
1522
1911
  // **两个目录一起搜**(文章知识库 + 聊天知识库)。分开是用户要求的,但"分开"
@@ -1587,7 +1976,48 @@ export async function executeTool(name, args, ctx) {
1587
1976
  await ensureDb();
1588
1977
  const sessions = await chatService.listSessions(undefined, 1000);
1589
1978
  const fav = await chatService.getFavorites({ limit: 1 });
1590
- return `会话数: ${sessions.length}\n收藏总数: ${fav.success ? fav.total : '未知'}`;
1979
+ const lines = [`会话数: ${sessions.length}`, `收藏总数: ${fav.success ? fav.total : '未知'}`];
1980
+ // 知识库那一半:MCP 的手写 get_stats 一直有、助手这边缺。同一个工具名在两个界面
1981
+ // 报的东西不一样,是最容易让人误判成数据丢了的那种不一致。
1982
+ const dates = existsSync(bizDailyDir())
1983
+ ? readdirSync(bizDailyDir()).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort()
1984
+ : [];
1985
+ let articleCount = 0;
1986
+ const topics = {};
1987
+ for (const d of dates) {
1988
+ const file = join(bizDailyDir(), d, '.articles.json');
1989
+ if (!existsSync(file))
1990
+ continue;
1991
+ try {
1992
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
1993
+ const arts = Array.isArray(raw) ? raw : (raw.articles || []);
1994
+ articleCount += arts.length;
1995
+ for (const a of arts)
1996
+ topics[a.topic || '未知'] = (topics[a.topic || '未知'] || 0) + 1;
1997
+ }
1998
+ catch { /* 某一天的文件坏了不该让整个统计失败 */ }
1999
+ }
2000
+ if (dates.length) {
2001
+ lines.push(`日报文章: ${articleCount} 篇,覆盖 ${dates.length} 天(${dates[0]} ~ ${dates[dates.length - 1]})`);
2002
+ const top = Object.entries(topics).sort((a, b) => b[1] - a[1]).slice(0, 6);
2003
+ if (top.length)
2004
+ lines.push(`主题分布: ${top.map(([t, n]) => `${t}${n}`).join('、')}`);
2005
+ }
2006
+ else {
2007
+ lines.push('日报文章: 还没有日报数据');
2008
+ }
2009
+ const conceptCounts = [];
2010
+ for (const dir of vaultDirs()) {
2011
+ const idx = wikiIndexPath(dir);
2012
+ if (!existsSync(idx))
2013
+ continue;
2014
+ const m = readFileSync(idx, 'utf8').match(/共\s*(\d+)\s*个概念/);
2015
+ if (m)
2016
+ conceptCounts.push(`${dir.split(/[\\/]/).includes('Chat') ? '聊天线' : '文章线'} ${m[1]}`);
2017
+ }
2018
+ if (conceptCounts.length)
2019
+ lines.push(`概念页: ${conceptCounts.join('、')}`);
2020
+ return lines.join('\n');
1591
2021
  }
1592
2022
  case 'get_reading_stats': {
1593
2023
  const days = boundedToolInteger(args.days, 7, 90, 'days');
@@ -1626,6 +2056,285 @@ export async function executeTool(name, args, ctx) {
1626
2056
  const added = ctx.memory.addFact(ctx.userId, content);
1627
2057
  return added ? '(已存入长期记忆)' : '(与已有记忆重复, 未保存)';
1628
2058
  }
2059
+ case 'list_skills': {
2060
+ const scan = scanSkills();
2061
+ const q = String(args.query || '').trim().toLowerCase();
2062
+ const hit = (s) => !q || [s.id, s.name, s.description, ...s.triggers].some(x => String(x).toLowerCase().includes(q));
2063
+ const rows = scan.skills.filter(hit);
2064
+ if (!rows.length) {
2065
+ // 一个技能都没有时**说清是"没装"还是"目录配错了"**:后者会把用户引到错的地方找
2066
+ if (!scan.skills.length) {
2067
+ return `本机没有扫描到技能。技能根目录:${skillRoots().join('、')}`
2068
+ + (scan.missingRoots.length ? `(其中这些目录不存在:${scan.missingRoots.join('、')})` : '');
2069
+ }
2070
+ return `没有匹配「${args.query}」的技能(本机共有 ${scan.skills.length} 个)`;
2071
+ }
2072
+ const disabled = disabledIds();
2073
+ const lines = rows.map(s => {
2074
+ const state = skillState(s, disabled);
2075
+ const tag = state === 'disabled' ? '[已禁用] ' : '';
2076
+ const ver = s.version ? ` v${s.version}` : '';
2077
+ return `· ${tag}${s.id}${ver}:${s.name} —— ${s.description || '(无描述)'}`;
2078
+ });
2079
+ lines.push(`(共 ${rows.length} 个,正文用 read_skill 读)`);
2080
+ return lines.join('\n');
2081
+ }
2082
+ case 'read_skill': {
2083
+ const id = String(args.id || '').trim();
2084
+ if (!id)
2085
+ return '(参数错误: 缺少技能 id)';
2086
+ const body = readSkillBody(id);
2087
+ if (!body.ok)
2088
+ return body.text;
2089
+ // 技能正文是**磁盘上的内容**,可能含 `</weflow-local-data>` —— 必须包起来再进上下文
2090
+ return frameLocalData(`skill.${id}`, body.text);
2091
+ }
2092
+ case 'list_contacts': {
2093
+ await ensureDb();
2094
+ const keyword = String(args.keyword || '').trim();
2095
+ const limit = boundedToolInteger(args.limit, 50, 200);
2096
+ const contacts = await chatService.listContacts(keyword || undefined, limit);
2097
+ if (!contacts.length) {
2098
+ return keyword
2099
+ ? `(通讯录里没有匹配「${keyword}」的名字)`
2100
+ : '(通讯录是空的或读不到——可以用 check 看本机数据源的状态)';
2101
+ }
2102
+ // **只给名字**:`username` 是 wxid,`avatarUrl` 里也可能带本机标识,
2103
+ // 本仓的纪律是本机标识不进模型上下文(同 speakerLabel 那边的处理)
2104
+ const names = [];
2105
+ for (const c of contacts) {
2106
+ const n = String(c.remark || c.displayName || c.nickname || '').trim();
2107
+ if (n && !names.includes(n))
2108
+ names.push(n);
2109
+ }
2110
+ if (!names.length)
2111
+ return '(通讯录读到了, 但都没有可显示的名字)';
2112
+ return `通讯录里 ${names.length} 个名字:\n` + names.slice(0, limit).map(n => `· ${n}`).join('\n');
2113
+ }
2114
+ case 'get_review': {
2115
+ const dir = reviewsDir();
2116
+ if (!existsSync(dir)) {
2117
+ // 说清"这个目录本来就还没生成",而不是让模型以为"今天没有回顾"
2118
+ return '(还没有学习回顾——它不是日报自动产的,要单独跑 `weflow-cli review`)';
2119
+ }
2120
+ const files = readdirSync(dir).filter(f => /^Daily-\d{4}-\d{2}-\d{2}\.md$/.test(f)).sort().reverse();
2121
+ if (!files.length)
2122
+ return '(学习回顾目录是空的)';
2123
+ const want = args.date ? safeDate(args.date) : null;
2124
+ if (args.date && !want)
2125
+ return `(日期格式不对: ${args.date},要 YYYY-MM-DD)`;
2126
+ const pick = want ? files.find(f => f.includes(want)) : files[0];
2127
+ if (!pick) {
2128
+ return `(${want} 没有学习回顾。最近几份: ${files.slice(0, 3).map(f => f.slice(6, 16)).join(', ')})`;
2129
+ }
2130
+ const maxChars = boundedToolInteger(args.max_chars, 3000, 6000);
2131
+ const body = readFileSync(join(dir, pick), 'utf8');
2132
+ const date = pick.slice(6, 16);
2133
+ return `${date} 学习回顾:\n${body.slice(0, maxChars)}`;
2134
+ }
2135
+ case 'get_concepts': {
2136
+ const limit = boundedToolInteger(args.limit, 40, 120);
2137
+ const out = [];
2138
+ for (const dir of vaultDirs()) {
2139
+ const line = dir.split(/[\\/]/).includes('Chat') ? '聊天线' : '文章线';
2140
+ const index = wikiIndexPath(dir);
2141
+ if (!existsSync(index)) {
2142
+ out.push(`【${line}】还没有总览索引(还没编译过这一线的知识库)`);
2143
+ continue;
2144
+ }
2145
+ const text = readFileSync(index, 'utf8');
2146
+ const rows = text.split(/\r?\n/).filter(l => l.trim()).slice(0, limit);
2147
+ out.push(`【${line}】${rows.length} 行:\n${rows.join('\n')}`);
2148
+ }
2149
+ return out.join('\n\n');
2150
+ }
2151
+ case 'format_article': {
2152
+ const content = String(args.content || '');
2153
+ if (!content.trim())
2154
+ return '(参数错误: content 不能为空)';
2155
+ const { formatWeChatArticle } = await import('./wechat-formatter.js');
2156
+ const theme = String(args.theme || 'default');
2157
+ const html = formatWeChatArticle(content, { theme: theme });
2158
+ const preview = html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').slice(0, 200);
2159
+ return `主题: ${theme}\n总长度: ${html.length} 字符\n正文预览: ${preview}…\n---\n${html}`;
2160
+ }
2161
+ case 'list_themes': {
2162
+ const { listThemes } = await import('./wechat-formatter.js');
2163
+ const themes = listThemes();
2164
+ if (!themes.length)
2165
+ return '(没有可用主题)';
2166
+ return `可用主题 ${themes.length} 个:\n`
2167
+ + themes.map(t => `· ${t.id} — ${t.name}: ${t.description}`).join('\n');
2168
+ }
2169
+ case 'lint_wiki': {
2170
+ // 两个知识库都要查(本仓的分线纪律:只查一个,另一半的断链永远看不见)
2171
+ const parts = [];
2172
+ let totalBad = 0;
2173
+ for (const dir of vaultDirs()) {
2174
+ const line = dir.split(/[\\/]/).includes('Chat') ? '聊天线' : '文章线';
2175
+ if (!existsSync(dir)) {
2176
+ parts.push(`【${line}】目录不存在,跳过`);
2177
+ continue;
2178
+ }
2179
+ // 超时给 60 秒:本机最大的一条线(3,623 页)实测 **3.4 秒**(脚本里的 O(n²) 修掉之后,
2180
+ // 见 wiki_lint.py 那条注释),17 倍余量够用。默认的 30 秒曾经也超——那是因为脚本本身慢,
2181
+ // 不是超时给得小。
2182
+ const r = await runPythonJson('wiki_lint.py', ['--dir', dir, '--json'], { timeoutMs: 60_000 });
2183
+ if (!r.ok || !r.data) {
2184
+ // 失败要说出来:静默跳过会让"体检通过"变成一句假话
2185
+ parts.push(`【${line}】体检失败: ${r.error || '读不到结果'}`);
2186
+ totalBad += 1;
2187
+ continue;
2188
+ }
2189
+ const d = r.data;
2190
+ const broken = (d.broken || []).length;
2191
+ const orphans = (d.orphans || []).length;
2192
+ const empty = (d.empty || []).length;
2193
+ const dup = Object.keys(d.duplicateTitles || {}).length;
2194
+ totalBad += broken + orphans + empty + dup;
2195
+ parts.push(`【${line}】${d.pages || 0} 张页:断链 ${broken}、孤儿 ${orphans}、空页 ${empty}、同名 ${dup} 组`);
2196
+ }
2197
+ return (totalBad ? '' : '两个知识库都没有断链/孤儿/空页/同名。\n') + parts.join('\n');
2198
+ }
2199
+ case 'check_skills': {
2200
+ const scan = scanSkills();
2201
+ const disabled = disabledIds();
2202
+ const lines = [`技能目录: ${skillRoots().join('、')}`, `扫描到 ${scan.skills.length} 个技能`];
2203
+ for (const dir of scan.missingRoots)
2204
+ lines.push(`· 目录不存在: ${dir}`);
2205
+ if (scan.unreadable.length) {
2206
+ lines.push(`读不出来的 ${scan.unreadable.length} 个(frontmatter 缺失或不闭合):`);
2207
+ for (const u of scan.unreadable)
2208
+ lines.push(`· ${u.file} —— ${u.reason}`);
2209
+ }
2210
+ if (scan.conflicts.length) {
2211
+ lines.push(`跨目录同名的 ${scan.conflicts.length} 个(靠前的目录胜出): ${scan.conflicts.map(c => c.id).join('、')}`);
2212
+ }
2213
+ const warned = scan.skills.filter(s => s.notes.length);
2214
+ if (warned.length) {
2215
+ lines.push(`有提醒的 ${warned.length} 个:`);
2216
+ for (const w of warned)
2217
+ for (const n of w.notes)
2218
+ lines.push(`· ${w.id}: ${n}`);
2219
+ }
2220
+ const off = scan.skills.filter(s => skillState(s, disabled) === 'disabled').map(s => s.id);
2221
+ if (off.length)
2222
+ lines.push(`已禁用: ${off.join('、')}`);
2223
+ if (!scan.unreadable.length && !scan.conflicts.length && !warned.length) {
2224
+ lines.push('没有读不出来或撞名的技能。');
2225
+ }
2226
+ return lines.join('\n');
2227
+ }
2228
+ case 'fetch_article': {
2229
+ const url = String(args.url || '').trim();
2230
+ // **严格白名单,不是 denylist**:只认 https 的 mp.weixin.qq.com。
2231
+ // 这条比 read_favorite 那条严,因为那里的链接是用户自己收藏的,这里的 URL 是别人给的。
2232
+ if (!isAllowedWeChatArticleUrl(url)) {
2233
+ return `(只接受 https 的 mp.weixin.qq.com 文章链接,拒绝抓取: ${url.slice(0, 60)})`;
2234
+ }
2235
+ const maxChars = boundedToolInteger(args.max_chars, 3000, 6000);
2236
+ // 逐跳复检:一次 302 就能把入口白名单绕过去
2237
+ const { status, html } = await fetchArticleHtml(url, { maxRedirects: 3, allowHop: isAllowedWeChatArticleUrl });
2238
+ if (!html)
2239
+ return `(抓取失败 HTTP ${status} —— 链接可能不是公众号文章,或者跳转到了别处)`;
2240
+ if (html.includes('已被发布者删除'))
2241
+ return '(这篇文章已被发布者删除)';
2242
+ const body = articleTextFromHtml(html, maxChars);
2243
+ if (!body.trim() || (body.match(/[一-鿿]/g) || []).length < 20) {
2244
+ return '(抓到了页面但取不出正文——可能是验证页,或者这篇文章要登录/已失效)';
2245
+ }
2246
+ const title = html.match(/<meta[^>]*property="og:title"[^>]*content="([^"]+)"/i)?.[1]
2247
+ || html.match(/<title>([^<]+)<\/title>/i)?.[1]?.trim() || '';
2248
+ const author = html.match(/var\s+nickname\s*=\s*"([^"]+)"/i)?.[1]
2249
+ || html.match(/<meta[^>]*property="og:article:author"[^>]*content="([^"]+)"/i)?.[1] || '';
2250
+ // 配图只报数量与前几个链接:正文是纯文本,但图往往就是这篇文章的要点
2251
+ const imgs = [...html.matchAll(/data-src="(https?:\/\/[^"]+)"/g)].map(m => m[1]).slice(0, 5);
2252
+ const head = [title && `标题: ${title}`, author && `公众号: ${author}`].filter(Boolean).join(' | ');
2253
+ const tail = imgs.length ? `\n\n配图 ${imgs.length} 张(前几张):\n${imgs.map(u => `· ${u}`).join('\n')}` : '';
2254
+ return (head ? `${head}\n---\n` : '') + body + tail;
2255
+ }
2256
+ case 'search_public': {
2257
+ const keyword = String(args.keyword || '').trim();
2258
+ if (!keyword)
2259
+ return '(参数错误: 缺少 keyword)';
2260
+ const limit = boundedToolInteger(args.limit, 8, 20);
2261
+ let html = '';
2262
+ try {
2263
+ const resp = await fetch(`https://weixin.sogou.com/weixin?type=2&query=${encodeURIComponent(keyword)}`, {
2264
+ headers: {
2265
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
2266
+ 'Referer': 'https://weixin.sogou.com/',
2267
+ 'Accept': 'text/html',
2268
+ 'Accept-Language': 'zh-CN,zh;q=0.9',
2269
+ },
2270
+ signal: AbortSignal.timeout(20_000),
2271
+ });
2272
+ html = await readTextLimited(resp, 2 * 1024 * 1024);
2273
+ }
2274
+ catch {
2275
+ return '(搜索失败——检查网络,或者稍后再试(第三方搜索页会限流))';
2276
+ }
2277
+ const grab = (re, clean) => {
2278
+ const out = [];
2279
+ for (const m of html.matchAll(re)) {
2280
+ const v = clean(m[1]);
2281
+ if (v)
2282
+ out.push(v);
2283
+ }
2284
+ return out;
2285
+ };
2286
+ const titles = grab(/<h3[^>]*>\s*<a[^>]*>(.*?)<\/a>\s*<\/h3>/gs, s => s.replace(/<em[^>]*>/g, '').replace(/<\/em>/g, '').replace(/<!--[^>]*-->/g, '').trim());
2287
+ const accounts = grab(/<span[^>]*class="[^"]*all-time-y2[^"]*"[^>]*>(.*?)<\/span>/gs, s => s.replace(/<[^>]+>/g, '').trim());
2288
+ const descs = grab(/<p[^>]*class="[^"]*txt-info[^"]*"[^>]*>(.*?)<\/p>/gs, s => s.replace(/<[^>]+>/g, '').replace(/&hellip;/g, '…').replace(/&rarr;/g, '→').replace(/&mdash;/g, '—').trim())
2289
+ .filter(d => d.length > 10);
2290
+ const count = Math.max(titles.length, accounts.length, descs.length);
2291
+ if (!count) {
2292
+ return `(没搜到与「${keyword}」相关的公开文章。换个说法、或加上公众号名试试;`
2293
+ + '找到链接后可以用 fetch_article 抓全文)';
2294
+ }
2295
+ const rows = [];
2296
+ for (let i = 0; i < Math.min(count, limit); i++) {
2297
+ rows.push(`${i + 1}. ${titles[i] || '(未知标题)'}\n 公众号: ${accounts[i] || '(未知)'}`
2298
+ + (descs[i] ? `\n ${descs[i]}` : ''));
2299
+ }
2300
+ return `公开文章里搜「${keyword}」,命中 ${Math.min(count, limit)} 条:\n`
2301
+ + rows.join('\n')
2302
+ + '\n(这是第三方搜索页的标题与摘要,不是全文;要正文用 fetch_article 抓)';
2303
+ }
2304
+ case 'set_todo_status': {
2305
+ const status = String(args.status || '');
2306
+ if (status !== 'done' && status !== 'pending')
2307
+ return '(参数错误: status 只能是 done(已完成)或 pending(取消完成))';
2308
+ const id = String(args.id || '').trim();
2309
+ const task = String(args.task || '').trim();
2310
+ if (!id && !task)
2311
+ return '(参数错误: 要给 id 或 task 指到哪一条待办)';
2312
+ let target = id;
2313
+ if (!target) {
2314
+ // 按任务文字找:**只认唯一**。命中 0 条或 ≥2 条都拒绝——
2315
+ // 猜错条目比拒绝更糟(用户会以为自己的待办被改了,而改的是另一条)
2316
+ const listed = await runPythonJson('extract_todos.py', ['list', '--json']);
2317
+ if (!listed.ok)
2318
+ return fail('待办读取失败', listed);
2319
+ const items = Array.isArray(listed.data) ? listed.data : (listed.data?.items || []);
2320
+ const norm = (s) => String(s || '').replace(/\s+/g, '');
2321
+ const hit = items.filter(t => norm(t.task).includes(norm(task)));
2322
+ if (!hit.length)
2323
+ return `(没有任务文字包含「${task}」的待办)`;
2324
+ if (hit.length > 1) {
2325
+ return `(「${task}」匹配到 ${hit.length} 条待办,不能确定是哪一条,请用 id 指定:\n`
2326
+ + hit.slice(0, 5).map(t => `· ${t.task}`).join('\n') + ')';
2327
+ }
2328
+ if (!hit[0].id)
2329
+ return '(这条待办没有 id——本机的待办文件格式比预期旧,请在电脑上用 `todos done` 处理)';
2330
+ target = String(hit[0].id);
2331
+ }
2332
+ const r = await runPythonJson('extract_todos.py', [status === 'done' ? 'done' : 'undone', target, '--json']);
2333
+ if (!r.ok)
2334
+ return fail(status === 'done' ? '标记完成失败' : '取消完成失败', r);
2335
+ // 括号开头 = "没有产出内容"(本仓的约定,见 producedContent):这条只报告动作做成了
2336
+ return status === 'done' ? `(已标记完成: ${target})` : `(已取消完成标记: ${target})`;
2337
+ }
1629
2338
  default:
1630
2339
  return `(未知工具: ${name})`;
1631
2340
  }