weflow-cli 1.6.4 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +1966 -0
  3. package/OPERATIONS.md +808 -0
  4. package/README.en.md +93 -7
  5. package/README.md +31 -6
  6. package/bin/weflow-cli.ts +1245 -26
  7. package/dist/bin/weflow-cli.js +1349 -72
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.js +39 -13
  10. package/dist/mcp-server/index.js.map +1 -1
  11. package/dist/src/core/appMsgFormat.d.ts +37 -0
  12. package/dist/src/core/appMsgFormat.d.ts.map +1 -0
  13. package/dist/src/core/appMsgFormat.js +106 -0
  14. package/dist/src/core/appMsgFormat.js.map +1 -0
  15. package/dist/src/core/ntCore.d.ts +43 -0
  16. package/dist/src/core/ntCore.d.ts.map +1 -1
  17. package/dist/src/core/ntCore.js +31 -0
  18. package/dist/src/core/ntCore.js.map +1 -1
  19. package/dist/src/core/sqlcipherCore.d.ts +0 -12
  20. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  21. package/dist/src/core/sqlcipherCore.js +5 -64
  22. package/dist/src/core/sqlcipherCore.js.map +1 -1
  23. package/dist/src/core/wcdbCore.d.ts +1 -1
  24. package/dist/src/core/wcdbCore.d.ts.map +1 -1
  25. package/dist/src/core/wcdbCore.js +5 -1
  26. package/dist/src/core/wcdbCore.js.map +1 -1
  27. package/dist/src/panel/client.d.ts +25 -0
  28. package/dist/src/panel/client.d.ts.map +1 -0
  29. package/dist/src/panel/client.js +34 -0
  30. package/dist/src/panel/client.js.map +1 -0
  31. package/dist/src/panel/endpoint.d.ts +45 -0
  32. package/dist/src/panel/endpoint.d.ts.map +1 -0
  33. package/dist/src/panel/endpoint.js +132 -0
  34. package/dist/src/panel/endpoint.js.map +1 -0
  35. package/dist/src/panel/launch.d.ts +50 -0
  36. package/dist/src/panel/launch.d.ts.map +1 -0
  37. package/dist/src/panel/launch.js +74 -0
  38. package/dist/src/panel/launch.js.map +1 -0
  39. package/dist/src/panel/server.d.ts +23 -0
  40. package/dist/src/panel/server.d.ts.map +1 -0
  41. package/dist/src/panel/server.js +413 -0
  42. package/dist/src/panel/server.js.map +1 -0
  43. package/dist/src/panel/userId.d.ts +37 -0
  44. package/dist/src/panel/userId.d.ts.map +1 -0
  45. package/dist/src/panel/userId.js +56 -0
  46. package/dist/src/panel/userId.js.map +1 -0
  47. package/dist/src/services/assistantDaemon.d.ts +20 -3
  48. package/dist/src/services/assistantDaemon.d.ts.map +1 -1
  49. package/dist/src/services/assistantDaemon.js +46 -9
  50. package/dist/src/services/assistantDaemon.js.map +1 -1
  51. package/dist/src/services/assistantEval.d.ts +141 -0
  52. package/dist/src/services/assistantEval.d.ts.map +1 -0
  53. package/dist/src/services/assistantEval.js +497 -0
  54. package/dist/src/services/assistantEval.js.map +1 -0
  55. package/dist/src/services/assistantMemory.d.ts +106 -4
  56. package/dist/src/services/assistantMemory.d.ts.map +1 -1
  57. package/dist/src/services/assistantMemory.js +396 -61
  58. package/dist/src/services/assistantMemory.js.map +1 -1
  59. package/dist/src/services/assistantPrivacy.d.ts +15 -2
  60. package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
  61. package/dist/src/services/assistantPrivacy.js +18 -4
  62. package/dist/src/services/assistantPrivacy.js.map +1 -1
  63. package/dist/src/services/assistantRouter.d.ts +41 -0
  64. package/dist/src/services/assistantRouter.d.ts.map +1 -0
  65. package/dist/src/services/assistantRouter.js +151 -0
  66. package/dist/src/services/assistantRouter.js.map +1 -0
  67. package/dist/src/services/assistantService.d.ts +209 -3
  68. package/dist/src/services/assistantService.d.ts.map +1 -1
  69. package/dist/src/services/assistantService.js +590 -75
  70. package/dist/src/services/assistantService.js.map +1 -1
  71. package/dist/src/services/assistantTools.d.ts +205 -1
  72. package/dist/src/services/assistantTools.d.ts.map +1 -1
  73. package/dist/src/services/assistantTools.js +1089 -58
  74. package/dist/src/services/assistantTools.js.map +1 -1
  75. package/dist/src/services/assistantTrace.d.ts +47 -0
  76. package/dist/src/services/assistantTrace.d.ts.map +1 -0
  77. package/dist/src/services/assistantTrace.js +150 -0
  78. package/dist/src/services/assistantTrace.js.map +1 -0
  79. package/dist/src/services/chatService.d.ts +13 -0
  80. package/dist/src/services/chatService.d.ts.map +1 -1
  81. package/dist/src/services/chatService.js +23 -0
  82. package/dist/src/services/chatService.js.map +1 -1
  83. package/dist/src/services/configService.d.ts +9 -0
  84. package/dist/src/services/configService.d.ts.map +1 -1
  85. package/dist/src/services/configService.js +15 -8
  86. package/dist/src/services/configService.js.map +1 -1
  87. package/dist/src/services/messageQuery.d.ts +35 -0
  88. package/dist/src/services/messageQuery.d.ts.map +1 -1
  89. package/dist/src/services/messageQuery.js +61 -0
  90. package/dist/src/services/messageQuery.js.map +1 -1
  91. package/dist/src/services/pythonBridge.d.ts +41 -0
  92. package/dist/src/services/pythonBridge.d.ts.map +1 -0
  93. package/dist/src/services/pythonBridge.js +119 -0
  94. package/dist/src/services/pythonBridge.js.map +1 -0
  95. package/dist/src/services/syncService.d.ts +127 -0
  96. package/dist/src/services/syncService.d.ts.map +1 -0
  97. package/dist/src/services/syncService.js +263 -0
  98. package/dist/src/services/syncService.js.map +1 -0
  99. package/dist/src/services/syncState.d.ts +113 -0
  100. package/dist/src/services/syncState.d.ts.map +1 -0
  101. package/dist/src/services/syncState.js +214 -0
  102. package/dist/src/services/syncState.js.map +1 -0
  103. package/dist/src/services/wechatMessageService.d.ts +25 -0
  104. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  105. package/dist/src/services/wechatMessageService.js +69 -7
  106. package/dist/src/services/wechatMessageService.js.map +1 -1
  107. package/dist/src/utils/atomicWrite.d.ts +4 -0
  108. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  109. package/dist/src/utils/atomicWrite.js +60 -0
  110. package/dist/src/utils/atomicWrite.js.map +1 -0
  111. package/dist/src/utils/dateRange.d.ts +15 -0
  112. package/dist/src/utils/dateRange.d.ts.map +1 -1
  113. package/dist/src/utils/dateRange.js +47 -0
  114. package/dist/src/utils/dateRange.js.map +1 -1
  115. package/dist/src/utils/text.d.ts +18 -0
  116. package/dist/src/utils/text.d.ts.map +1 -0
  117. package/dist/src/utils/text.js +20 -0
  118. package/dist/src/utils/text.js.map +1 -0
  119. package/docs/AI_INTERFACE.md +2 -0
  120. package/docs/DECISIONS.md +1484 -20
  121. package/docs/EXTENDING.md +187 -0
  122. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +338 -0
  123. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  124. package/docs/MCP.md +23 -4
  125. package/docs/PROJECT_STATE.md +299 -96
  126. package/docs/SETUP.md +22 -0
  127. package/docs/SYNC_CONTRACT.md +180 -0
  128. package/mcp-server/index.ts +681 -657
  129. package/package.json +84 -83
  130. package/resources/panel/ball-position.cjs +153 -0
  131. package/resources/panel/index.html +46 -0
  132. package/resources/panel/main.cjs +447 -0
  133. package/resources/panel/mascot-focus.png +0 -0
  134. package/resources/panel/mascot-happy.png +0 -0
  135. package/resources/panel/mascot-sorry.png +0 -0
  136. package/resources/panel/mascot-tired.png +0 -0
  137. package/resources/panel/mascot.png +0 -0
  138. package/resources/panel/package.json +7 -0
  139. package/resources/panel/panel.css +407 -0
  140. package/resources/panel/preload.cjs +72 -0
  141. package/resources/panel/quick-menu.cjs +197 -0
  142. package/resources/panel/renderer.js +441 -0
  143. package/resources/panel/tray-menu.cjs +40 -0
  144. package/resources/panel/tray.png +0 -0
  145. package/scripts/_utils.py +403 -54
  146. package/scripts/annual_report.py +4 -2
  147. package/scripts/article_notes.py +423 -0
  148. package/scripts/backfill_articles.py +438 -0
  149. package/scripts/biz_daily.py +590 -105
  150. package/scripts/chat_notes.py +470 -0
  151. package/scripts/classify_daily.py +10 -18
  152. package/scripts/compile_wiki.py +1059 -46
  153. package/scripts/create_reading_notes.py +199 -14
  154. package/scripts/decide.py +253 -0
  155. package/scripts/draft_reply.py +671 -0
  156. package/scripts/export_chat_html.py +212 -46
  157. package/scripts/extract_todos.py +31 -5
  158. package/scripts/fav_notes.py +318 -0
  159. package/scripts/fix_topics.py +2 -1
  160. package/scripts/generate_ai_report.py +214 -9
  161. package/scripts/generate_html.py +18 -3
  162. package/scripts/generate_review.py +2 -2
  163. package/scripts/jev_client.py +294 -0
  164. package/scripts/jev_probe.py +440 -0
  165. package/scripts/nt_common.py +82 -0
  166. package/scripts/nt_decrypt.py +1755 -1352
  167. package/scripts/pipeline.py +27 -2
  168. package/scripts/promote_ideas.py +19 -4
  169. package/scripts/quality_eval.py +707 -0
  170. package/scripts/rag_chat.py +4 -4
  171. package/scripts/read_image.py +187 -0
  172. package/scripts/reply_debt.py +488 -0
  173. package/scripts/route_cards.py +453 -0
  174. package/scripts/semantic_search.py +208 -49
  175. package/scripts/user_notes.py +315 -0
  176. package/scripts/vault_rag.py +13 -7
  177. package/scripts/vault_search.py +21 -4
  178. package/scripts/wiki_lint.py +397 -0
  179. package/src/core/appMsgFormat.ts +117 -0
  180. package/src/core/ntCore.ts +67 -0
  181. package/src/core/sqlcipherCore.ts +5 -61
  182. package/src/core/wcdbCore.ts +6 -2
  183. package/src/panel/client.ts +49 -0
  184. package/src/panel/endpoint.ts +144 -0
  185. package/src/panel/launch.ts +101 -0
  186. package/src/panel/server.ts +424 -0
  187. package/src/panel/userId.ts +72 -0
  188. package/src/services/assistantDaemon.ts +57 -9
  189. package/src/services/assistantEval.ts +611 -0
  190. package/src/services/assistantMemory.ts +413 -57
  191. package/src/services/assistantPrivacy.ts +18 -4
  192. package/src/services/assistantRouter.ts +191 -0
  193. package/src/services/assistantService.ts +671 -74
  194. package/src/services/assistantTools.ts +1118 -57
  195. package/src/services/assistantTrace.ts +175 -0
  196. package/src/services/chatService.ts +30 -0
  197. package/src/services/configService.ts +403 -385
  198. package/src/services/messageQuery.ts +97 -0
  199. package/src/services/pythonBridge.ts +139 -0
  200. package/src/services/syncService.ts +363 -0
  201. package/src/services/syncState.ts +275 -0
  202. package/src/services/wechatMessageService.ts +73 -7
  203. package/src/utils/atomicWrite.ts +56 -0
  204. package/src/utils/dateRange.ts +46 -0
  205. package/src/utils/text.ts +20 -0
