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
@@ -7,6 +7,8 @@ import { configService } from './configService.js'
7
7
  import { runPythonJson } from './pythonBridge.js'
8
8
  import { exportService } from './exportService.js'
9
9
  import type { AssistantMemory } from './assistantMemory.js'
10
+ import { frameLocalData } from './assistantMemory.js'
11
+ import { disabledIds, readSkillBody, scanSkills, skillRoots, skillState } from './assistantSkills.js'
10
12
  import { privacyGate } from './assistantPrivacy.js'
11
13
  import { existsSync, readFileSync, readdirSync } from 'fs'
12
14
  import { basename, join } from 'path'
@@ -15,18 +17,49 @@ import { resolvePackageRoot } from '../utils/packageRoot.js'
15
17
  import type { Contact } from '../types.js'
16
18
  import { clipWithMarker } from '../utils/text.js'
17
19
  import { resolveSince, resolveUntil } from '../utils/dateRange.js'
20
+ import { isAllowedWeChatArticleUrl, safeDate } from '../utils/mcpSecurity.js'
18
21
 
19
22
  const PKG_ROOT = resolvePackageRoot(import.meta.url)
20
- const BIZ_DAILY_DIR = join(PKG_ROOT, 'output', 'biz-daily')
23
+ /**
24
+ * 日报目录。**可以用 `WEFLOW_ASSISTANT_BIZ_DAILY_DIR` 指到别处**。
25
+ *
26
+ * 为什么加这个注入点:`get_daily_report` 一直是本文件里**唯一从没被执行过的工具分支**,
27
+ * 原因写在测试文件头上——它读的是模块级常量路径,没有注入点,断言只能写成"没数据/有数据皆可",
28
+ * 那种测试看着像覆盖、其实什么也没钉住。同 `WEFLOW_ASSISTANT_EXPORT_ROOT` 的先例(见 `export_chat`)。
29
+ */
30
+ // 做成函数而不是模块常量:环境变量在**每次调用**时读,测试才能在同一进程里换夹具
31
+ // (同 `export_chat` 的 `WEFLOW_ASSISTANT_EXPORT_ROOT` 做法)。常量只在 import 时求值,
32
+ // 那会让「目录不存在」这类分支永远测不到——写出来的断言就只能是摆设。
33
+ const bizDailyDir = (): string =>
34
+ process.env.WEFLOW_ASSISTANT_BIZ_DAILY_DIR || join(PKG_ROOT, 'output', 'biz-daily')
35
+ /** 学习回顾目录。同样可注入:否则 `get_review` 的断言只能依赖这台机器上有没有跑过 `review`。 */
36
+ const reviewsDir = (): string =>
37
+ process.env.WEFLOW_ASSISTANT_REVIEWS_DIR || join(PKG_ROOT, 'output', 'reviews', 'Daily')
21
38
  /** 单条聊天消息进上下文的字数上限(见 get_messages:引用消息要放得下正文+被引原文) */
22
39
  const MSG_BODY_CHARS = 160
23
40
  // 知识页住在哪几个目录。**两个是有意的**:文章知识库与聊天知识库分开(用户 2026-09-27
24
41
  // 要求),所以读的一方要把**两个都读**——只读一个,分出去的那一半就静默地搜不到。
25
42
  // 与 Python 的 `_utils.CONCEPT_DIRS` 是同一份清单,有测试钉住两者一致(跨语言没法共用常量)。
26
- const VAULT_WIKI_DIRS = [
27
- join(PKG_ROOT, 'output', 'wechat-vault', 'Wiki', 'Concepts'),
28
- join(PKG_ROOT, 'output', 'wechat-vault', 'Chat', 'Concepts'),
29
- ]
43
+ /**
44
+ * 总览索引在哪:**在概念目录的上一层**——`Wiki/Concepts/` 的兄弟是 `Wiki/00-Overview.md`。
45
+ *
46
+ * 抽成函数是为了能直接测:这段路径写错过一次(写成 `join(dir, '00-Overview.md')`,于是一直读不到),
47
+ * 而当时那条测试只能断言「有内容或没有」这种形状——真实机器上有索引、CI 上没有,两条路都绿。
48
+ */
49
+ export function wikiIndexPath(conceptDir: string): string {
50
+ return join(conceptDir, '..', '00-Overview.md')
51
+ }
52
+
53
+ /**
54
+ * 知识库那两条线的 `Concepts` 目录。**同样可注入**(`WEFLOW_ASSISTANT_VAULT_DIR`),
55
+ * 与上面 `bizDailyDir` / `reviewsDir` 是同一条理由:评测跑在临时家目录里,包内那个
56
+ * `output/wechat-vault/` 必然为空,`get_concepts` 就只能测到「还没编译过」那一支——
57
+ * 要给它写用例,就得能把这个根换掉。(做成函数而不是常量:环境变量要在**每次调用**时读。)
58
+ */
59
+ const vaultDirs = (): string[] => {
60
+ const root = process.env.WEFLOW_ASSISTANT_VAULT_DIR || join(PKG_ROOT, 'output', 'wechat-vault')
61
+ return [join(root, 'Wiki', 'Concepts'), join(root, 'Chat', 'Concepts')]
62
+ }
30
63
 
31
64
  /**
32
65
  * 概念名 → 文件名。**与 `compile_wiki` 是同一套规则**(非法字符换下划线、截到 60 字)。
@@ -77,7 +110,7 @@ function relatedNames(pagePath: string): string[] {
77
110
  * - **没建页的邻居照旧报数**(`missing`),不静默吞掉。
78
111
  */
