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.
- package/CHANGELOG.md +359 -0
- package/OPERATIONS.md +79 -0
- package/README.en.md +9 -2
- package/README.md +12 -2
- package/bin/weflow-cli-electron.cjs +2 -0
- package/bin/weflow-cli.ts +648 -79
- package/dist/bin/weflow-cli.js +675 -78
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/http.d.ts +14 -0
- package/dist/mcp-server/http.d.ts.map +1 -0
- package/dist/mcp-server/http.js +84 -0
- package/dist/mcp-server/http.js.map +1 -0
- package/dist/mcp-server/httpConfig.d.ts +50 -0
- package/dist/mcp-server/httpConfig.d.ts.map +1 -0
- package/dist/mcp-server/httpConfig.js +104 -0
- package/dist/mcp-server/httpConfig.js.map +1 -0
- package/dist/mcp-server/index.d.ts +13 -1
- package/dist/mcp-server/index.d.ts.map +1 -1
- package/dist/mcp-server/index.js +39 -373
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/panel/server.d.ts.map +1 -1
- package/dist/src/panel/server.js +26 -1
- package/dist/src/panel/server.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts +69 -0
- package/dist/src/services/assistantEval.d.ts.map +1 -1
- package/dist/src/services/assistantEval.js +270 -8
- package/dist/src/services/assistantEval.js.map +1 -1
- package/dist/src/services/assistantScenes.d.ts +81 -0
- package/dist/src/services/assistantScenes.d.ts.map +1 -0
- package/dist/src/services/assistantScenes.js +322 -0
- package/dist/src/services/assistantScenes.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +14 -4
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +82 -6
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantSkills.d.ts +94 -0
- package/dist/src/services/assistantSkills.d.ts.map +1 -0
- package/dist/src/services/assistantSkills.js +330 -0
- package/dist/src/services/assistantSkills.js.map +1 -0
- package/dist/src/services/assistantTools.d.ts +7 -0
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +770 -61
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/assistantTrace.d.ts +4 -0
- package/dist/src/services/assistantTrace.d.ts.map +1 -1
- package/dist/src/services/assistantTrace.js +9 -0
- package/dist/src/services/assistantTrace.js.map +1 -1
- package/dist/src/services/configService.d.ts +7 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +4 -2
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/outboundSend.d.ts +53 -0
- package/dist/src/services/outboundSend.d.ts.map +1 -0
- package/dist/src/services/outboundSend.js +90 -0
- package/dist/src/services/outboundSend.js.map +1 -0
- package/dist/src/services/pythonBridge.d.ts.map +1 -1
- package/dist/src/services/pythonBridge.js +1 -0
- package/dist/src/services/pythonBridge.js.map +1 -1
- package/dist/src/utils/python.js +2 -2
- package/dist/src/utils/python.js.map +1 -1
- package/docs/BRANCHES.md +37 -0
- package/docs/DECISIONS.md +350 -0
- package/docs/EXTENDING.md +29 -3
- package/docs/MCP.md +56 -2
- package/docs/PROJECT_STATE.md +121 -9
- package/mcp-server/http.ts +108 -0
- package/mcp-server/httpConfig.ts +126 -0
- package/mcp-server/index.ts +344 -681
- package/package.json +1 -1
- package/resources/panel/ball-position.cjs +48 -2
- package/resources/panel/index.html +6 -4
- package/resources/panel/main.cjs +137 -5
- package/resources/panel/mascot-base.png +0 -0
- package/resources/panel/mascot-iris.png +0 -0
- package/resources/panel/mascot-peek.png +0 -0
- package/resources/panel/panel.css +107 -63
- package/resources/panel/preload.cjs +26 -0
- package/resources/panel/renderer.js +167 -10
- package/scripts/_utils.py +47 -1
- package/scripts/backfill_articles.py +202 -21
- package/scripts/biz_daily.py +8 -31
- package/scripts/bonds.py +331 -0
- package/scripts/chat_notes.py +551 -470
- package/scripts/chat_stats.py +8 -7
- package/scripts/compile_wiki.py +24 -2
- package/scripts/export_chat_html.py +3 -1
- package/scripts/mcp_bridge.py +6 -7
- package/scripts/wechat_voice.py +9 -0
- package/scripts/wiki_lint.py +30 -7
- package/src/panel/server.ts +25 -1
- package/src/services/assistantEval.ts +323 -12
- package/src/services/assistantScenes.ts +342 -0
- package/src/services/assistantService.ts +81 -6
- package/src/services/assistantSkills.ts +364 -0
- package/src/services/assistantTools.ts +738 -59
- package/src/services/assistantTrace.ts +9 -0
- package/src/services/configService.ts +412 -403
- package/src/services/outboundSend.ts +126 -0
- package/src/services/pythonBridge.ts +1 -0
- 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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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[] =
|
|
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[] =
|
|
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(
|
|
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(
|
|
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: '获取公众号日报:
|
|
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: '
|
|
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
|
|
1149
|
-
|
|
1150
|
-
|
|
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
|
-
|
|
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(
|
|
1184
|
-
? readdirSync(
|
|
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
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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(/…/g, '…').replace(/→/g, '→').replace(/—/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
|
}
|