@@ -1,657 +1,681 @@
1
- #!/usr/bin/env npx tsx
2
- /**
3
- * WeFlow MCP Server — 让 Claude Code 等 AI Agent 直接查询知识库。
4
- *
5
- * 启动: npx tsx mcp-server/index.ts
6
- * 在 CLAUDE.md 中注册后,AI 可直接搜索文章、概念、日报。
7
- */
8
- import { Server } from '@modelcontextprotocol/sdk/server/index.js'
9
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
10
- import {
11
- CallToolRequestSchema,
12
- ListToolsRequestSchema,
13
- } from '@modelcontextprotocol/sdk/types.js'
14
- import { readFileSync, readdirSync, existsSync, statSync } from 'fs'
15
- import { join } from 'path'
16
- import { isAllowedWeChatArticleUrl, safeChildPath, safeDate } from '../src/utils/mcpSecurity.js'
17
- import { resolvePackageRoot } from '../src/utils/packageRoot.js'
18
- import { chatService } from '../src/services/chatService.js'
19
- import { createWeFlowEnvelope } from '../src/services/messageContract.js'
20
-
21
- // ---- 微信公众号文章抓取 ----
22
- async function readResponseTextLimited(response: Response, maximumBytes: number): Promise<string> {
23
- const contentLength = Number(response.headers.get('content-length') || 0)
24
- if (contentLength > maximumBytes) throw new Error('RESPONSE_TOO_LARGE')
25
- if (!response.body) return ''
26
-
27
- const reader = response.body.getReader()
28
- const chunks: Uint8Array[] = []
29
- let size = 0
30
- while (true) {
31
- const { done, value } = await reader.read()
32
- if (done) break
33
- size += value.byteLength
34
- if (size > maximumBytes) {
35
- await reader.cancel()
36
- throw new Error('RESPONSE_TOO_LARGE')
37
- }
38
- chunks.push(value)
39
- }
40
- return Buffer.concat(chunks).toString('utf8')
41
- }
42
-
43
- async function fetchWeChatArticle(url: string): Promise<{
44
- title: string
45
- author: string
46
- description: string
47
- content: string
48
- images: string[]
49
- }> {
50
- let currentUrl = url
51
- let resp: Response | null = null
52
- for (let redirectCount = 0; redirectCount <= 3; redirectCount++) {
53
- if (!isAllowedWeChatArticleUrl(currentUrl)) throw new Error('URL_NOT_ALLOWED')
54
- resp = await fetch(currentUrl, {
55
- headers: {
56
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
57
- 'Accept': 'text/html,application/xhtml+xml',
58
- 'Accept-Language': 'zh-CN,zh;q=0.9',
59
- },
60
- redirect: 'manual',
61
- signal: AbortSignal.timeout(20_000),
62
- })
63
- if (resp.status < 300 || resp.status >= 400) break
64
- const location = resp.headers.get('location')
65
- if (!location) throw new Error('REDIRECT_WITHOUT_LOCATION')
66
- currentUrl = new URL(location, currentUrl).toString()
67
- resp = null
68
- }
69
- if (!resp) throw new Error('TOO_MANY_REDIRECTS')
70
- if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${resp.statusText}`)
71
- const html = await readResponseTextLimited(resp, 5 * 1024 * 1024)
72
-
73
- // 提取 meta 信息
74
- const title = html.match(/<meta[^>]*property="og:title"[^>]*content="([^"]+)"/i)?.[1]
75
- || html.match(/<title>([^<]+)<\/title>/i)?.[1]?.trim()
76
- || '(未知标题)'
77
- const author = html.match(/<meta[^>]*property="og:article:author"[^>]*content="([^"]+)"/i)?.[1]
78
- || html.match(/var\s+nickname\s*=\s*"([^"]+)"/i)?.[1]
79
- || '(未知作者)'
80
- const description = html.match(/<meta[^>]*property="og:description"[^>]*content="([^"]+)"/i)?.[1]
81
- || ''
82
-
83
- // 提取 js_content 文章正文
84
- const contentMatch = html.match(/<div[^>]*id="js_content"[^>]*>([\s\S]*?)<\/div>\s*<script/i)
85
- let content = contentMatch?.[1] || ''
86
-
87
- // 替换图片 data-src → src
88
- content = content.replace(/data-src="/g, 'src="')
89
-
90
- // 简单的 HTML → Markdown 转换
91
- content = htmlToMarkdown(content)
92
-
93
- // 提取图片链接
94
- const images: string[] = []
95
- const imgMatches = content.matchAll(/!\[.*?\]\((.*?)\)/g)
96
- for (const m of imgMatches) {
97
- if (m[1]) images.push(m[1])
98
- }
99
-
100
- return { title, author, description, content, images }
101
- }
102
-
103
- /** 简单的 HTML → Markdown 转换(处理微信文章常见结构) */
104
- function htmlToMarkdown(html: string): string {
105
- let md = html
106
- // 移除 style/script 标签
107
- .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
108
- .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
109
- // 标题
110
- .replace(/<h1[^>]*>(.*?)<\/h1>/gi, '\n# $1\n')
111
- .replace(/<h2[^>]*>(.*?)<\/h2>/gi, '\n## $1\n')
112
- .replace(/<h3[^>]*>(.*?)<\/h3>/gi, '\n### $1\n')
113
- .replace(/<h4[^>]*>(.*?)<\/h4>/gi, '\n#### $1\n')
114
- // 粗体/斜体
115
- .replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**')
116
- .replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**')
117
- .replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*')
118
- .replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*')
119
- // 段落和换行
120
- .replace(/<br\s*\/?>/gi, '\n')
121
- .replace(/<p[^>]*>/gi, '\n\n')
122
- .replace(/<\/p>/gi, '')
123
- // 列表
124
- .replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n')
125
- // 引用
126
- .replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (_: string, content: string) => {
127
- return '\n> ' + content.trim().replace(/\n/g, '\n> ') + '\n'
128
- })
129
- // 图片
130
- .replace(/<img[^>]*src="([^"]+)"[^>]*>/gi, '![]($1)\n')
131
- // 链接
132
- .replace(/<a[^>]*href="([^"]+)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)')
133
- // 移除剩余标签
134
- .replace(/<[^>]+>/g, '')
135
- // 解码 HTML 实体
136
- .replace(/&amp;/g, '&')
137
- .replace(/&lt;/g, '<')
138
- .replace(/&gt;/g, '>')
139
- .replace(/&quot;/g, '"')
140
- .replace(/&#39;/g, "'")
141
- .replace(/&nbsp;/g, ' ')
142
- // 清理多余空行
143
- .replace(/\n{3,}/g, '\n\n')
144
- .trim()
145
-
146
- return md
147
- }
148
- import { formatWeChatArticle, listThemes } from '../src/services/wechat-formatter.js'
149
- import { boundedToolInteger, MCP_READ_ONLY_TOOL_DEFS, executeTool } from '../src/services/assistantTools.js'
150
- import { AssistantMemory } from '../src/services/assistantMemory.js'
151
-
152
- // ---- 本地微信数据工具 (复用 assistant 工具层, 微信 bot / MCP server 同源) ----
153
- // MCP 无微信用户身份, 记忆固定挂 'mcp' 用户下; 与 assistant 守护进程共用同一持久化文件
154
- const mcpMemory = new AssistantMemory()
155
- const MCP_TOOL_CTX = { userId: 'mcp', memory: mcpMemory }
156
-
157
- /** assistant 工具 (OpenAI function 格式) → MCP 工具格式; get_stats 并入现有 wechat.get_stats */
158
- const ASSISTANT_TOOLS = MCP_READ_ONLY_TOOL_DEFS
159
- .filter(t => t.function.name !== 'get_stats')
160
- .map(t => ({
161
- name: `wechat.${t.function.name}`,
162
- description: t.function.description,
163
- inputSchema: t.function.parameters,
164
- }))
165
-
166
- const PKG_ROOT = resolvePackageRoot(import.meta.url)
167
- const BIZ_DAILY = join(PKG_ROOT, 'output', 'biz-daily')
168
- const VAULT_WIKI = join(PKG_ROOT, 'output', 'wechat-vault', 'Wiki', 'Concepts')
169
- const VAULT_INDEX = join(PKG_ROOT, 'output', 'wechat-vault', 'Wiki', '00-Overview.md')
170
- const REVIEWS = join(PKG_ROOT, 'output', 'reviews', 'Daily')
171
- const MCP_MESSAGE_LIMIT_DEFAULT = 100
172
- const MCP_MESSAGE_LIMIT_MAX = 1000
173
-
174
- function parseMessageDate(value: unknown, label: string): number | undefined {
175
- if (value === undefined || value === null || value === '') return undefined
176
- const date = safeDate(value)
177
- if (!date) throw new Error(`${label} 必须是有效的 YYYY-MM-DD 日期`)
178
- const [year, month, day] = date.split('-').map(Number)
179
- const start = new Date(year, month - 1, day)
180
- if (start.getFullYear() !== year || start.getMonth() !== month - 1 || start.getDate() !== day) {
181
- throw new Error(`${label} 必须是有效的日历日期`)
182
- }
183
- return Math.floor(start.getTime() / 1000)
184
- }
185
-
186
- async function resolveMcpTalker(input: unknown): Promise<string> {
187
- const contact = String(input || '').trim()
188
- if (!contact) throw new Error('contact 不能为空')
189
- if (contact.startsWith('wxid_') || contact.includes('@chatroom') || contact.includes('@openim')) return contact
190
-
191
- const sessions = await chatService.listSessions(contact, 20)
192
- const exact = sessions.filter(session => session.username === contact || session.displayName === contact)
193
- if (exact.length === 1) return exact[0].username
194
- if (exact.length > 1 || sessions.length > 1) throw new Error('contact 匹配到多个会话,请使用会话 ID')
195
- if (sessions.length === 1) return sessions[0].username
196
- throw new Error('未找到对应会话,请使用会话 ID 或准确的会话名称')
197
- }
198
-
199
- async function exportMessagesForMcp(args: Record<string, any>): Promise<string> {
200
- const limit = args.limit === undefined ? MCP_MESSAGE_LIMIT_DEFAULT : Number(args.limit)
201
- if (!Number.isInteger(limit) || limit < 1 || limit > MCP_MESSAGE_LIMIT_MAX) {
202
- throw new Error(`limit 必须是 1-${MCP_MESSAGE_LIMIT_MAX} 的整数`)
203
- }
204
- const from = parseMessageDate(args.from, 'from')
205
- const toStart = parseMessageDate(args.to, 'to')
206
- const to = toStart === undefined ? undefined : toStart + 24 * 60 * 60 - 1
207
- if (from !== undefined && to !== undefined && from > to) throw new Error('from 不能晚于 to')
208
-
209
- const talker = await resolveMcpTalker(args.contact)
210
- const messages = await chatService.getMessagesInRange(talker, limit, from, to)
211
- return JSON.stringify(createWeFlowEnvelope(messages, new Date().toISOString(), {
212
- requestedFrom: from,
213
- requestedTo: to,
214
- requestedLimit: limit,
215
- }), null, 2)
216
- }
217
-
218
- function parseFrontmatter(text: string): Record<string, any> {
219
- if (!text.startsWith('---')) return {}
220
- const end = text.indexOf('---', 3)
221
- if (end === -1) return {}
222
- const fm: Record<string, any> = {}
223
- for (const line of text.slice(3, end).split('\n')) {
224
- const idx = line.indexOf(':')
225
- if (idx === -1) continue
226
- const key = line.slice(0, idx).trim()
227
- let val = line.slice(idx + 1).trim()
228
- if (val.startsWith('[') && val.endsWith(']')) {
229
- fm[key] = val.slice(1, -1).split(',').map(v => v.trim().replace(/['"]/g, ''))
230
- } else if (val.startsWith('"') && val.endsWith('"')) {
231
- fm[key] = val.slice(1, -1)
232
- } else {
233
- fm[key] = val
234
- }
235
- }
236
- return fm
237
- }
238
-
239
- function scanArticles(): any[] {
240
- const results: any[] = []
241
- if (!existsSync(BIZ_DAILY)) return results
242
- for (const dateDir of readdirSync(BIZ_DAILY).sort().reverse()) {
243
- const dp = join(BIZ_DAILY, dateDir)
244
- if (!statSync(dp).isDirectory()) continue
245
- for (const topic of readdirSync(dp)) {
246
- const tp = join(dp, topic)
247
- if (!statSync(tp).isDirectory()) continue
248
- for (const file of readdirSync(tp)) {
249
- if (!file.endsWith('.md') || file === 'README.md') continue
250
- const content = readFileSync(join(tp, file), 'utf-8')
251
- const fm = parseFrontmatter(content)
252
- results.push({
253
- ...fm,
254
- file: join(dateDir, topic, file),
255
- dateDir,
256
- })
257
- }
258
- }
259
- }
260
- return results
261
- }
262
-
263
- function searchArticles(args: Record<string, any>): string {
264
- let articles = scanArticles()
265
- if (args.keyword) {
266
- const kw = String(args.keyword).toLowerCase()
267
- articles = articles.filter(a =>
268
- (a.title || '').toLowerCase().includes(kw) ||
269
- (a.source || '').toLowerCase().includes(kw) ||
270
- (a.tags || []).some((t: string) => t.toLowerCase().includes(kw))
271
- )
272
- }
273
- if (args.topic) {
274
- articles = articles.filter(a => a.topic === args.topic)
275
- }
276
- if (args.date) {
277
- articles = articles.filter(a => a.dateDir === args.date)
278
- }
279
- const limit = boundedToolInteger(args.limit, 20, 100)
280
- const top = articles.slice(0, limit)
281
- if (!top.length) return '未找到匹配文章'
282
- return top.map(a =>
283
- `- [${a.date}] **${a.title || '(无标题)'}** — ${a.source || ''} [${a.topic || ''}] [${(a.tags || []).join(', ')}]\n ${(a.description || '').slice(0, 120)}`
284
- ).join('\n\n')
285
- }
286
-
287
- async function main() {
288
- const server = new Server(
289
- { name: 'weflow-mcp', version: '1.0.0' },
290
- { capabilities: { tools: {} } }
291
- )
292
-
293
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
294
- tools: [
295
- {
296
- name: 'wechat.search_articles',
297
- description: '搜索知识库文章(可按关键词/主题/日期过滤)',
298
- inputSchema: {
299
- type: 'object',
300
- properties: {
301
- keyword: { type: 'string', description: '搜索关键词(标题/来源/标签)' },
302
- topic: { type: 'string', description: '主题: AI | 学术 | 新闻 | 文学 | 投资' },
303
- date: { type: 'string', description: '日期 YYYY-MM-DD' },
304
- limit: { type: 'number', description: '返回数量,默认20' },
305
- },
306
- },
307
- },
308
- {
309
- name: 'wechat.get_daily',
310
- description: '获取指定日期的公众号日报',
311
- inputSchema: {
312
- type: 'object',
313
- properties: {
314
- date: { type: 'string', description: '日期 YYYY-MM-DD,默认最新' },
315
- },
316
- },
317
- },
318
- {
319
- name: 'wechat.get_concepts',
320
- description: '查看概念图谱索引',
321
- inputSchema: { type: 'object', properties: {} },
322
- },
323
- {
324
- name: 'wechat.get_concept',
325
- description: '获取某个概念的详细 Wiki 页',
326
- inputSchema: {
327
- type: 'object',
328
- properties: {
329
- name: { type: 'string', description: '概念名,如 RAG、Agent' },
330
- },
331
- required: ['name'],
332
- },
333
- },
334
- {
335
- name: 'wechat.get_review',
336
- description: '获取某日的 AI 学习日报',
337
- inputSchema: {
338
- type: 'object',
339
- properties: {
340
- date: { type: 'string', description: '日期 YYYY-MM-DD,默认最新' },
341
- },
342
- },
343
- },
344
- {
345
- name: 'wechat.get_stats',
346
- description: '统计概览: 知识库文章/概念页 + 本地微信数据(会话数/收藏总数)',
347
- inputSchema: { type: 'object', properties: {} },
348
- },
349
- {
350
- name: 'wechat.format_article',
351
- description: '将 Markdown 文章转换为微信公众号排版 HTML(支持 4 种主题:default/warm/minimal/green)',
352
- inputSchema: {
353
- type: 'object',
354
- properties: {
355
- content: { type: 'string', description: 'Markdown 格式的文章内容' },
356
- theme: { type: 'string', description: '排版主题: default | warm | minimal | green,默认 default' },
357
- },
358
- required: ['content'],
359
- },
360
- },
361
- {
362
- name: 'wechat.list_themes',
363
- description: '列出微信公众号排版可用的所有主题',
364
- inputSchema: { type: 'object', properties: {} },
365
- },
366
- {
367
- name: 'wechat.search_public',
368
- description: '搜索全网微信公众号文章(通过搜索引擎 site:mp.weixin.qq.com)',
369
- inputSchema: {
370
- type: 'object',
371
- properties: {
372
- keyword: { type: 'string', description: '搜索关键词' },
373
- limit: { type: 'number', description: '返回数量,默认10' },
374
- },
375
- required: ['keyword'],
376
- },
377
- },
378
- {
379
- name: 'wechat.fetch_article',
380
- description: '抓取单篇微信公众号文章,转换为 Markdown',
381
- inputSchema: {
382
- type: 'object',
383
- properties: {
384
- url: { type: 'string', description: '文章链接(mp.weixin.qq.com)' },
385
- },
386
- required: ['url'],
387
- },
388
- },
389
- {
390
- name: 'wechat.export_messages',
391
- description: '以 weflow-message/v1 JSON 返回指定会话的本地消息。只读,不返回数据库路径、密钥或配置。',
392
- inputSchema: {
393
- type: 'object',
394
- properties: {
395
- contact: { type: 'string', description: '会话 ID,或唯一匹配的会话名称' },
396
- limit: { type: 'number', description: '返回上限,默认 100,最大 1000' },
397
- from: { type: 'string', description: '起始日期 YYYY-MM-DD(含当天)' },
398
- to: { type: 'string', description: '结束日期 YYYY-MM-DD(含当天)' },
399
- },
400
- required: ['contact'],
401
- },
402
- },
403
- // ---- 本地微信数据 (与 weflow-cli assistant / 微信 bot 共享工具层) ----
404
- ...ASSISTANT_TOOLS,
405
- ],
406
- }))
407
-
408
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
409
- const { name, arguments: args = {} } = request.params
410
-
411
- try {
412
- switch (name) {
413
- case 'wechat.search_articles':
414
- return { content: [{ type: 'text', text: searchArticles(args) }] }
415
-
416
- case 'wechat.get_daily': {
417
- const dates = existsSync(BIZ_DAILY) ? readdirSync(BIZ_DAILY).sort().reverse() : []
418
- if (args.date && !safeDate(args.date)) return { content: [{ type: 'text', text: '日期无效,请使用 YYYY-MM-DD' }] }
419
- const date = safeDate(args.date) || dates.find(d => /^\d{4}-\d{2}-\d{2}$/.test(d)) || ''
420
- const readme = safeChildPath(BIZ_DAILY, join(date, 'README.md'))
421
- if (!readme || !existsSync(readme)) return { content: [{ type: 'text', text: `未找到 ${date} 的日报` }] }
422
- return { content: [{ type: 'text', text: readFileSync(readme, 'utf-8') }] }
423
- }
424
-
425
- case 'wechat.get_concepts': {
426
- if (!existsSync(VAULT_INDEX)) return { content: [{ type: 'text', text: '概念索引尚未生成,请先运行 wiki compile' }] }
427
- return { content: [{ type: 'text', text: readFileSync(VAULT_INDEX, 'utf-8') }] }
428
- }
429
-
430
- case 'wechat.get_concept': {
431
- const name = String(args.name)
432
- if (!name || name.includes('/') || name.includes('\\') || name.includes('..')) {
433
- return { content: [{ type: 'text', text: '概念名无效' }] }
434
- }
435
- for (const ext of ['', '.md']) {
436
- const path = safeChildPath(VAULT_WIKI, name + ext)
437
- if (path && existsSync(path)) {
438
- return { content: [{ type: 'text', text: readFileSync(path, 'utf-8') }] }
439
- }
440
- }
441
- // Fuzzy search
442
- if (existsSync(VAULT_WIKI)) {
443
- for (const f of readdirSync(VAULT_WIKI)) {
444
- if (f.includes(name)) {
445
- const path = safeChildPath(VAULT_WIKI, f)
446
- if (path) return { content: [{ type: 'text', text: readFileSync(path, 'utf-8') }] }
447
- }
448
- }
449
- }
450
- return { content: [{ type: 'text', text: `未找到概念: ${name}` }] }
451
- }
452
-
453
- case 'wechat.get_review': {
454
- const files = existsSync(REVIEWS) ? readdirSync(REVIEWS).sort().reverse() : []
455
- const date = String(args.date || '')
456
- let path: string
457
- if (date) {
458
- path = safeDate(date) ? join(REVIEWS, `Daily-${date}.md`) : ''
459
- } else {
460
- path = files.length ? join(REVIEWS, files[0]) : ''
461
- }
462
- if (!path || !existsSync(path)) return { content: [{ type: 'text', text: `未找到 ${date || '最新'} 的学习日报` }] }
463
- return { content: [{ type: 'text', text: readFileSync(path, 'utf-8') }] }
464
- }
465
-
466
- case 'wechat.format_article': {
467
- const content = String(args.content || '')
468
- if (!content) return { content: [{ type: 'text', text: '错误: content 不能为空' }] }
469
- const theme = String(args.theme || 'default')
470
- const html = formatWeChatArticle(content, { theme: theme as any })
471
- const preview = html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').slice(0, 200) + '...'
472
- return {
473
- content: [{
474
- type: 'text',
475
- text: [
476
- `主题: ${theme}`,
477
- `总长度: ${html.length} 字符`,
478
- `正文预览: ${preview}`,
479
- `---`,
480
- html,
481
- ].join('\n')
482
- }]
483
- }
484
- }
485
-
486
- case 'wechat.list_themes': {
487
- const themes = listThemes()
488
- return {
489
- content: [{
490
- type: 'text',
491
- text: themes.map(t => `- **${t.id}** — ${t.name}: ${t.description}`).join('\n')
492
- }]
493
- }
494
- }
495
-
496
- case 'wechat.get_stats': {
497
- const articles = scanArticles()
498
- const dates = [...new Set(articles.map(a => a.dateDir))].sort()
499
- const topics: Record<string, number> = {}
500
- articles.forEach(a => { topics[a.topic || '未知'] = (topics[a.topic || '未知'] || 0) + 1 })
501
- let stats = `## 知识库统计\n\n- 文章总数: ${articles.length}\n- 覆盖日期: ${dates.length} 天 (${dates[0]} ~ ${dates[dates.length-1]})\n- 主题分布:\n`
502
- for (const [t, c] of Object.entries(topics).sort((a, b) => b[1] - a[1])) {
503
- stats += ` - ${t}: ${c} 篇\n`
504
- }
505
- if (existsSync(VAULT_INDEX)) {
506
- const idx = readFileSync(VAULT_INDEX, 'utf-8')
507
- const m = idx.match(/共 (\d+) 个概念/)
508
- if (m) stats += `- 概念页: ${m[1]} 个\n`
509
- }
510
- // 追加本地微信数据统计 (会话/收藏; 数据库未配置时返回提示)
511
- const chatStats = await executeTool('get_stats', {}, MCP_TOOL_CTX)
512
- stats += `\n## 本地微信数据\n\n${chatStats}\n`
513
- return { content: [{ type: 'text', text: stats }] }
514
- }
515
-
516
- case 'wechat.search_public': {
517
- const keyword = String(args.keyword || '').trim()
518
- if (!keyword) return { content: [{ type: 'text', text: '错误: keyword 不能为空' }] }
519
- let limit = 10
520
- try {
521
- limit = boundedToolInteger(args.limit, 10, 20)
522
- } catch {
523
- return { content: [{ type: 'text', text: '错误: limit 必须是 1-20 的整数' }] }
524
- }
525
-
526
- try {
527
- // 使用搜狗微信搜索
528
- const resp = await fetch(
529
- `https://weixin.sogou.com/weixin?type=2&query=${encodeURIComponent(keyword)}`,
530
- {
531
- headers: {
532
- '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',
533
- 'Referer': 'https://weixin.sogou.com/',
534
- 'Accept': 'text/html',
535
- 'Accept-Language': 'zh-CN,zh;q=0.9',
536
- },
537
- signal: AbortSignal.timeout(20_000),
538
- }
539
- )
540
- const html = await readResponseTextLimited(resp, 2 * 1024 * 1024)
541
-
542
- // 从搜狗结果中提取标题、公众号名、摘要
543
- // 搜狗将结果放在 li 标签中,每篇文章包含标题链接 + 摘要
544
- const items: Array<{ title: string; account: string; desc: string }> = []
545
-
546
- // 提取文章标题(h3 标签内的文本)
547
- const titlePattern = /<h3[^>]*>\s*<a[^>]*>(.*?)<\/a>\s*<\/h3>/g
548
- const titles: string[] = []
549
- let tm: RegExpExecArray | null
550
- while ((tm = titlePattern.exec(html)) !== null) {
551
- const t = tm[1].replace(/<em[^>]*>/g, '').replace(/<\/em>/g, '').replace(/<!--[^>]*-->/g, '').trim()
552
- if (t) titles.push(t)
553
- }
554
-
555
- // 提取公众号名(在 span.all-time-y2 中)
556
- const acctPattern = /<span[^>]*class="[^"]*all-time-y2[^"]*"[^>]*>(.*?)<\/span>/g
557
- const accounts: string[] = []
558
- let am: RegExpExecArray | null
559
- while ((am = acctPattern.exec(html)) !== null) {
560
- const a = am[1].replace(/<[^>]+>/g, '').trim()
561
- if (a) accounts.push(a)
562
- }
563
-
564
- // 提取摘要(p 标签)
565
- const descPattern = /<p[^>]*class="[^"]*txt-info[^"]*"[^>]*>(.*?)<\/p>/g
566
- const descs: string[] = []
567
- let dm: RegExpExecArray | null
568
- while ((dm = descPattern.exec(html)) !== null) {
569
- const d = dm[1].replace(/<[^>]+>/g, '').replace(/&hellip;/g, '…').replace(/&rarr;/g, '→').replace(/&mdash;/g, '—').trim()
570
- if (d && d.length > 10) descs.push(d)
571
- }
572
-
573
- // 组合结果
574
- const count = Math.max(titles.length, accounts.length, descs.length)
575
- for (let i = 0; i < Math.min(count, limit); i++) {
576
- items.push({
577
- title: titles[i] || '(未知标题)',
578
- account: accounts[i] || '(未知公众号)',
579
- desc: descs[i] || '',
580
- })
581
- }
582
-
583
- if (!items.length) {
584
- return { content: [{ type: 'text', text: `未找到与"${keyword}"相关的微信公众号文章。\n\n提示:\n1. 尝试更具体的关键词\n2. 加上作者/公众号名\n3. 直接提供文章链接使用 wechat.fetch_article 抓取` }] }
585
- }
586
-
587
- const results = items.map((item, i) =>
588
- `${i + 1}. **${item.title}**\n 公众号: ${item.account}\n ${item.desc}`
589
- ).join('\n\n')
590
-
591
- return {
592
- content: [{
593
- type: 'text',
594
- text: [
595
- `搜索关键词: "${keyword}" | 找到 ${items.length} 篇:`,
596
- '',
597
- results,
598
- '',
599
- '---',
600
- '提示: 在微信或搜狗微信搜索中打开看到文章后,复制链接使用 wechat.fetch_article 抓取全文。'
601
- ].join('\n')
602
- }]
603
- }
604
- } catch {
605
- return { content: [{ type: 'text', text: '搜索失败,请检查网络连接后重试' }] }
606
- }
607
- }
608
-
609
- case 'wechat.fetch_article': {
610
- const url = String(args.url || '').trim()
611
- if (!url) return { content: [{ type: 'text', text: '错误: url 不能为空' }] }
612
- if (!isAllowedWeChatArticleUrl(url)) {
613
- return { content: [{ type: 'text', text: '错误: 仅支持 HTTPS 微信公众号文章链接 (mp.weixin.qq.com)' }] }
614
- }
615
-
616
- try {
617
- const article = await fetchWeChatArticle(url)
618
- return {
619
- content: [{
620
- type: 'text',
621
- text: [
622
- `# ${article.title}`,
623
- `> 作者: ${article.author}`,
624
- article.description ? `> ${article.description}` : '',
625
- '',
626
- article.content,
627
- ].join('\n')
628
- }]
629
- }
630
- } catch {
631
- return { content: [{ type: 'text', text: '抓取失败,请检查链接和网络连接。部分文章需要微信客户端环境才能访问。' }] }
632
- }
633
- }
634
-
635
- case 'wechat.export_messages': {
636
- return { content: [{ type: 'text', text: await exportMessagesForMcp(args) }] }
637
- }
638
-
639
- default: {
640
- // assistant 工具层统一分发 (wechat.list_sessions / wechat.get_messages / ...)
641
- if (name.startsWith('wechat.')) {
642
- const text = await executeTool(name.slice('wechat.'.length), args, MCP_TOOL_CTX)
643
- return { content: [{ type: 'text', text }] }
644
- }
645
- return { content: [{ type: 'text', text: `未知工具: ${name}` }] }
646
- }
647
- }
648
- } catch {
649
- return { content: [{ type: 'text', text: '工具执行失败,请检查输入、本地配置或运行状态' }] }
650
- }
651
- })
652
-
653
- const transport = new StdioServerTransport()
654
- await server.connect(transport)
655
- }
656
-
657
- main().catch(console.error)
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * WeFlow MCP Server — 让 Claude Code 等 AI Agent 直接查询知识库。
4
+ *
5
+ * 启动: npx tsx mcp-server/index.ts
6
+ * 在 CLAUDE.md 中注册后,AI 可直接搜索文章、概念、日报。
7
+ */
8
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
9
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
10
+ import {
11
+ CallToolRequestSchema,
12
+ ListToolsRequestSchema,
13
+ } from '@modelcontextprotocol/sdk/types.js'
14
+ import { readFileSync, readdirSync, existsSync, statSync } from 'fs'
15
+ import { join } from 'path'
16
+ import { isAllowedWeChatArticleUrl, safeChildPath, safeDate } from '../src/utils/mcpSecurity.js'
17
+ import { resolvePackageRoot } from '../src/utils/packageRoot.js'
18
+ import { chatService } from '../src/services/chatService.js'
19
+ import { createWeFlowEnvelope } from '../src/services/messageContract.js'
20
+
21
+ // ---- 微信公众号文章抓取 ----
22
+ async function readResponseTextLimited(response: Response, maximumBytes: number): Promise<string> {
23
+ const contentLength = Number(response.headers.get('content-length') || 0)
24
+ if (contentLength > maximumBytes) throw new Error('RESPONSE_TOO_LARGE')
25
+ if (!response.body) return ''
26
+
27
+ const reader = response.body.getReader()
28
+ const chunks: Uint8Array[] = []
29
+ let size = 0
30
+ while (true) {
31
+ const { done, value } = await reader.read()
32
+ if (done) break
33
+ size += value.byteLength
34
+ if (size > maximumBytes) {
35
+ await reader.cancel()
36
+ throw new Error('RESPONSE_TOO_LARGE')
37
+ }
38
+ chunks.push(value)
39
+ }
40
+ return Buffer.concat(chunks).toString('utf8')
41
+ }
42
+
43
+ async function fetchWeChatArticle(url: string): Promise<{
44
+ title: string
45
+ author: string
46
+ description: string
47
+ content: string
48
+ images: string[]
49
+ }> {
50
+ let currentUrl = url
51
+ let resp: Response | null = null
52
+ for (let redirectCount = 0; redirectCount <= 3; redirectCount++) {
53
+ if (!isAllowedWeChatArticleUrl(currentUrl)) throw new Error('URL_NOT_ALLOWED')
54
+ resp = await fetch(currentUrl, {
55
+ headers: {
56
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
57
+ 'Accept': 'text/html,application/xhtml+xml',
58
+ 'Accept-Language': 'zh-CN,zh;q=0.9',
59
+ },
60
+ redirect: 'manual',
61
+ signal: AbortSignal.timeout(20_000),
62
+ })
63
+ if (resp.status < 300 || resp.status >= 400) break
64
+ const location = resp.headers.get('location')
65
+ if (!location) throw new Error('REDIRECT_WITHOUT_LOCATION')
66
+ currentUrl = new URL(location, currentUrl).toString()
67
+ resp = null
68
+ }
69
+ if (!resp) throw new Error('TOO_MANY_REDIRECTS')
70
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${resp.statusText}`)
71
+ const html = await readResponseTextLimited(resp, 5 * 1024 * 1024)
72
+
73
+ // 提取 meta 信息
74
+ const title = html.match(/<meta[^>]*property="og:title"[^>]*content="([^"]+)"/i)?.[1]
75
+ || html.match(/<title>([^<]+)<\/title>/i)?.[1]?.trim()
76
+ || '(未知标题)'
77
+ const author = html.match(/<meta[^>]*property="og:article:author"[^>]*content="([^"]+)"/i)?.[1]
78
+ || html.match(/var\s+nickname\s*=\s*"([^"]+)"/i)?.[1]
79
+ || '(未知作者)'
80
+ const description = html.match(/<meta[^>]*property="og:description"[^>]*content="([^"]+)"/i)?.[1]
81
+ || ''
82
+
83
+ // 提取 js_content 文章正文
84
+ const contentMatch = html.match(/<div[^>]*id="js_content"[^>]*>([\s\S]*?)<\/div>\s*<script/i)
85
+ let content = contentMatch?.[1] || ''
86
+
87
+ // 替换图片 data-src → src
88
+ content = content.replace(/data-src="/g, 'src="')
89
+
90
+ // 简单的 HTML → Markdown 转换
91
+ content = htmlToMarkdown(content)
92
+
93
+ // 提取图片链接
94
+ const images: string[] = []
95
+ const imgMatches = content.matchAll(/!\[.*?\]\((.*?)\)/g)
96
+ for (const m of imgMatches) {
97
+ if (m[1]) images.push(m[1])
98
+ }
99
+
100
+ return { title, author, description, content, images }
101
+ }
102
+
103
+ /** 简单的 HTML → Markdown 转换(处理微信文章常见结构) */
104
+ function htmlToMarkdown(html: string): string {
105
+ let md = html
106
+ // 移除 style/script 标签
107
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
108
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
109
+ // 标题
110
+ .replace(/<h1[^>]*>(.*?)<\/h1>/gi, '\n# $1\n')
111
+ .replace(/<h2[^>]*>(.*?)<\/h2>/gi, '\n## $1\n')
112
+ .replace(/<h3[^>]*>(.*?)<\/h3>/gi, '\n### $1\n')
113
+ .replace(/<h4[^>]*>(.*?)<\/h4>/gi, '\n#### $1\n')
114
+ // 粗体/斜体
115
+ .replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**')
116
+ .replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**')
117
+ .replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*')
118
+ .replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*')
119
+ // 段落和换行
120
+ .replace(/<br\s*\/?>/gi, '\n')
121
+ .replace(/<p[^>]*>/gi, '\n\n')
122
+ .replace(/<\/p>/gi, '')
123
+ // 列表
124
+ .replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n')
125
+ // 引用
126
+ .replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (_: string, content: string) => {
127
+ return '\n> ' + content.trim().replace(/\n/g, '\n> ') + '\n'
128
+ })
129
+ // 图片
130
+ .replace(/<img[^>]*src="([^"]+)"[^>]*>/gi, '![]($1)\n')
131
+ // 链接
132
+ .replace(/<a[^>]*href="([^"]+)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)')
133
+ // 移除剩余标签
134
+ .replace(/<[^>]+>/g, '')
135
+ // 解码 HTML 实体
136
+ .replace(/&amp;/g, '&')
137
+ .replace(/&lt;/g, '<')
138
+ .replace(/&gt;/g, '>')
139
+ .replace(/&quot;/g, '"')
140
+ .replace(/&#39;/g, "'")
141
+ .replace(/&nbsp;/g, ' ')
142
+ // 清理多余空行
143
+ .replace(/\n{3,}/g, '\n\n')
144
+ .trim()
145
+
146
+ return md
147
+ }
148
+ import { formatWeChatArticle, listThemes } from '../src/services/wechat-formatter.js'
149
+ import { boundedToolInteger, MCP_TOOL_DEFS, executeTool } from '../src/services/assistantTools.js'
150
+ import { AssistantMemory } from '../src/services/assistantMemory.js'
151
+
152
+ // ---- 本地微信数据工具 (复用 assistant 工具层, 微信 bot / MCP server 同源) ----
153
+ // MCP 无微信用户身份, 记忆固定挂 'mcp' 用户下; 与 assistant 守护进程共用同一持久化文件
154
+ //
155
+ // `requiresConfirm` 是**这条路独有的边界**:MCP 客户端是别人家的进程,它那边谁也看不到
156
+ // 这一轮是在什么上下文里决定调用的。所以凡是"会把用户数据送出去"的工具(目前是
157
+ // `draft_reply`:把一段对话发给两个云端模型),机器调用默认只给预览,要显式带
158
+ // `confirm: true` 才真出境。面板/微信那条路不置这一位。
159
+ const mcpMemory = new AssistantMemory()
160
+ const MCP_TOOL_CTX = { userId: 'mcp', memory: mcpMemory, requiresConfirm: true }
161
+
162
+ /** assistant 工具 (OpenAI function 格式) → MCP 工具格式; get_stats 并入现有 wechat.get_stats */
163
+ const ASSISTANT_TOOLS = MCP_TOOL_DEFS
164
+ .filter(t => t.function.name !== 'get_stats')
165
+ .map(t => ({
166
+ name: `wechat.${t.function.name}`,
167
+ description: t.function.description,
168
+ inputSchema: t.function.parameters,
169
+ }))
170
+
171
+ const PKG_ROOT = resolvePackageRoot(import.meta.url)
172
+ const BIZ_DAILY = join(PKG_ROOT, 'output', 'biz-daily')
173
+ // **两个概念目录**:`Wiki/Concepts` 是公众号文章线的,`Chat/Concepts` 是聊天线的
174
+ // (用户要求两条线分开)。这里必须两个都带上——只留一个的话,另一半概念页会**静默地查不到**,
175
+ // 工具照样返回"未找到概念"。与 `src/services/assistantTools.ts` 的 `VAULT_WIKI_DIRS`、
176
+ // 以及 `scripts/_utils.py` 的 `CONCEPT_DIRS` 是同一份清单,`test/concept-dirs-agreement.test.ts` 钉住。
177
+ const VAULT_WIKI_DIRS = [
178
+ join(PKG_ROOT, 'output', 'wechat-vault', 'Wiki', 'Concepts'),
179
+ join(PKG_ROOT, 'output', 'wechat-vault', 'Chat', 'Concepts'),
180
+ ]
181
+ const VAULT_WIKI = VAULT_WIKI_DIRS[0]
182
+ const VAULT_INDEX = join(PKG_ROOT, 'output', 'wechat-vault', 'Wiki', '00-Overview.md')
183
+ const VAULT_CHAT_INDEX = join(PKG_ROOT, 'output', 'wechat-vault', 'Chat', '00-Overview.md')
184
+ const REVIEWS = join(PKG_ROOT, 'output', 'reviews', 'Daily')
185
+ const MCP_MESSAGE_LIMIT_DEFAULT = 100
186
+ const MCP_MESSAGE_LIMIT_MAX = 1000
187
+
188
+ function parseMessageDate(value: unknown, label: string): number | undefined {
189
+ if (value === undefined || value === null || value === '') return undefined
190
+ const date = safeDate(value)
191
+ if (!date) throw new Error(`${label} 必须是有效的 YYYY-MM-DD 日期`)
192
+ const [year, month, day] = date.split('-').map(Number)
193
+ const start = new Date(year, month - 1, day)
194
+ if (start.getFullYear() !== year || start.getMonth() !== month - 1 || start.getDate() !== day) {
195
+ throw new Error(`${label} 必须是有效的日历日期`)
196
+ }
197
+ return Math.floor(start.getTime() / 1000)
198
+ }
199
+
200
+ async function resolveMcpTalker(input: unknown): Promise<string> {
201
+ const contact = String(input || '').trim()
202
+ if (!contact) throw new Error('contact 不能为空')
203
+ if (contact.startsWith('wxid_') || contact.includes('@chatroom') || contact.includes('@openim')) return contact
204
+
205
+ const sessions = await chatService.listSessions(contact, 20)
206
+ const exact = sessions.filter(session => session.username === contact || session.displayName === contact)
207
+ if (exact.length === 1) return exact[0].username
208
+ if (exact.length > 1 || sessions.length > 1) throw new Error('contact 匹配到多个会话,请使用会话 ID')
209
+ if (sessions.length === 1) return sessions[0].username
210
+ throw new Error('未找到对应会话,请使用会话 ID 或准确的会话名称')
211
+ }
212
+
213
+ async function exportMessagesForMcp(args: Record<string, any>): Promise<string> {
214
+ const limit = args.limit === undefined ? MCP_MESSAGE_LIMIT_DEFAULT : Number(args.limit)
215
+ if (!Number.isInteger(limit) || limit < 1 || limit > MCP_MESSAGE_LIMIT_MAX) {
216
+ throw new Error(`limit 必须是 1-${MCP_MESSAGE_LIMIT_MAX} 的整数`)
217
+ }
218
+ const from = parseMessageDate(args.from, 'from')
219
+ const toStart = parseMessageDate(args.to, 'to')
220
+ const to = toStart === undefined ? undefined : toStart + 24 * 60 * 60 - 1
221
+ if (from !== undefined && to !== undefined && from > to) throw new Error('from 不能晚于 to')
222
+
223
+ const talker = await resolveMcpTalker(args.contact)
224
+ const messages = await chatService.getMessagesInRange(talker, limit, from, to)
225
+ return JSON.stringify(createWeFlowEnvelope(messages, new Date().toISOString(), {
226
+ requestedFrom: from,
227
+ requestedTo: to,
228
+ requestedLimit: limit,
229
+ }), null, 2)
230
+ }
231
+
232
+ function parseFrontmatter(text: string): Record<string, any> {
233
+ if (!text.startsWith('---')) return {}
234
+ const end = text.indexOf('---', 3)
235
+ if (end === -1) return {}
236
+ const fm: Record<string, any> = {}
237
+ for (const line of text.slice(3, end).split('\n')) {
238
+ const idx = line.indexOf(':')
239
+ if (idx === -1) continue
240
+ const key = line.slice(0, idx).trim()
241
+ let val = line.slice(idx + 1).trim()
242
+ if (val.startsWith('[') && val.endsWith(']')) {
243
+ fm[key] = val.slice(1, -1).split(',').map(v => v.trim().replace(/['"]/g, ''))
244
+ } else if (val.startsWith('"') && val.endsWith('"')) {
245
+ fm[key] = val.slice(1, -1)
246
+ } else {
247
+ fm[key] = val
248
+ }
249
+ }
250
+ return fm
251
+ }
252
+
253
+ function scanArticles(): any[] {
254
+ const results: any[] = []
255
+ if (!existsSync(BIZ_DAILY)) return results
256
+ for (const dateDir of readdirSync(BIZ_DAILY).sort().reverse()) {
257
+ const dp = join(BIZ_DAILY, dateDir)
258
+ if (!statSync(dp).isDirectory()) continue
259
+ for (const topic of readdirSync(dp)) {
260
+ const tp = join(dp, topic)
261
+ if (!statSync(tp).isDirectory()) continue
262
+ for (const file of readdirSync(tp)) {
263
+ if (!file.endsWith('.md') || file === 'README.md') continue
264
+ const content = readFileSync(join(tp, file), 'utf-8')
265
+ const fm = parseFrontmatter(content)
266
+ results.push({
267
+ ...fm,
268
+ file: join(dateDir, topic, file),
269
+ dateDir,
270
+ })
271
+ }
272
+ }
273
+ }
274
+ return results
275
+ }
276
+
277
+ function searchArticles(args: Record<string, any>): string {
278
+ let articles = scanArticles()
279
+ if (args.keyword) {
280
+ const kw = String(args.keyword).toLowerCase()
281
+ articles = articles.filter(a =>
282
+ (a.title || '').toLowerCase().includes(kw) ||
283
+ (a.source || '').toLowerCase().includes(kw) ||
284
+ (a.tags || []).some((t: string) => t.toLowerCase().includes(kw))
285
+ )
286
+ }
287
+ if (args.topic) {
288
+ articles = articles.filter(a => a.topic === args.topic)
289
+ }
290
+ if (args.date) {
291
+ articles = articles.filter(a => a.dateDir === args.date)
292
+ }
293
+ const limit = boundedToolInteger(args.limit, 20, 100)
294
+ const top = articles.slice(0, limit)
295
+ if (!top.length) return '未找到匹配文章'
296
+ return top.map(a =>
297
+ `- [${a.date}] **${a.title || '(无标题)'}** — ${a.source || ''} [${a.topic || ''}] [${(a.tags || []).join(', ')}]\n ${(a.description || '').slice(0, 120)}`
298
+ ).join('\n\n')
299
+ }
300
+
301
+ async function main() {
302
+ const server = new Server(
303
+ { name: 'weflow-mcp', version: '1.0.0' },
304
+ { capabilities: { tools: {} } }
305
+ )
306
+
307
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
308
+ tools: [
309
+ {
310
+ name: 'wechat.search_articles',
311
+ description: '搜索知识库文章(可按关键词/主题/日期过滤)',
312
+ inputSchema: {
313
+ type: 'object',
314
+ properties: {
315
+ keyword: { type: 'string', description: '搜索关键词(标题/来源/标签)' },
316
+ topic: { type: 'string', description: '主题: AI | 学术 | 新闻 | 文学 | 投资' },
317
+ date: { type: 'string', description: '日期 YYYY-MM-DD' },
318
+ limit: { type: 'number', description: '返回数量,默认20' },
319
+ },
320
+ },
321
+ },
322
+ {
323
+ name: 'wechat.get_daily',
324
+ description: '获取指定日期的公众号日报',
325
+ inputSchema: {
326
+ type: 'object',
327
+ properties: {
328
+ date: { type: 'string', description: '日期 YYYY-MM-DD,默认最新' },
329
+ },
330
+ },
331
+ },
332
+ {
333
+ name: 'wechat.get_concepts',
334
+ description: '查看概念图谱索引',
335
+ inputSchema: { type: 'object', properties: {} },
336
+ },
337
+ {
338
+ name: 'wechat.get_concept',
339
+ description: '获取某个概念的详细 Wiki 页',
340
+ inputSchema: {
341
+ type: 'object',
342
+ properties: {
343
+ name: { type: 'string', description: '概念名,如 RAG、Agent' },
344
+ },
345
+ required: ['name'],
346
+ },
347
+ },
348
+ {
349
+ name: 'wechat.get_review',
350
+ description: '获取某日的 AI 学习日报',
351
+ inputSchema: {
352
+ type: 'object',
353
+ properties: {
354
+ date: { type: 'string', description: '日期 YYYY-MM-DD,默认最新' },
355
+ },
356
+ },
357
+ },
358
+ {
359
+ name: 'wechat.get_stats',
360
+ description: '统计概览: 知识库文章/概念页 + 本地微信数据(会话数/收藏总数)',
361
+ inputSchema: { type: 'object', properties: {} },
362
+ },
363
+ {
364
+ name: 'wechat.format_article',
365
+ description: '将 Markdown 文章转换为微信公众号排版 HTML(支持 4 种主题:default/warm/minimal/green)',
366
+ inputSchema: {
367
+ type: 'object',
368
+ properties: {
369
+ content: { type: 'string', description: 'Markdown 格式的文章内容' },
370
+ theme: { type: 'string', description: '排版主题: default | warm | minimal | green,默认 default' },
371
+ },
372
+ required: ['content'],
373
+ },
374
+ },
375
+ {
376
+ name: 'wechat.list_themes',
377
+ description: '列出微信公众号排版可用的所有主题',
378
+ inputSchema: { type: 'object', properties: {} },
379
+ },
380
+ {
381
+ name: 'wechat.search_public',
382
+ description: '搜索全网微信公众号文章(通过搜索引擎 site:mp.weixin.qq.com)',
383
+ inputSchema: {
384
+ type: 'object',
385
+ properties: {
386
+ keyword: { type: 'string', description: '搜索关键词' },
387
+ limit: { type: 'number', description: '返回数量,默认10' },
388
+ },
389
+ required: ['keyword'],
390
+ },
391
+ },
392
+ {
393
+ name: 'wechat.fetch_article',
394
+ description: '抓取单篇微信公众号文章,转换为 Markdown',
395
+ inputSchema: {
396
+ type: 'object',
397
+ properties: {
398
+ url: { type: 'string', description: '文章链接(mp.weixin.qq.com)' },
399
+ },
400
+ required: ['url'],
401
+ },
402
+ },
403
+ {
404
+ name: 'wechat.export_messages',
405
+ description: '以 weflow-message/v1 JSON 返回指定会话的本地消息。只读,不返回数据库路径、密钥或配置。',
406
+ inputSchema: {
407
+ type: 'object',
408
+ properties: {
409
+ contact: { type: 'string', description: '会话 ID,或唯一匹配的会话名称' },
410
+ limit: { type: 'number', description: '返回上限,默认 100,最大 1000' },
411
+ from: { type: 'string', description: '起始日期 YYYY-MM-DD(含当天)' },
412
+ to: { type: 'string', description: '结束日期 YYYY-MM-DD(含当天)' },
413
+ },
414
+ required: ['contact'],
415
+ },
416
+ },
417
+ // ---- 本地微信数据 (与 weflow-cli assistant / 微信 bot 共享工具层) ----
418
+ ...ASSISTANT_TOOLS,
419
+ ],
420
+ }))
421
+
422
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
423
+ const { name, arguments: args = {} } = request.params
424
+
425
+ try {
426
+ switch (name) {
427
+ case 'wechat.search_articles':
428
+ return { content: [{ type: 'text', text: searchArticles(args) }] }
429
+
430
+ case 'wechat.get_daily': {
431
+ const dates = existsSync(BIZ_DAILY) ? readdirSync(BIZ_DAILY).sort().reverse() : []
432
+ if (args.date && !safeDate(args.date)) return { content: [{ type: 'text', text: '日期无效,请使用 YYYY-MM-DD' }] }
433
+ const date = safeDate(args.date) || dates.find(d => /^\d{4}-\d{2}-\d{2}$/.test(d)) || ''
434
+ const readme = safeChildPath(BIZ_DAILY, join(date, 'README.md'))
435
+ if (!readme || !existsSync(readme)) return { content: [{ type: 'text', text: `未找到 ${date} 的日报` }] }
436
+ return { content: [{ type: 'text', text: readFileSync(readme, 'utf-8') }] }
437
+ }
438
+
439
+ case 'wechat.get_concepts': {
440
+ // 两条线各有一份索引:`Wiki/00-Overview.md`(文章)与 `Chat/00-Overview.md`(聊天)。
441
+ // 只回一份的话,另一个知识库对调用方就等于不存在——而索引本来就是"这里有什么"的清单。
442
+ const indexes: string[] = []
443
+ for (const [index, label] of [[VAULT_INDEX, '文章'], [VAULT_CHAT_INDEX, '聊天']] as const) {
444
+ if (!existsSync(index)) continue
445
+ indexes.push(`<!-- ${label}知识库 -->\n` + readFileSync(index, 'utf-8'))
446
+ }
447
+ if (!indexes.length) return { content: [{ type: 'text', text: '概念索引尚未生成,请先运行 wiki compile' }] }
448
+ return { content: [{ type: 'text', text: indexes.join('\n\n') }] }
449
+ }
450
+
451
+ case 'wechat.get_concept': {
452
+ const name = String(args.name)
453
+ if (!name || name.includes('/') || name.includes('\\') || name.includes('..')) {
454
+ return { content: [{ type: 'text', text: '概念名无效' }] }
455
+ }
456
+ for (const dir of VAULT_WIKI_DIRS) {
457
+ for (const ext of ['', '.md']) {
458
+ const path = safeChildPath(dir, name + ext)
459
+ if (path && existsSync(path)) {
460
+ return { content: [{ type: 'text', text: readFileSync(path, 'utf-8') }] }
461
+ }
462
+ }
463
+ }
464
+ // Fuzzy search
465
+ for (const dir of VAULT_WIKI_DIRS) {
466
+ if (!existsSync(dir)) continue
467
+ for (const f of readdirSync(dir)) {
468
+ if (f.includes(name)) {
469
+ const path = safeChildPath(dir, f)
470
+ if (path) return { content: [{ type: 'text', text: readFileSync(path, 'utf-8') }] }
471
+ }
472
+ }
473
+ }
474
+ return { content: [{ type: 'text', text: `未找到概念: ${name}` }] }
475
+ }
476
+
477
+ case 'wechat.get_review': {
478
+ const files = existsSync(REVIEWS) ? readdirSync(REVIEWS).sort().reverse() : []
479
+ const date = String(args.date || '')
480
+ let path: string
481
+ if (date) {
482
+ path = safeDate(date) ? join(REVIEWS, `Daily-${date}.md`) : ''
483
+ } else {
484
+ path = files.length ? join(REVIEWS, files[0]) : ''
485
+ }
486
+ if (!path || !existsSync(path)) return { content: [{ type: 'text', text: `未找到 ${date || '最新'} 的学习日报` }] }
487
+ return { content: [{ type: 'text', text: readFileSync(path, 'utf-8') }] }
488
+ }
489
+
490
+ case 'wechat.format_article': {
491
+ const content = String(args.content || '')
492
+ if (!content) return { content: [{ type: 'text', text: '错误: content 不能为空' }] }
493
+ const theme = String(args.theme || 'default')
494
+ const html = formatWeChatArticle(content, { theme: theme as any })
495
+ const preview = html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').slice(0, 200) + '...'
496
+ return {
497
+ content: [{
498
+ type: 'text',
499
+ text: [
500
+ `主题: ${theme}`,
501
+ `总长度: ${html.length} 字符`,
502
+ `正文预览: ${preview}`,
503
+ `---`,
504
+ html,
505
+ ].join('\n')
506
+ }]
507
+ }
508
+ }
509
+
510
+ case 'wechat.list_themes': {
511
+ const themes = listThemes()
512
+ return {
513
+ content: [{
514
+ type: 'text',
515
+ text: themes.map(t => `- **${t.id}** — ${t.name}: ${t.description}`).join('\n')
516
+ }]
517
+ }
518
+ }
519
+
520
+ case 'wechat.get_stats': {
521
+ const articles = scanArticles()
522
+ const dates = [...new Set(articles.map(a => a.dateDir))].sort()
523
+ const topics: Record<string, number> = {}
524
+ articles.forEach(a => { topics[a.topic || '未知'] = (topics[a.topic || '未知'] || 0) + 1 })
525
+ let stats = `## 知识库统计\n\n- 文章总数: ${articles.length}\n- 覆盖日期: ${dates.length} 天 (${dates[0]} ~ ${dates[dates.length-1]})\n- 主题分布:\n`
526
+ for (const [t, c] of Object.entries(topics).sort((a, b) => b[1] - a[1])) {
527
+ stats += ` - ${t}: ${c} 篇\n`
528
+ }
529
+ if (existsSync(VAULT_INDEX)) {
530
+ const idx = readFileSync(VAULT_INDEX, 'utf-8')
531
+ const m = idx.match(/共 (\d+) 个概念/)
532
+ if (m) stats += `- 概念页: ${m[1]} 个\n`
533
+ }
534
+ // 追加本地微信数据统计 (会话/收藏; 数据库未配置时返回提示)
535
+ const chatStats = await executeTool('get_stats', {}, MCP_TOOL_CTX)
536
+ stats += `\n## 本地微信数据\n\n${chatStats}\n`
537
+ return { content: [{ type: 'text', text: stats }] }
538
+ }
539
+
540
+ case 'wechat.search_public': {
541
+ const keyword = String(args.keyword || '').trim()
542
+ if (!keyword) return { content: [{ type: 'text', text: '错误: keyword 不能为空' }] }
543
+ let limit = 10
544
+ try {
545
+ limit = boundedToolInteger(args.limit, 10, 20)
546
+ } catch {
547
+ return { content: [{ type: 'text', text: '错误: limit 必须是 1-20 的整数' }] }
548
+ }
549
+
550
+ try {
551
+ // 使用搜狗微信搜索
552
+ const resp = await fetch(
553
+ `https://weixin.sogou.com/weixin?type=2&query=${encodeURIComponent(keyword)}`,
554
+ {
555
+ headers: {
556
+ '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',
557
+ 'Referer': 'https://weixin.sogou.com/',
558
+ 'Accept': 'text/html',
559
+ 'Accept-Language': 'zh-CN,zh;q=0.9',
560
+ },
561
+ signal: AbortSignal.timeout(20_000),
562
+ }
563
+ )
564
+ const html = await readResponseTextLimited(resp, 2 * 1024 * 1024)
565
+
566
+ // 从搜狗结果中提取标题、公众号名、摘要
567
+ // 搜狗将结果放在 li 标签中,每篇文章包含标题链接 + 摘要
568
+ const items: Array<{ title: string; account: string; desc: string }> = []
569
+
570
+ // 提取文章标题(h3 标签内的文本)
571
+ const titlePattern = /<h3[^>]*>\s*<a[^>]*>(.*?)<\/a>\s*<\/h3>/g
572
+ const titles: string[] = []
573
+ let tm: RegExpExecArray | null
574
+ while ((tm = titlePattern.exec(html)) !== null) {
575
+ const t = tm[1].replace(/<em[^>]*>/g, '').replace(/<\/em>/g, '').replace(/<!--[^>]*-->/g, '').trim()
576
+ if (t) titles.push(t)
577
+ }
578
+
579
+ // 提取公众号名(在 span.all-time-y2 中)
580
+ const acctPattern = /<span[^>]*class="[^"]*all-time-y2[^"]*"[^>]*>(.*?)<\/span>/g
581
+ const accounts: string[] = []
582
+ let am: RegExpExecArray | null
583
+ while ((am = acctPattern.exec(html)) !== null) {
584
+ const a = am[1].replace(/<[^>]+>/g, '').trim()
585
+ if (a) accounts.push(a)
586
+ }
587
+
588
+ // 提取摘要(p 标签)
589
+ const descPattern = /<p[^>]*class="[^"]*txt-info[^"]*"[^>]*>(.*?)<\/p>/g
590
+ const descs: string[] = []
591
+ let dm: RegExpExecArray | null
592
+ while ((dm = descPattern.exec(html)) !== null) {
593
+ const d = dm[1].replace(/<[^>]+>/g, '').replace(/&hellip;/g, '…').replace(/&rarr;/g, '→').replace(/&mdash;/g, '—').trim()
594
+ if (d && d.length > 10) descs.push(d)
595
+ }
596
+
597
+ // 组合结果
598
+ const count = Math.max(titles.length, accounts.length, descs.length)
599
+ for (let i = 0; i < Math.min(count, limit); i++) {
600
+ items.push({
601
+ title: titles[i] || '(未知标题)',
602
+ account: accounts[i] || '(未知公众号)',
603
+ desc: descs[i] || '',
604
+ })
605
+ }
606
+
607
+ if (!items.length) {
608
+ return { content: [{ type: 'text', text: `未找到与"${keyword}"相关的微信公众号文章。\n\n提示:\n1. 尝试更具体的关键词\n2. 加上作者/公众号名\n3. 直接提供文章链接使用 wechat.fetch_article 抓取` }] }
609
+ }
610
+
611
+ const results = items.map((item, i) =>
612
+ `${i + 1}. **${item.title}**\n 公众号: ${item.account}\n ${item.desc}`
613
+ ).join('\n\n')
614
+
615
+ return {
616
+ content: [{
617
+ type: 'text',
618
+ text: [
619
+ `搜索关键词: "${keyword}" | 找到 ${items.length} 篇:`,
620
+ '',
621
+ results,
622
+ '',
623
+ '---',
624
+ '提示: 在微信或搜狗微信搜索中打开看到文章后,复制链接使用 wechat.fetch_article 抓取全文。'
625
+ ].join('\n')
626
+ }]
627
+ }
628
+ } catch {
629
+ return { content: [{ type: 'text', text: '搜索失败,请检查网络连接后重试' }] }
630
+ }
631
+ }
632
+
633
+ case 'wechat.fetch_article': {
634
+ const url = String(args.url || '').trim()
635
+ if (!url) return { content: [{ type: 'text', text: '错误: url 不能为空' }] }
636
+ if (!isAllowedWeChatArticleUrl(url)) {
637
+ return { content: [{ type: 'text', text: '错误: 仅支持 HTTPS 微信公众号文章链接 (mp.weixin.qq.com)' }] }
638
+ }
639
+
640
+ try {
641
+ const article = await fetchWeChatArticle(url)
642
+ return {
643
+ content: [{
644
+ type: 'text',
645
+ text: [
646
+ `# ${article.title}`,
647
+ `> 作者: ${article.author}`,
648
+ article.description ? `> ${article.description}` : '',
649
+ '',
650
+ article.content,
651
+ ].join('\n')
652
+ }]
653
+ }
654
+ } catch {
655
+ return { content: [{ type: 'text', text: '抓取失败,请检查链接和网络连接。部分文章需要微信客户端环境才能访问。' }] }
656
+ }
657
+ }
658
+
659
+ case 'wechat.export_messages': {
660
+ return { content: [{ type: 'text', text: await exportMessagesForMcp(args) }] }
661
+ }
662
+
663
+ default: {
664
+ // assistant 工具层统一分发 (wechat.list_sessions / wechat.get_messages / ...)
665
+ if (name.startsWith('wechat.')) {
666
+ const text = await executeTool(name.slice('wechat.'.length), args, MCP_TOOL_CTX)
667
+ return { content: [{ type: 'text', text }] }
668
+ }
669
+ return { content: [{ type: 'text', text: `未知工具: ${name}` }] }
670
+ }
671
+ }
672
+ } catch {
673
+ return { content: [{ type: 'text', text: '工具执行失败,请检查输入、本地配置或运行状态' }] }
674
+ }
675
+ })
676
+
677
+ const transport = new StdioServerTransport()
678
+ await server.connect(transport)
679
+ }
680
+
681
+ main().catch(console.error)