79
112
  export function conceptSubgraph(pageName: string, depth = 2, maxNodes = 30,
80
- wikiDirs: string[] = VAULT_WIKI_DIRS): {
113
+ wikiDirs: string[] = vaultDirs()): {
81
114
  levels: Array<Array<{ name: string; brief: string; via: string }>>
82
115
  truncated: boolean
83
116
  missing: number
@@ -149,7 +182,7 @@ export function conceptBrief(text: string): string {
149
182
  *
150
183
  * 有页的邻居连定义一起给;没有页的只报个数(`missing`),免得模型去追一个取不到的名字。
151
184
  */
152
- export function conceptNeighbors(pageName: string, wikiDirs: string[] = VAULT_WIKI_DIRS): { linked: Array<{ name: string; brief: string }>; missing: number } {
185
+ export function conceptNeighbors(pageName: string, wikiDirs: string[] = vaultDirs()): { linked: Array<{ name: string; brief: string }>; missing: number } {
153
186
  // **传字符串会静默地按字符遍历**(`for (const dir of 'D:\vault')` 挨个字符看过去),
154
187
  // 于是 `join('D', '某概念.md')` 不存在,函数安静地回一句「没有邻居」。2026-09-27 把参数
155
188
  // 从单个目录改成目录数组时,测试里正好踩到这个——类型拦得住 `src/`(`tsc` 覆盖它),
@@ -413,6 +446,80 @@ export function extractText(html: string): string {
413
446
  }
414
447
 
415
448
  /** 确保数据库已连接 (connect 幂等, 已连接时直接返回) */
449
+ /**
450
+ * 抓页面 HTML:微信内置浏览器 UA + Referer 绕 WAF,最多试两次。`read_favorite` 与 `fetch_article` 共用。
451
+ *
452
+ * `maxRedirects > 0` 时**自己跟跳转并逐跳复检白名单**:默认的 `redirect: 'follow'` 只看入口,
453
+ * 一次 302 就能把白名单绕过去(拿到一个被允许的入口,跳到内网地址)。`fetch_article`
454
+ * 收的是用户/模型给的任意 URL,所以要这道闸;`read_favorite` 收的是用户**自己收藏**的链接,
455
+ * 保持原行为不动(那里用 denylist,且收藏里本来就有各种站)。
456
+ */
457
+ async function fetchArticleHtml(
458
+ url: string,
459
+ opts: { maxRedirects?: number; allowHop?: (u: string) => boolean } = {},
460
+ ): Promise<{ status: number; html: string }> {
461
+ const maxRedirects = opts.maxRedirects ?? 0
462
+ let current = url
463
+ let lastStatus = 0
464
+ for (let hop = 0; hop <= maxRedirects; hop++) {
465
+ if (maxRedirects > 0 && opts.allowHop && !opts.allowHop(current)) {
466
+ return { status: lastStatus, html: '' } // 某一跳出了白名单:宁可空手而归
467
+ }
468
+ let html = ''
469
+ let status = 0
470
+ let followed = false
471
+ for (let attempt = 0; attempt < 2; attempt++) {
472
+ try {
473
+ const res = await fetch(current, {
474
+ headers: {
475
+ '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',
476
+ 'Referer': 'https://mp.weixin.qq.com/',
477
+ 'Accept': 'text/html,application/xhtml+xml',
478
+ 'Accept-Language': 'zh-CN,zh;q=0.9',
479
+ },
480
+ ...(maxRedirects > 0 ? { redirect: 'manual' as const } : {}),
481
+ signal: AbortSignal.timeout(20_000),
482
+ })
483
+ status = res.status
484
+ if (maxRedirects > 0 && status >= 300 && status < 400) {
485
+ const location = res.headers.get('location')
486
+ if (!location) return { status, html: '' }
487
+ current = new URL(location, current).toString()
488
+ followed = true
489
+ break // 换一跳,重来
490
+ }
491
+ html = (await res.text()).slice(0, 500_000)
492
+ // 正文验证: 必须含 js_content (否则是验证页, 重试)
493
+ if (res.ok && html.includes('js_content')) break
494
+ } catch { /* 超时/网络错误 → 重试 */ }
495
+ }
496
+ if (html) return { status, html }
497
+ lastStatus = status
498
+ // 没跟跳转就走完了两次尝试:就是抓不到,别再拿同一个地址重试下去
499
+ if (!followed) return { status, html: '' }
500
+ }
501
+ return { status: lastStatus, html: '' }
502
+ }
503
+
504
+ /** 读响应体但设上限:第三方搜索页可能很大,不让它把内存吃掉(超了就截断,不报错) */
505
+ async function readTextLimited(response: Response, maxBytes: number): Promise<string> {
506
+ const buf = await response.arrayBuffer()
507
+ return Buffer.from(buf).subarray(0, maxBytes).toString('utf8')
508
+ }
509
+
510
+ /**
511
+ * 从抓到的 HTML 里取正文:优先正常页面的 `js_content`,取不到再走 WAF 挑战页的 `content_noencode`。
512
+ * 返回的文本已按 `maxChars` 截断。
513
+ */
514
+ function articleTextFromHtml(html: string, maxChars: number): string {
515
+ let text = html.includes('js_content') ? extractText(html) : ''
516
+ if (!text || (text.match(/[一-鿿]/g) || []).length < 50) {
517
+ const decoded = extractFromChallengePage(html)
518
+ if (decoded) text = stripTags(decoded)
519
+ }
520
+ return text.slice(0, maxChars)
521
+ }
522
+
416
523
  async function ensureDb(): Promise<void> {
417
524
  await chatService.connect()
418
525
  }
@@ -420,10 +527,10 @@ async function ensureDb(): Promise<void> {
420
527
  /** 最近一份**有内容**的日报是哪天,以及那是几天前。算不出来就回空串——不猜。 */
421
528
  function lastDailyNote(): string {
422
529
  try {
423
- const days = readdirSync(BIZ_DAILY_DIR)
530
+ const days = readdirSync(bizDailyDir())
424
531
  .filter(name => /^\d{4}-\d{2}-\d{2}$/.test(name)).sort().reverse().slice(0, 30)
425
532
  for (const day of days) {
426
- const file = join(BIZ_DAILY_DIR, day, '.articles.json')
533
+ const file = join(bizDailyDir(), day, '.articles.json')
427
534
  if (!existsSync(file)) continue
428
535
  const payload = JSON.parse(readFileSync(file, 'utf8'))
429
536
  if ((payload?.articles ?? []).length) {
@@ -442,6 +549,15 @@ interface TalkerCandidate {
442
549
 
443
550
  class ToolInputError extends Error {}
444
551
 
552
+ /**
553
+ * 把待办 id 露给模型。**不露出来它就指不到具体哪一条**——`set_todo_status` 只能靠 id
554
+ * 或唯一的任务文字定位,而任务文字常常是长句、几条之间又很像。
555
+ * 给完整 id(CLI 那边支持 id 前缀,但前缀要唯一才安全,这里不赌)。
556
+ */
557
+ function idTag(t: any): string {
558
+ return t?.id ? ` [id: ${t.id}]` : ''
559
+ }
560
+
445
561
  export function boundedToolInteger(value: unknown, fallback: number, maximum: number, field = 'limit'): number {
446
562
  if (value === undefined || value === null || value === '') return fallback
447
563
  const parsed = Number(value)
@@ -596,13 +712,19 @@ export const TOOL_DEFS: ToolDef[] = [
596
712
  type: 'function',
597
713
  function: {
598
714
  name: 'get_daily_report',
599
- description: '获取公众号日报: 某天推送了哪些文章(标题/来源/分类/AI摘要)。适合「今天/某天公众号推了什么」「最近有哪些AI文章」类问题。',
715
+ description: '获取公众号日报: 推送了哪些文章(标题/来源/分类/AI摘要)。'
716
+ + '适合「今天/某天公众号推了什么」「最近有哪些AI文章」「搜一下关于X的文章」类问题。'
717
+ + '**给 date 就是那一天的;不给 date 只给关键词/分类就是跨日期搜**(默认最近 7 天,用 days 调)。'
718
+ + '要当天那份**人读的日报正文**(不是条目清单)加 full。',
600
719
  parameters: {
601
720
  type: 'object',
602
721
  properties: {
603
- date: { type: 'string', description: '日期 YYYY-MM-DD, 默认最新一期' },
722
+ date: { type: 'string', description: '日期 YYYY-MM-DD;省略则最新一期(或配合关键词跨日期搜)' },
604
723
  topic: { type: 'string', description: '分类过滤: AI | 学术 | 新闻 | 文学 | 投资' },
605
- keyword: { type: 'string', description: '标题/摘要关键词过滤' },
724
+ keyword: { type: 'string', description: '标题/摘要/来源关键词。不给 date 时跨日期搜' },
725
+ days: { type: 'number', description: '跨日期搜的时间窗(天), 默认 7, 上限 60' },
726
+ full: { type: 'boolean', description: '返回当天日报的正文(人读的那份),而不是条目清单' },
727
+ max_chars: { type: 'number', description: 'full 时正文上限, 默认 3000, 上限 6000' },
606
728
  limit: { type: 'number', description: '返回条数, 默认15' },
607
729
  },
608
730
  },
@@ -626,12 +748,16 @@ export const TOOL_DEFS: ToolDef[] = [
626
748
  type: 'function',
627
749
  function: {
628
750
  name: 'get_weread',
629
- description: '查看用户微信读书数据: 书架(shelf)、笔记(notebooks)、搜索书(search)。适合「我在读什么书」「某本书的笔记」类问题。',
751
+ description: '查看用户微信读书数据。mode: shelf(书架) | notebooks(有笔记的书) | search(按书名搜) '
752
+ + '| stats(近期阅读时长) | book(某本书详情+我的进度) | review(某本书的热门书评) '
753
+ + '| discover(推荐/相似书) | profile(累计阅读画像)。适合「我在读什么书」「某本书讲什么」「读了多少」类问题。'
754
+ + 'book / review 需要先用 search 拿到 book_id。',
630
755
  parameters: {
631
756
  type: 'object',
632
757
  properties: {
633
- mode: { type: 'string', description: 'shelf | notebooks | search, 默认 shelf' },
758
+ mode: { type: 'string', description: 'shelf | notebooks | search | stats | book | review | discover | profile, 默认 shelf' },
634
759
  keyword: { type: 'string', description: 'search 模式的书名关键词' },
760
+ book_id: { type: 'string', description: 'book / review 模式的书籍 id(search 或 shelf 的结果里有)' },
635
761
  },
636
762
  },
637
763
  },
@@ -640,11 +766,13 @@ export const TOOL_DEFS: ToolDef[] = [
640
766
  type: 'function',
641
767
  function: {
642
768
  name: 'get_todos',
643
- description: '查看用户从聊天记录提取的待办任务清单(含优先级和截止时间)。适合「我最近有什么待办」「有什么紧急的事」类问题。',
769
+ description: '查看用户从聊天记录提取的待办任务清单(含优先级和截止时间)。适合「我最近有什么待办」「有什么紧急的事」类问题。'
770
+ + '每条后面方括号里的 id 用来改某一条的状态(set_todo_status 需要它)。按紧急度分组看用 group_by。',
644
771
  parameters: {
645
772
  type: 'object',
646
773
  properties: {
647
774
  status: { type: 'string', description: 'pending | done, 默认 pending' },
775
+ group_by: { type: 'string', description: '传 urgency 就按紧急度分组列出' },
648
776
  },
649
777
  },
650
778
  },
@@ -777,7 +905,8 @@ export const TOOL_DEFS: ToolDef[] = [
777
905
  type: 'function',
778
906
  function: {
779
907
  name: 'get_stats',
780
- description: '获取用户本地微信数据统计(会话数/收藏总数等)。',
908
+ description: '本地数据总览:会话数、收藏总数、日报文章数与覆盖天数、主题分布、概念页数。'
909
+ + '适合「我一共有多少数据」这类总览问题;单看公众号推送用 get_reading_stats。',
781
910
  parameters: { type: 'object', properties: {} },
782
911
  },
783
912
  },
@@ -830,6 +959,172 @@ export const TOOL_DEFS: ToolDef[] = [
830
959
  },
831
960
  },
832
961
  },
962
+ {
963
+ type: 'function',
964
+ function: {
965
+ name: 'list_skills',
966
+ description: '列出本机装好的技能包(名字+一句话说明+版本)。技能是一份说明文档,'
967
+ + '用 read_skill 读它的正文再照着做;不要凭名字猜它怎么用。',
968
+ parameters: {
969
+ type: 'object',
970
+ properties: {
971
+ query: { type: 'string', description: '可选:按关键词过滤(匹配 id、名称、说明和触发词)' },
972
+ },
973
+ },
974
+ },
975
+ },
976
+ {
977
+ type: 'function',
978
+ function: {
979
+ name: 'read_skill',
980
+ description: '读取某个技能包的完整说明(SKILL.md 正文)。要用某个技能前先读它。'
981
+ + 'id 用 list_skills 查到的那个。',
982
+ parameters: {
983
+ type: 'object',
984
+ properties: {
985
+ id: { type: 'string', description: '技能 id,如 wechat-article-extract' },
986
+ },
987
+ required: ['id'],
988
+ },
989
+ },
990
+ },
991
+ {
992
+ type: 'function',
993
+ function: {
994
+ name: 'list_contacts',
995
+ description: '列出通讯录里的联系人(显示名、备注、昵称)。**不含 wxid 等本机标识**,'
996
+ + '只有能叫得出名字的人。'
997
+ + '(要看最近跟谁聊过用 list_sessions;要按名字查某个会话的聊天用 get_messages)',
998
+ parameters: {
999
+ type: 'object',
1000
+ properties: {
1001
+ keyword: { type: 'string', description: '按名字过滤(可省略)' },
1002
+ limit: { type: 'number', description: '最多几条, 默认 50, 上限 200' },
1003
+ },
1004
+ },
1005
+ },
1006
+ },
1007
+ {
1008
+ type: 'function',
1009
+ function: {
1010
+ name: 'get_review',
1011
+ description: '读某一天的学习回顾(对当天日报文章做的二次整理:要点、联系、值得回看的)。'
1012
+ + '**与 get_daily_report 不是一回事**:那个是当天日报的原始条目清单,这个是复盘。',
1013
+ parameters: {
1014
+ type: 'object',
1015
+ properties: {
1016
+ date: { type: 'string', description: '日期 YYYY-MM-DD,省略则取最近一份' },
1017
+ },
1018
+ },
1019
+ },
1020
+ },
1021
+ {
1022
+ type: 'function',
1023
+ function: {
1024
+ name: 'get_concepts',
1025
+ description: '读知识库的两份总览索引(文章线、聊天线各一份)。这是**目录**,'
1026
+ + '用来回答"我的知识库里都有什么";要按名字找**某一页**的内容用 search_knowledge。',
1027
+ parameters: {
1028
+ type: 'object',
1029
+ properties: {
1030
+ limit: { type: 'number', description: '每份索引最多返回多少行, 默认 40' },
1031
+ },
1032
+ },
1033
+ },
1034
+ },
1035
+ {
1036
+ type: 'function',
1037
+ function: {
1038
+ name: 'fetch_article',
1039
+ description: '抓取一篇公众号文章的正文(需要完整链接)。只接受 https 的 mp.weixin.qq.com 链接。'
1040
+ + '与 read_favorite 的区别:那个从你自己的收藏里找,这个由用户给出链接。',
1041
+ parameters: {
1042
+ type: 'object',
1043
+ properties: {
1044
+ url: { type: 'string', description: '公众号文章完整链接' },
1045
+ max_chars: { type: 'number', description: '正文上限, 默认 3000, 上限 6000' },
1046
+ },
1047
+ required: ['url'],
1048
+ },
1049
+ },
1050
+ },
1051
+ {
1052
+ type: 'function',
1053
+ function: {
1054
+ name: 'search_public',
1055
+ description: '在公开的公众号文章里按关键词搜标题与摘要(走第三方搜索页)。'
1056
+ + '只搜公开文章,搜不到你自己的收藏、聊天记录或知识库。',
1057
+ parameters: {
1058
+ type: 'object',
1059
+ properties: {
1060
+ keyword: { type: 'string', description: '搜索词' },
1061
+ limit: { type: 'number', description: '最多几条, 默认 8, 上限 20' },
1062
+ },
1063
+ required: ['keyword'],
1064
+ },
1065
+ },
1066
+ },
1067
+ {
1068
+ type: 'function',
1069
+ function: {
1070
+ name: 'format_article',
1071
+ description: '把 Markdown 转成能直接粘进公众号编辑器的 HTML 片段(纯本地转换,不联网、不写文件)。'
1072
+ + '主题名用 list_themes 查。',
1073
+ parameters: {
1074
+ type: 'object',
1075
+ properties: {
1076
+ // 参数名保持 `content` —— 这个名字在 MCP 那条路上已经被外部客户端用了,
1077
+ // 改名的代价是别人的调用直接失效(工具名不变、参数变了,最难查的那种坏法)
1078
+ content: { type: 'string', description: '要排版的 Markdown 正文' },
1079
+ theme: { type: 'string', description: '主题名(省略用 default)' },
1080
+ },
1081
+ required: ['content'],
1082
+ },
1083
+ },
1084
+ },
1085
+ {
1086
+ type: 'function',
1087
+ function: {
1088
+ name: 'list_themes',
1089
+ description: '列出可用的公众号排版主题(配合 format_article 用)。',
1090
+ parameters: { type: 'object', properties: {} },
1091
+ },
1092
+ },
1093
+ {
1094
+ type: 'function',
1095
+ function: {
1096
+ name: 'lint_wiki',
1097
+ description: '知识库体检:断链、孤儿页、空页、同名页。**只读**,不改任何页。',
1098
+ parameters: { type: 'object', properties: {} },
1099
+ },
1100
+ },
1101
+ {
1102
+ type: 'function',
1103
+ function: {
1104
+ name: 'check_skills',
1105
+ description: '技能体检:报出读不出来的 SKILL.md、跨目录同名、id 不合规范、被禁用的技能。'
1106
+ + '与 list_skills 的区别:那个是清单,这个是**问题清单**。',
1107
+ parameters: { type: 'object', properties: {} },
1108
+ },
1109
+ },
1110
+ {
1111
+ type: 'function',
1112
+ function: {
1113
+ name: 'set_todo_status',
1114
+ description: '把一条待办标记为已完成 / 取消完成。**不能删除待办、不能新增**。'
1115
+ + '要精确指到那一条:优先用 get_todos 给出的 id;也可以给一段任务文字,'
1116
+ + '但**匹配到零条或多条都会被拒绝**(猜错条目比拒绝更糟)。',
1117
+ parameters: {
1118
+ type: 'object',
1119
+ properties: {
1120
+ id: { type: 'string', description: '待办 id(get_todos 输出里的那个)' },
1121
+ task: { type: 'string', description: '或用任务文字精确匹配(必须唯一)' },
1122
+ status: { type: 'string', description: 'done = 已完成, pending = 取消完成' },
1123
+ },
1124
+ required: ['status'],
1125
+ },
1126
+ },
1127
+ },
833
1128
  ]
834
1129
 
835
1130
  /**
@@ -851,6 +1146,10 @@ export const TOOL_DEFS: ToolDef[] = [
851
1146
  export const MCP_EXCLUDED: Record<string, string> = {
852
1147
  save_memory: 'MCP 没有微信用户身份,挂的记忆桶是单独一条,让它写没有意义',
853
1148
  look_at_image: 'MCP 只把工具返回的文本交给客户端,它挂进侧信道的图没人接,回话会变成一句假话',
1149
+ // 这条是**故意留在外面的**(docs/MCP.md 的天花板:「mutating todos ... stay out」):
1150
+ // 助手在微信/面板里可以替你翻转一条待办状态,但外部 MCP 客户端不行——
1151
+ // 那条路上没有"你",只有调用方;一条机器指令顺手改掉你的待办,不该是默认能力。
1152
+ set_todo_status: '待办是本地状态,MCP 那条路上没有用户可确认;改状态请在微信/面板里说,或用 CLI 的两段式 todos done',
854
1153
  }
855
1154
 
856
1155
  export const MCP_TOOL_DEFS = TOOL_DEFS.filter(tool => !(tool.function.name in MCP_EXCLUDED))
@@ -1145,34 +1444,11 @@ export async function executeTool(name: string, args: Record<string, any>, ctx:
1145
1444
  return txt ? `「${art.title || '无标题'}」内容:\n${txt.slice(0, maxChars)}` : `(「${art.title}」没有可读的链接和内容)`
1146
1445
  }
1147
1446
  if (!isSafeUrl(art.link)) return `(链接不安全, 拒绝抓取: ${art.link.slice(0, 60)})`
1148
- // 微信内置浏览器 UA + Referer 绕 WAF (与 biz_daily.py 同策略)
1149
- let html = ''
1150
- let status = 0
1151
- for (let attempt = 0; attempt < 2; attempt++) {
1152
- try {
1153
- const res = await fetch(art.link, {
1154
- headers: {
1155
- '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',
1156
- 'Referer': 'https://mp.weixin.qq.com/',
1157
- 'Accept': 'text/html,application/xhtml+xml',
1158
- 'Accept-Language': 'zh-CN,zh;q=0.9',
1159
- },
1160
- signal: AbortSignal.timeout(20_000),
1161
- })
1162
- status = res.status
1163
- html = (await res.text()).slice(0, 500_000)
1164
- // 正文验证: 必须含 js_content (否则是验证页, 重试)
1165
- if (res.ok && html.includes('js_content')) break
1166
- } catch { /* 超时/网络错误 → 重试 */ }
1167
- }
1447
+ // 微信内置浏览器 UA + Referer 绕 WAF(与 biz_daily.py 同策略);
1448
+ // 这段与 fetch_article 是同一份实现——两处各写一份必然会漂
1449
+ const { status, html } = await fetchArticleHtml(art.link)
1168
1450
  if (!html) return `(抓取失败 HTTP ${status})`
1169
- // 路径1: 正常页面 js_content; 路径2: WAF 挑战页 content_noencode 兜底
1170
- let text = html.includes('js_content') ? extractText(html) : ''
1171
- if (!text || (text.match(/[\u4e00-\u9fff]/g) || []).length < 50) {
1172
- const decoded = extractFromChallengePage(html)
1173
- if (decoded) text = stripTags(decoded)
1174
- }
1175
- text = text.slice(0, maxChars)
1451
+ let text = articleTextFromHtml(html, maxChars)
1176
1452
  if (html.includes('已被发布者删除')) return `(「${art.title}」已被发布者删除)`
1177
1453
  if (!text.trim() || (text.match(/[\u4e00-\u9fff]/g) || []).length < 20) {
1178
1454
  return `(「${art.title}」正文提取失败, 可能是纯图片文章或已被删除)`
@@ -1180,32 +1456,80 @@ export async function executeTool(name: string, args: Record<string, any>, ctx:
1180
1456
  return `「${art.title || '无标题'}」(${art.source_name || '未知来源'}) 正文:\n${text}`
1181
1457
  }
1182
1458
  case 'get_daily_report': {
1183
- const dates = existsSync(BIZ_DAILY_DIR)
1184
- ? readdirSync(BIZ_DAILY_DIR).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse()
1459
+ const dates = existsSync(bizDailyDir())
1460
+ ? readdirSync(bizDailyDir()).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse()
1185
1461
  : []
1186
1462
  if (!dates.length) return '(还没有日报数据, 先运行 biz_daily 生成)'
1187
- const date = String(args.date || dates[0])
1188
- const articlesFile = join(BIZ_DAILY_DIR, date, '.articles.json')
1189
- if (!existsSync(articlesFile)) return `(${date} 没有日报。可用日期: ${dates.slice(0, 5).join(', ')})`
1190
- const raw = JSON.parse(readFileSync(articlesFile, 'utf8'))
1191
- const all = Array.isArray(raw) ? raw : (raw.articles || Object.values(raw))
1192
- let list = all as any[]
1463
+ const readArticles = (date: string): any[] | null => {
1464
+ const file = join(bizDailyDir(), date, '.articles.json')
1465
+ if (!existsSync(file)) return null
1466
+ try {
1467
+ const raw = JSON.parse(readFileSync(file, 'utf8'))
1468
+ return Array.isArray(raw) ? raw : (raw.articles || Object.values(raw))
1469
+ } catch { return null }
1470
+ }
1471
+ const fmt = (a: any) => {
1472
+ const summary = (a.summary || '').replace(/\s+/g, ' ').slice(0, 80)
1473
+ const where = a.date ? `${a.date} ` : ''
1474
+ return `· ${where}[${a.topic}] ${a.title} (${a.source})${summary ? `\n ${summary}` : ''}`
1475
+ }
1476
+ const limit = boundedToolInteger(args.limit, 15, 30)
1477
+
1478
+ // 不给 date 但给了关键词 → 跨日期搜(MCP 那边原本叫 search_articles,两个工具搜的
1479
+ // 是同一批文章、只是范围不同,模型无从判断该用哪个,所以合并成一个)。
1480
+ const wantDate = args.date ? safeDate(args.date) : null
1481
+ if (args.date && !wantDate) return `(日期格式不对: ${args.date},要 YYYY-MM-DD)`
1482
+ if (!wantDate && (args.keyword || args.topic)) {
1483
+ const days = boundedToolInteger(args.days, 7, 60)
1484
+ const window = dates.slice(0, days)
1485
+ const hits: any[] = []
1486
+ let scanned = 0
1487
+ for (const d of window) {
1488
+ const arts = readArticles(d)
1489
+ if (!arts) continue
1490
+ scanned += arts.length
1491
+ for (const a of arts) {
1492
+ if (args.topic && a.topic !== args.topic) continue
1493
+ if (args.keyword) {
1494
+ const kw = String(args.keyword).toLowerCase()
1495
+ const hay = `${a.title || ''} ${a.summary || ''} ${a.source || ''}`.toLowerCase()
1496
+ if (!hay.includes(kw)) continue
1497
+ }
1498
+ hits.push({ ...a, date: d })
1499
+ }
1500
+ }
1501
+ if (!hits.length) {
1502
+ return `(最近 ${window.length} 天的日报里(共 ${scanned} 篇)没有匹配`
1503
+ + `${args.keyword ? `「${args.keyword}」` : `主题 ${args.topic}`} 的文章)`
1504
+ }
1505
+ return `最近 ${window.length} 天里匹配 ${hits.length} 篇(共扫 ${scanned} 篇):\n`
1506
+ + hits.slice(0, limit).map(fmt).join('\n')
1507
+ }
1508
+
1509
+ const date = wantDate || dates[0]
1510
+ const all = readArticles(date)
1511
+ if (!all) return `(${date} 没有日报。可用日期: ${dates.slice(0, 5).join(', ')})`
1512
+ // `full`:给的是当天那份人读的日报正文(README.md),不是条目清单。
1513
+ // 不加这个参数,模型想"把那天的日报念一段"就只能拿到标题列表。
1514
+ if (args.full) {
1515
+ const readme = join(bizDailyDir(), date, 'README.md')
1516
+ if (!existsSync(readme)) return `(${date} 没有日报正文文件,只有条目)`
1517
+ const maxChars = boundedToolInteger(args.max_chars, 3000, 6000)
1518
+ return `${date} 日报正文(${all.length} 篇):\n${readFileSync(readme, 'utf8').slice(0, maxChars)}`
1519
+ }
1520
+ let list = all
1193
1521
  if (args.topic) list = list.filter(a => a.topic === args.topic)
1194
1522
  if (args.keyword) {
1195
1523
  const kw = String(args.keyword).toLowerCase()
1196
1524
  list = list.filter(a =>
1197
1525
  (a.title || '').toLowerCase().includes(kw) || (a.summary || '').toLowerCase().includes(kw))
1198
1526
  }
1199
- const limit = boundedToolInteger(args.limit, 15, 30)
1200
1527
  if (!list.length) return `(${date} 日报共 ${all.length} 篇, 过滤后无匹配)`
1201
1528
  const byTopic: Record<string, number> = {}
1202
1529
  for (const a of all) byTopic[a.topic] = (byTopic[a.topic] || 0) + 1
1203
1530
  const topicStat = Object.entries(byTopic).map(([t, n]) => `${t}${n}篇`).join(' ')
1204
1531
  return `${date} 日报共 ${all.length} 篇 (${topicStat}), 匹配 ${list.length} 篇:\n` +
1205
- list.slice(0, limit).map(a => {
1206
- const summary = (a.summary || '').replace(/\s+/g, ' ').slice(0, 80)
1207
- return `· [${a.topic}] ${a.title} (${a.source})${summary ? `\n ${summary}` : ''}`
1208
- }).join('\n')
1532
+ list.slice(0, limit).map(fmt).join('\n')
1209
1533
  }
1210
1534
  case 'get_sns': {
1211
1535
  await ensureDb()
@@ -1257,6 +1581,56 @@ export async function executeTool(name: string, args: Record<string, any>, ctx:
1257
1581
  r.data.books.slice(0, 15).map((b: any) =>
1258
1582
  `· ${titleOf(b)} (${authorOf(b)}) — ${b.noteCount || 0} 条笔记`).join('\n')
1259
1583
  }
1584
+ if (mode === 'profile') {
1585
+ const r = await wereadService.profile()
1586
+ if (!r.ok || !r.data) return `(读不到阅读画像: ${r.error || ''})`
1587
+ const p = r.data
1588
+ return `阅读画像: ${p.name || '(未给昵称)'}\n`
1589
+ + `累计阅读 ${Math.round((p.totalReadTime || 0) / 60)} 分钟、${p.totalReadDays || 0} 天\n`
1590
+ + `书架 ${p.totalBooks || 0} 本,读完 ${p.totalFinished || 0} 本`
1591
+ }
1592
+ if (mode === 'stats') {
1593
+ const r = await wereadService.readData('monthly')
1594
+ if (!r.ok || !r.data) return `(读不到阅读统计: ${r.error || ''})`
1595
+ const d = r.data
1596
+ const top = (d.readLongest || []).slice(0, 5).map((x: any) =>
1597
+ `· ${x?.book?.title || x?.albumInfo?.title || '(未知)'} ${Math.round((x?.readTime || 0) / 60)} 分钟`)
1598
+ return `近期阅读 ${Math.round((d.totalReadTime || 0) / 60)} 分钟、读了 ${d.readDays || 0} 天`
1599
+ + `,日均 ${Math.round((d.dayAverageReadTime || 0) / 60)} 分钟`
1600
+ + (top.length ? `\n读得最久的:\n${top.join('\n')}` : '')
1601
+ }
1602
+ if (mode === 'book') {
1603
+ const bookId = String(args.book_id || '').trim()
1604
+ if (!bookId) return '(book 模式需要 book_id —— 先用 search 模式按书名找到它)'
1605
+ const info = await wereadService.bookInfo(bookId)
1606
+ if (!info.ok || !info.data) return `(读不到这本书: ${info.error || ''})`
1607
+ const b: any = info.data
1608
+ const prog = await wereadService.getProgress(bookId)
1609
+ const pct = prog.ok && prog.data
1610
+ ? `\n我的进度: ${prog.data.progress}%${prog.data.chapterTitle ? `(${prog.data.chapterTitle})` : ''}`
1611
+ : ''
1612
+ return `《${b.title}》${b.author || ''}\n评分 ${b.rating ?? '未知'}(${b.ratingCount || 0} 人)`
1613
+ + `| ${b.category || '未分类'} | ${b.wordCount || '未知'}字${pct}`
1614
+ }
1615
+ if (mode === 'review') {
1616
+ const bookId = String(args.book_id || '').trim()
1617
+ if (!bookId) return '(review 模式需要 book_id —— 先用 search 模式按书名找到它)'
1618
+ const r = await wereadService.reviews(bookId, 5)
1619
+ if (!r.ok || !r.data?.reviews?.length) return `(这本书还没有热门书评: ${r.error || ''})`
1620
+ return `热门书评 ${r.data.reviews.length} 条:\n`
1621
+ + r.data.reviews.map((v: any) =>
1622
+ `· ${String(v.content || '').replace(/\s+/g, ' ').slice(0, 120)}`
1623
+ + `(${v.user?.name || '匿名'},${v.rating ? `评 ${v.rating} 分` : '未评分'},${v.likeCount || 0} 赞)`).join('\n')
1624
+ }
1625
+ if (mode === 'discover') {
1626
+ const bookId = String(args.book_id || '').trim()
1627
+ const r = bookId ? await wereadService.similar(bookId, 6) : await wereadService.recommend(8)
1628
+ if (!r.ok || !r.data?.books?.length) return `(读不到推荐: ${r.error || ''})`
1629
+ return (bookId ? '与这本书相似的' : '为你推荐的') + `书 ${r.data.books.length} 本:\n`
1630
+ + r.data.books.map((b: any) =>
1631
+ `· 《${b.title}》${b.author || ''}${b.rating ? ` 评分 ${b.rating}` : ''}`
1632
+ + (b.intro ? `\n ${String(b.intro).replace(/\s+/g, ' ').slice(0, 80)}` : '')).join('\n')
1633
+ }
1260
1634
  if (mode === 'search') {
1261
1635
  const kw = String(args.keyword || '')
1262
1636
  if (!kw) return '(search 模式需要 keyword)'
@@ -1294,9 +1668,23 @@ export async function executeTool(name: string, args: Record<string, any>, ctx:
1294
1668
  }
1295
1669
  return `(没有${status === 'done' ? '已完成' : '待办'}任务)`
1296
1670
  }
1671
+ // 分组:`todos remind` 其实就是同一个文件按紧急度重排,所以不另开一个工具,
1672
+ // 加个参数就够(多一个近义工具只会让模型在两者之间瞎挑)
1673
+ if (String(args.group_by || '') === 'urgency') {
1674
+ const order: Record<string, number> = { 高: 0, 中: 1, 低: 2 }
1675
+ const sorted = [...todos].sort((a: any, b: any) =>
1676
+ (order[a.urgency] ?? 3) - (order[b.urgency] ?? 3))
1677
+ const groups: Record<string, any[]> = {}
1678
+ for (const t of sorted) (groups[t.urgency || '未标'] = groups[t.urgency || '未标'] || []).push(t)
1679
+ return `${status === 'done' ? '已完成' : '待办'} ${todos.length} 项(按紧急度分组):\n` +
1680
+ Object.entries(groups).map(([u, list]) =>
1681
+ `【${u}】${list.length} 项\n` + list.slice(0, 10).map((t: any) =>
1682
+ `· ${t.task || t.content || t.text || t.title}${t.deadline && t.deadline !== '未提及' ? ` (截止 ${t.deadline})` : ''}${idTag(t)}`).join('\n')
1683
+ ).join('\n')
1684
+ }
1297
1685
  return `${status === 'done' ? '已完成' : '待办'} ${todos.length} 项:\n` +
1298
1686
  todos.slice(0, 15).map((t: any) =>
1299
- `· [${t.urgency || '中'}] ${t.task || t.content || t.text || t.title}${t.deadline && t.deadline !== '未提及' ? ` (截止 ${t.deadline})` : ''}`).join('\n')
1687
+ `· [${t.urgency || '中'}] ${t.task || t.content || t.text || t.title}${t.deadline && t.deadline !== '未提及' ? ` (截止 ${t.deadline})` : ''}${idTag(t)}`).join('\n')
1300
1688
  }
1301
1689
  case 'search_chats': {
1302
1690
  // 跨会话检索:本机 message_fts 索引 + 判断模型挑查询词(**只发候选词与问题,不发聊天内容**)
@@ -1530,7 +1918,7 @@ export async function executeTool(name: string, args: Record<string, any>, ctx:
1530
1918
  case 'search_knowledge': {
1531
1919
  const kw = String(args.keyword || '')
1532
1920
  if (!kw) return '(缺少 keyword 参数)'
1533
- const liveDirs = VAULT_WIKI_DIRS.filter(dir => existsSync(dir))
1921
+ const liveDirs = vaultDirs().filter(dir => existsSync(dir))
1534
1922
  if (!liveDirs.length) return '(知识库尚未生成, 先运行 weflow-cli wiki compile)'
1535
1923
  // **两个目录一起搜**(文章知识库 + 聊天知识库)。分开是用户要求的,但"分开"
1536
1924
  // 不该变成"搜不到"——只读一个的话,另一半会静默缺席。
@@ -1595,7 +1983,40 @@ export async function executeTool(name: string, args: Record<string, any>, ctx:
1595
1983
  await ensureDb()
1596
1984
  const sessions = await chatService.listSessions(undefined, 1000)
1597
1985
  const fav = await chatService.getFavorites({ limit: 1 })
1598
- return `会话数: ${sessions.length}\n收藏总数: ${fav.success ? fav.total : '未知'}`
1986
+ const lines = [`会话数: ${sessions.length}`, `收藏总数: ${fav.success ? fav.total : '未知'}`]
1987
+ // 知识库那一半:MCP 的手写 get_stats 一直有、助手这边缺。同一个工具名在两个界面
1988
+ // 报的东西不一样,是最容易让人误判成数据丢了的那种不一致。
1989
+ const dates = existsSync(bizDailyDir())
1990
+ ? readdirSync(bizDailyDir()).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort()
1991
+ : []
1992
+ let articleCount = 0
1993
+ const topics: Record<string, number> = {}
1994
+ for (const d of dates) {
1995
+ const file = join(bizDailyDir(), d, '.articles.json')
1996
+ if (!existsSync(file)) continue
1997
+ try {
1998
+ const raw = JSON.parse(readFileSync(file, 'utf8'))
1999
+ const arts: any[] = Array.isArray(raw) ? raw : (raw.articles || [])
2000
+ articleCount += arts.length
2001
+ for (const a of arts) topics[a.topic || '未知'] = (topics[a.topic || '未知'] || 0) + 1
2002
+ } catch { /* 某一天的文件坏了不该让整个统计失败 */ }
2003
+ }
2004
+ if (dates.length) {
2005
+ lines.push(`日报文章: ${articleCount} 篇,覆盖 ${dates.length} 天(${dates[0]} ~ ${dates[dates.length - 1]})`)
2006
+ const top = Object.entries(topics).sort((a, b) => b[1] - a[1]).slice(0, 6)
2007
+ if (top.length) lines.push(`主题分布: ${top.map(([t, n]) => `${t}${n}`).join('、')}`)
2008
+ } else {
2009
+ lines.push('日报文章: 还没有日报数据')
2010
+ }
2011
+ const conceptCounts: string[] = []
2012
+ for (const dir of vaultDirs()) {
2013
+ const idx = wikiIndexPath(dir)
2014
+ if (!existsSync(idx)) continue
2015
+ const m = readFileSync(idx, 'utf8').match(/共\s*(\d+)\s*个概念/)
2016
+ if (m) conceptCounts.push(`${dir.split(/[\\/]/).includes('Chat') ? '聊天线' : '文章线'} ${m[1]}`)
2017
+ }
2018
+ if (conceptCounts.length) lines.push(`概念页: ${conceptCounts.join('、')}`)
2019
+ return lines.join('\n')
1599
2020
  }
1600
2021
  case 'get_reading_stats': {
1601
2022
  const days = boundedToolInteger(args.days, 7, 90, 'days')
@@ -1630,6 +2051,264 @@ export async function executeTool(name: string, args: Record<string, any>, ctx:
1630
2051
  const added = ctx.memory.addFact(ctx.userId, content)
1631
2052
  return added ? '(已存入长期记忆)' : '(与已有记忆重复, 未保存)'
1632
2053
  }
2054
+ case 'list_skills': {
2055
+ const scan = scanSkills()
2056
+ const q = String(args.query || '').trim().toLowerCase()
2057
+ const hit = (s: { id: string; name: string; description: string; triggers: string[] }) =>
2058
+ !q || [s.id, s.name, s.description, ...s.triggers].some(x => String(x).toLowerCase().includes(q))
2059
+ const rows = scan.skills.filter(hit)
2060
+ if (!rows.length) {
2061
+ // 一个技能都没有时**说清是"没装"还是"目录配错了"**:后者会把用户引到错的地方找
2062
+ if (!scan.skills.length) {
2063
+ return `本机没有扫描到技能。技能根目录:${skillRoots().join('、')}`
2064
+ + (scan.missingRoots.length ? `(其中这些目录不存在:${scan.missingRoots.join('、')})` : '')
2065
+ }
2066
+ return `没有匹配「${args.query}」的技能(本机共有 ${scan.skills.length} 个)`
2067
+ }
2068
+ const disabled = disabledIds()
2069
+ const lines = rows.map(s => {
2070
+ const state = skillState(s, disabled)
2071
+ const tag = state === 'disabled' ? '[已禁用] ' : ''
2072
+ const ver = s.version ? ` v${s.version}` : ''
2073
+ return `· ${tag}${s.id}${ver}:${s.name} —— ${s.description || '(无描述)'}`
2074
+ })
2075
+ lines.push(`(共 ${rows.length} 个,正文用 read_skill 读)`)
2076
+ return lines.join('\n')
2077
+ }
2078
+ case 'read_skill': {
2079
+ const id = String(args.id || '').trim()
2080
+ if (!id) return '(参数错误: 缺少技能 id)'
2081
+ const body = readSkillBody(id)
2082
+ if (!body.ok) return body.text
2083
+ // 技能正文是**磁盘上的内容**,可能含 `</weflow-local-data>` —— 必须包起来再进上下文
2084
+ return frameLocalData(`skill.${id}`, body.text)
2085
+ }
2086
+ case 'list_contacts': {
2087
+ await ensureDb()
2088
+ const keyword = String(args.keyword || '').trim()
2089
+ const limit = boundedToolInteger(args.limit, 50, 200)
2090
+ const contacts = await chatService.listContacts(keyword || undefined, limit)
2091
+ if (!contacts.length) {
2092
+ return keyword
2093
+ ? `(通讯录里没有匹配「${keyword}」的名字)`
2094
+ : '(通讯录是空的或读不到——可以用 check 看本机数据源的状态)'
2095
+ }
2096
+ // **只给名字**:`username` 是 wxid,`avatarUrl` 里也可能带本机标识,
2097
+ // 本仓的纪律是本机标识不进模型上下文(同 speakerLabel 那边的处理)
2098
+ const names: string[] = []
2099
+ for (const c of contacts) {
2100
+ const n = String(c.remark || c.displayName || c.nickname || '').trim()
2101
+ if (n && !names.includes(n)) names.push(n)
2102
+ }
2103
+ if (!names.length) return '(通讯录读到了, 但都没有可显示的名字)'
2104
+ return `通讯录里 ${names.length} 个名字:\n` + names.slice(0, limit).map(n => `· ${n}`).join('\n')
2105
+ }
2106
+ case 'get_review': {
2107
+ const dir = reviewsDir()
2108
+ if (!existsSync(dir)) {
2109
+ // 说清"这个目录本来就还没生成",而不是让模型以为"今天没有回顾"
2110
+ return '(还没有学习回顾——它不是日报自动产的,要单独跑 `weflow-cli review`)'
2111
+ }
2112
+ const files = readdirSync(dir).filter(f => /^Daily-\d{4}-\d{2}-\d{2}\.md$/.test(f)).sort().reverse()
2113
+ if (!files.length) return '(学习回顾目录是空的)'
2114
+ const want = args.date ? safeDate(args.date) : null
2115
+ if (args.date && !want) return `(日期格式不对: ${args.date},要 YYYY-MM-DD)`
2116
+ const pick = want ? files.find(f => f.includes(want)) : files[0]
2117
+ if (!pick) {
2118
+ return `(${want} 没有学习回顾。最近几份: ${files.slice(0, 3).map(f => f.slice(6, 16)).join(', ')})`
2119
+ }
2120
+ const maxChars = boundedToolInteger(args.max_chars, 3000, 6000)
2121
+ const body = readFileSync(join(dir, pick), 'utf8')
2122
+ const date = pick.slice(6, 16)
2123
+ return `${date} 学习回顾:\n${body.slice(0, maxChars)}`
2124
+ }
2125
+ case 'get_concepts': {
2126
+ const limit = boundedToolInteger(args.limit, 40, 120)
2127
+ const out: string[] = []
2128
+ for (const dir of vaultDirs()) {
2129
+ const line = dir.split(/[\\/]/).includes('Chat') ? '聊天线' : '文章线'
2130
+ const index = wikiIndexPath(dir)
2131
+ if (!existsSync(index)) { out.push(`【${line}】还没有总览索引(还没编译过这一线的知识库)`); continue }
2132
+ const text = readFileSync(index, 'utf8')
2133
+ const rows = text.split(/\r?\n/).filter(l => l.trim()).slice(0, limit)
2134
+ out.push(`【${line}】${rows.length} 行:\n${rows.join('\n')}`)
2135
+ }
2136
+ return out.join('\n\n')
2137
+ }
2138
+ case 'format_article': {
2139
+ const content = String(args.content || '')
2140
+ if (!content.trim()) return '(参数错误: content 不能为空)'
2141
+ const { formatWeChatArticle } = await import('./wechat-formatter.js')
2142
+ const theme = String(args.theme || 'default')
2143
+ const html = formatWeChatArticle(content, { theme: theme as any })
2144
+ const preview = html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').slice(0, 200)
2145
+ return `主题: ${theme}\n总长度: ${html.length} 字符\n正文预览: ${preview}…\n---\n${html}`
2146
+ }
2147
+ case 'list_themes': {
2148
+ const { listThemes } = await import('./wechat-formatter.js')
2149
+ const themes = listThemes()
2150
+ if (!themes.length) return '(没有可用主题)'
2151
+ return `可用主题 ${themes.length} 个:\n`
2152
+ + themes.map(t => `· ${t.id} — ${t.name}: ${t.description}`).join('\n')
2153
+ }
2154
+ case 'lint_wiki': {
2155
+ // 两个知识库都要查(本仓的分线纪律:只查一个,另一半的断链永远看不见)
2156
+ const parts: string[] = []
2157
+ let totalBad = 0
2158
+ for (const dir of vaultDirs()) {
2159
+ const line = dir.split(/[\\/]/).includes('Chat') ? '聊天线' : '文章线'
2160
+ if (!existsSync(dir)) { parts.push(`【${line}】目录不存在,跳过`); continue }
2161
+ // 超时给 60 秒:本机最大的一条线(3,623 页)实测 **3.4 秒**(脚本里的 O(n²) 修掉之后,
2162
+ // 见 wiki_lint.py 那条注释),17 倍余量够用。默认的 30 秒曾经也超——那是因为脚本本身慢,
2163
+ // 不是超时给得小。
2164
+ const r = await runPythonJson<any>('wiki_lint.py', ['--dir', dir, '--json'], { timeoutMs: 60_000 })
2165
+ if (!r.ok || !r.data) {
2166
+ // 失败要说出来:静默跳过会让"体检通过"变成一句假话
2167
+ parts.push(`【${line}】体检失败: ${r.error || '读不到结果'}`)
2168
+ totalBad += 1
2169
+ continue
2170
+ }
2171
+ const d = r.data
2172
+ const broken = (d.broken || []).length
2173
+ const orphans = (d.orphans || []).length
2174
+ const empty = (d.empty || []).length
2175
+ const dup = Object.keys(d.duplicateTitles || {}).length
2176
+ totalBad += broken + orphans + empty + dup
2177
+ parts.push(`【${line}】${d.pages || 0} 张页:断链 ${broken}、孤儿 ${orphans}、空页 ${empty}、同名 ${dup} 组`)
2178
+ }
2179
+ return (totalBad ? '' : '两个知识库都没有断链/孤儿/空页/同名。\n') + parts.join('\n')
2180
+ }
2181
+ case 'check_skills': {
2182
+ const scan = scanSkills()
2183
+ const disabled = disabledIds()
2184
+ const lines: string[] = [`技能目录: ${skillRoots().join('、')}`, `扫描到 ${scan.skills.length} 个技能`]
2185
+ for (const dir of scan.missingRoots) lines.push(`· 目录不存在: ${dir}`)
2186
+ if (scan.unreadable.length) {
2187
+ lines.push(`读不出来的 ${scan.unreadable.length} 个(frontmatter 缺失或不闭合):`)
2188
+ for (const u of scan.unreadable) lines.push(`· ${u.file} —— ${u.reason}`)
2189
+ }
2190
+ if (scan.conflicts.length) {
2191
+ lines.push(`跨目录同名的 ${scan.conflicts.length} 个(靠前的目录胜出): ${scan.conflicts.map(c => c.id).join('、')}`)
2192
+ }
2193
+ const warned = scan.skills.filter(s => s.notes.length)
2194
+ if (warned.length) {
2195
+ lines.push(`有提醒的 ${warned.length} 个:`)
2196
+ for (const w of warned) for (const n of w.notes) lines.push(`· ${w.id}: ${n}`)
2197
+ }
2198
+ const off = scan.skills.filter(s => skillState(s, disabled) === 'disabled').map(s => s.id)
2199
+ if (off.length) lines.push(`已禁用: ${off.join('、')}`)
2200
+ if (!scan.unreadable.length && !scan.conflicts.length && !warned.length) {
2201
+ lines.push('没有读不出来或撞名的技能。')
2202
+ }
2203
+ return lines.join('\n')
2204
+ }
2205
+ case 'fetch_article': {
2206
+ const url = String(args.url || '').trim()
2207
+ // **严格白名单,不是 denylist**:只认 https 的 mp.weixin.qq.com。
2208
+ // 这条比 read_favorite 那条严,因为那里的链接是用户自己收藏的,这里的 URL 是别人给的。
2209
+ if (!isAllowedWeChatArticleUrl(url)) {
2210
+ return `(只接受 https 的 mp.weixin.qq.com 文章链接,拒绝抓取: ${url.slice(0, 60)})`
2211
+ }
2212
+ const maxChars = boundedToolInteger(args.max_chars, 3000, 6000)
2213
+ // 逐跳复检:一次 302 就能把入口白名单绕过去
2214
+ const { status, html } = await fetchArticleHtml(url,
2215
+ { maxRedirects: 3, allowHop: isAllowedWeChatArticleUrl })
2216
+ if (!html) return `(抓取失败 HTTP ${status} —— 链接可能不是公众号文章,或者跳转到了别处)`
2217
+ if (html.includes('已被发布者删除')) return '(这篇文章已被发布者删除)'
2218
+ const body = articleTextFromHtml(html, maxChars)
2219
+ if (!body.trim() || (body.match(/[一-鿿]/g) || []).length < 20) {
2220
+ return '(抓到了页面但取不出正文——可能是验证页,或者这篇文章要登录/已失效)'
2221
+ }
2222
+ const title = html.match(/<meta[^>]*property="og:title"[^>]*content="([^"]+)"/i)?.[1]
2223
+ || html.match(/<title>([^<]+)<\/title>/i)?.[1]?.trim() || ''
2224
+ const author = html.match(/var\s+nickname\s*=\s*"([^"]+)"/i)?.[1]
2225
+ || html.match(/<meta[^>]*property="og:article:author"[^>]*content="([^"]+)"/i)?.[1] || ''
2226
+ // 配图只报数量与前几个链接:正文是纯文本,但图往往就是这篇文章的要点
2227
+ const imgs = [...html.matchAll(/data-src="(https?:\/\/[^"]+)"/g)].map(m => m[1]).slice(0, 5)
2228
+ const head = [title && `标题: ${title}`, author && `公众号: ${author}`].filter(Boolean).join(' | ')
2229
+ const tail = imgs.length ? `\n\n配图 ${imgs.length} 张(前几张):\n${imgs.map(u => `· ${u}`).join('\n')}` : ''
2230
+ return (head ? `${head}\n---\n` : '') + body + tail
2231
+ }
2232
+ case 'search_public': {
2233
+ const keyword = String(args.keyword || '').trim()
2234
+ if (!keyword) return '(参数错误: 缺少 keyword)'
2235
+ const limit = boundedToolInteger(args.limit, 8, 20)
2236
+ let html = ''
2237
+ try {
2238
+ const resp = await fetch(
2239
+ `https://weixin.sogou.com/weixin?type=2&query=${encodeURIComponent(keyword)}`,
2240
+ {
2241
+ headers: {
2242
+ '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',
2243
+ 'Referer': 'https://weixin.sogou.com/',
2244
+ 'Accept': 'text/html',
2245
+ 'Accept-Language': 'zh-CN,zh;q=0.9',
2246
+ },
2247
+ signal: AbortSignal.timeout(20_000),
2248
+ },
2249
+ )
2250
+ html = await readTextLimited(resp, 2 * 1024 * 1024)
2251
+ } catch {
2252
+ return '(搜索失败——检查网络,或者稍后再试(第三方搜索页会限流))'
2253
+ }
2254
+ const grab = (re: RegExp, clean: (s: string) => string): string[] => {
2255
+ const out: string[] = []
2256
+ for (const m of html.matchAll(re)) {
2257
+ const v = clean(m[1])
2258
+ if (v) out.push(v)
2259
+ }
2260
+ return out
2261
+ }
2262
+ const titles = grab(/<h3[^>]*>\s*<a[^>]*>(.*?)<\/a>\s*<\/h3>/gs,
2263
+ s => s.replace(/<em[^>]*>/g, '').replace(/<\/em>/g, '').replace(/<!--[^>]*-->/g, '').trim())
2264
+ const accounts = grab(/<span[^>]*class="[^"]*all-time-y2[^"]*"[^>]*>(.*?)<\/span>/gs,
2265
+ s => s.replace(/<[^>]+>/g, '').trim())
2266
+ const descs = grab(/<p[^>]*class="[^"]*txt-info[^"]*"[^>]*>(.*?)<\/p>/gs,
2267
+ s => s.replace(/<[^>]+>/g, '').replace(/&hellip;/g, '…').replace(/&rarr;/g, '→').replace(/&mdash;/g, '—').trim())
2268
+ .filter(d => d.length > 10)
2269
+ const count = Math.max(titles.length, accounts.length, descs.length)
2270
+ if (!count) {
2271
+ return `(没搜到与「${keyword}」相关的公开文章。换个说法、或加上公众号名试试;`
2272
+ + '找到链接后可以用 fetch_article 抓全文)'
2273
+ }
2274
+ const rows: string[] = []
2275
+ for (let i = 0; i < Math.min(count, limit); i++) {
2276
+ rows.push(`${i + 1}. ${titles[i] || '(未知标题)'}\n 公众号: ${accounts[i] || '(未知)'}`
2277
+ + (descs[i] ? `\n ${descs[i]}` : ''))
2278
+ }
2279
+ return `公开文章里搜「${keyword}」,命中 ${Math.min(count, limit)} 条:\n`
2280
+ + rows.join('\n')
2281
+ + '\n(这是第三方搜索页的标题与摘要,不是全文;要正文用 fetch_article 抓)'
2282
+ }
2283
+ case 'set_todo_status': {
2284
+ const status = String(args.status || '')
2285
+ if (status !== 'done' && status !== 'pending') return '(参数错误: status 只能是 done(已完成)或 pending(取消完成))'
2286
+ const id = String(args.id || '').trim()
2287
+ const task = String(args.task || '').trim()
2288
+ if (!id && !task) return '(参数错误: 要给 id 或 task 指到哪一条待办)'
2289
+ let target = id
2290
+ if (!target) {
2291
+ // 按任务文字找:**只认唯一**。命中 0 条或 ≥2 条都拒绝——
2292
+ // 猜错条目比拒绝更糟(用户会以为自己的待办被改了,而改的是另一条)
2293
+ const listed = await runPythonJson<any>('extract_todos.py', ['list', '--json'])
2294
+ if (!listed.ok) return fail('待办读取失败', listed)
2295
+ const items: any[] = Array.isArray(listed.data) ? listed.data : (listed.data?.items || [])
2296
+ const norm = (s: unknown) => String(s || '').replace(/\s+/g, '')
2297
+ const hit = items.filter(t => norm(t.task).includes(norm(task)))
2298
+ if (!hit.length) return `(没有任务文字包含「${task}」的待办)`
2299
+ if (hit.length > 1) {
2300
+ return `(「${task}」匹配到 ${hit.length} 条待办,不能确定是哪一条,请用 id 指定:\n`
2301
+ + hit.slice(0, 5).map(t => `· ${t.task}`).join('\n') + ')'
2302
+ }
2303
+ if (!hit[0].id) return '(这条待办没有 id——本机的待办文件格式比预期旧,请在电脑上用 `todos done` 处理)'
2304
+ target = String(hit[0].id)
2305
+ }
2306
+ const r = await runPythonJson<any>('extract_todos.py',
2307
+ [status === 'done' ? 'done' : 'undone', target, '--json'])
2308
+ if (!r.ok) return fail(status === 'done' ? '标记完成失败' : '取消完成失败', r)
2309
+ // 括号开头 = "没有产出内容"(本仓的约定,见 producedContent):这条只报告动作做成了
2310
+ return status === 'done' ? `(已标记完成: ${target})` : `(已取消完成标记: ${target})`
2311
+ }
1633
2312
  default:
1634
2313
  return `(未知工具: ${name})`
1635
2314
  }