weflow-cli 1.8.2 → 1.10.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/ARCHITECTURE.md +62 -26
- package/CHANGELOG.md +1290 -0
- package/OPERATIONS.md +144 -11
- package/README.en.md +20 -4
- package/README.md +30 -5
- package/bin/weflow-cli-electron.cjs +2 -0
- package/bin/weflow-cli.ts +996 -118
- package/dist/bin/weflow-cli.js +1038 -146
- 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 +58 -374
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/dbPathService.d.ts +12 -0
- package/dist/src/core/dbPathService.d.ts.map +1 -1
- package/dist/src/core/dbPathService.js +20 -3
- package/dist/src/core/dbPathService.js.map +1 -1
- package/dist/src/panel/server.d.ts.map +1 -1
- package/dist/src/panel/server.js +61 -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 +33 -4
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +102 -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 +34 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +931 -88
- 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 +9 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +5 -2
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/messageQuery.d.ts +18 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -1
- package/dist/src/services/messageQuery.js +29 -0
- package/dist/src/services/messageQuery.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/services/wechatClient.d.ts +125 -0
- package/dist/src/services/wechatClient.d.ts.map +1 -0
- package/dist/src/services/wechatClient.js +344 -0
- package/dist/src/services/wechatClient.js.map +1 -0
- package/dist/src/services/wechatMedia.d.ts +44 -0
- package/dist/src/services/wechatMedia.d.ts.map +1 -0
- package/dist/src/services/wechatMedia.js +155 -0
- package/dist/src/services/wechatMedia.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +11 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +55 -11
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/types.d.ts +4 -0
- package/dist/src/types.d.ts.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/CONTACT_DB_SCHEMA.md +1085 -0
- package/docs/DECISIONS.md +1624 -0
- package/docs/EXTENDING.md +98 -10
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +6 -0
- package/docs/HEALTH-CHECK.md +12 -7
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
- package/docs/MCP.md +58 -4
- package/docs/PROJECT_STATE.md +180 -11
- package/docs/RELEASING.md +115 -1
- package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
- package/logo.png +0 -0
- package/mcp-server/http.ts +108 -0
- package/mcp-server/httpConfig.ts +126 -0
- package/mcp-server/index.ts +359 -681
- package/package.json +5 -3
- package/resources/js/graph3d/NOTICE.txt +35 -0
- package/resources/js/graph3d/d3-binarytree.min.js +2 -0
- package/resources/js/graph3d/d3-dispatch.min.js +2 -0
- package/resources/js/graph3d/d3-force-3d.min.js +2 -0
- package/resources/js/graph3d/d3-octree.min.js +2 -0
- package/resources/js/graph3d/d3-quadtree.min.js +2 -0
- package/resources/js/graph3d/d3-timer.min.js +2 -0
- package/resources/js/graph3d/layout.mjs +63 -0
- package/resources/js/graph3d/three.min.js +6 -0
- package/resources/js/graph3d/viewer2d.js +448 -0
- package/resources/panel/ball-position.cjs +48 -2
- package/resources/panel/index.html +18 -4
- package/resources/panel/main.cjs +137 -5
- package/resources/panel/mascot-base.png +0 -0
- package/resources/panel/mascot-blink-1.png +0 -0
- package/resources/panel/mascot-blink-2.png +0 -0
- package/resources/panel/mascot-iris.png +0 -0
- package/resources/panel/mascot-lift-down-1.png +0 -0
- package/resources/panel/mascot-lift-down-2.png +0 -0
- package/resources/panel/mascot-lift-down-3.png +0 -0
- package/resources/panel/mascot-lift-down-4.png +0 -0
- package/resources/panel/mascot-lift-down-5.png +0 -0
- package/resources/panel/mascot-lift-down-6.png +0 -0
- package/resources/panel/mascot-lift-up-1.png +0 -0
- package/resources/panel/mascot-lift-up-2.png +0 -0
- package/resources/panel/mascot-lift-up-3.png +0 -0
- package/resources/panel/mascot-lift-up-4.png +0 -0
- package/resources/panel/mascot-lift-up-5.png +0 -0
- package/resources/panel/mascot-lift-up-6.png +0 -0
- package/resources/panel/mascot-peek.png +0 -0
- package/resources/panel/mascot-tickle-1.png +0 -0
- package/resources/panel/mascot-tickle-2.png +0 -0
- package/resources/panel/mascot-tickle-3.png +0 -0
- package/resources/panel/mascot-tickle-4.png +0 -0
- package/resources/panel/panel.css +205 -59
- package/resources/panel/preload.cjs +26 -0
- package/resources/panel/renderer.js +646 -17
- package/scripts/_utils.py +1203 -931
- package/scripts/backfill_articles.py +202 -21
- package/scripts/biz_daily.py +26 -34
- package/scripts/bonds.py +331 -0
- package/scripts/chat_notes.py +561 -470
- package/scripts/chat_stats.py +8 -7
- package/scripts/check_readme_commands.py +426 -0
- package/scripts/compile_wiki.py +187 -59
- package/scripts/contact_schema.py +339 -0
- package/scripts/data/wechat_emoji.json +225 -0
- package/scripts/export_chat_html.py +3 -1
- package/scripts/fav_notes.py +4 -1
- package/scripts/graph_2d.py +151 -0
- package/scripts/graph_3d.py +646 -0
- package/scripts/health_check.py +163 -26
- package/scripts/mcp_bridge.py +6 -7
- package/scripts/nt_decrypt.py +3 -3
- package/scripts/panel_frames.py +683 -0
- package/scripts/pipeline.py +21 -10
- package/scripts/quality_eval.py +5 -0
- package/scripts/rag_chat.py +33 -16
- package/scripts/reply_debt.py +26 -6
- package/scripts/semantic_search.py +130 -22
- package/scripts/vault_rag.py +42 -16
- package/scripts/vault_search.py +44 -8
- package/scripts/wechat_emoticon.py +6 -0
- package/scripts/wechat_voice.py +9 -0
- package/scripts/wiki_lint.py +124 -24
- package/src/core/dbPathService.ts +22 -4
- package/src/panel/server.ts +58 -1
- package/src/services/assistantEval.ts +323 -12
- package/src/services/assistantScenes.ts +342 -0
- package/src/services/assistantService.ts +107 -6
- package/src/services/assistantSkills.ts +364 -0
- package/src/services/assistantTools.ts +2452 -1640
- package/src/services/assistantTrace.ts +9 -0
- package/src/services/configService.ts +415 -403
- package/src/services/messageQuery.ts +29 -0
- package/src/services/outboundSend.ts +126 -0
- package/src/services/pythonBridge.ts +1 -0
- package/src/services/wechatClient.ts +380 -0
- package/src/services/wechatMedia.ts +166 -0
- package/src/services/wechatMessageService.ts +62 -11
- package/src/types.ts +4 -0
- package/src/utils/python.ts +2 -2
- package/dist/src/utils/wechatEmoji.d.ts +0 -5
- package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
- package/dist/src/utils/wechatEmoji.js +0 -35
- package/dist/src/utils/wechatEmoji.js.map +0 -1
|
@@ -6,24 +6,99 @@ import { chatService } from './chatService.js';
|
|
|
6
6
|
import { configService } from './configService.js';
|
|
7
7
|
import { runPythonJson } from './pythonBridge.js';
|
|
8
8
|
import { exportService } from './exportService.js';
|
|
9
|
+
import { frameLocalData } from './assistantMemory.js';
|
|
10
|
+
import { disabledIds, readSkillBody, scanSkills, skillRoots, skillState } from './assistantSkills.js';
|
|
9
11
|
import { privacyGate } from './assistantPrivacy.js';
|
|
10
12
|
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
11
|
-
import { basename, join } from 'path';
|
|
13
|
+
import { basename, dirname, join } from 'path';
|
|
12
14
|
// 直接调进程的写法已收敛进 pythonBridge:这里不再 import child_process
|
|
13
15
|
import { resolvePackageRoot } from '../utils/packageRoot.js';
|
|
14
16
|
import { clipWithMarker } from '../utils/text.js';
|
|
15
17
|
import { resolveSince, resolveUntil } from '../utils/dateRange.js';
|
|
18
|
+
import { isAllowedWeChatArticleUrl, safeDate } from '../utils/mcpSecurity.js';
|
|
16
19
|
const PKG_ROOT = resolvePackageRoot(import.meta.url);
|
|
17
|
-
|
|
20
|
+
/**
|
|
21
|
+
* 日报目录。**可以用 `WEFLOW_ASSISTANT_BIZ_DAILY_DIR` 指到别处**。
|
|
22
|
+
*
|
|
23
|
+
* 为什么加这个注入点:`get_daily_report` 一直是本文件里**唯一从没被执行过的工具分支**,
|
|
24
|
+
* 原因写在测试文件头上——它读的是模块级常量路径,没有注入点,断言只能写成"没数据/有数据皆可",
|
|
25
|
+
* 那种测试看着像覆盖、其实什么也没钉住。同 `WEFLOW_ASSISTANT_EXPORT_ROOT` 的先例(见 `export_chat`)。
|
|
26
|
+
*/
|
|
27
|
+
// 做成函数而不是模块常量:环境变量在**每次调用**时读,测试才能在同一进程里换夹具
|
|
28
|
+
// (同 `export_chat` 的 `WEFLOW_ASSISTANT_EXPORT_ROOT` 做法)。常量只在 import 时求值,
|
|
29
|
+
// 那会让「目录不存在」这类分支永远测不到——写出来的断言就只能是摆设。
|
|
30
|
+
const bizDailyDir = () => process.env.WEFLOW_ASSISTANT_BIZ_DAILY_DIR || join(PKG_ROOT, 'output', 'biz-daily');
|
|
31
|
+
/** 学习回顾目录。同样可注入:否则 `get_review` 的断言只能依赖这台机器上有没有跑过 `review`。 */
|
|
32
|
+
const reviewsDir = () => process.env.WEFLOW_ASSISTANT_REVIEWS_DIR || join(PKG_ROOT, 'output', 'reviews', 'Daily');
|
|
18
33
|
/** 单条聊天消息进上下文的字数上限(见 get_messages:引用消息要放得下正文+被引原文) */
|
|
19
34
|
const MSG_BODY_CHARS = 160;
|
|
20
35
|
// 知识页住在哪几个目录。**两个是有意的**:文章知识库与聊天知识库分开(用户 2026-09-27
|
|
21
36
|
// 要求),所以读的一方要把**两个都读**——只读一个,分出去的那一半就静默地搜不到。
|
|
22
37
|
// 与 Python 的 `_utils.CONCEPT_DIRS` 是同一份清单,有测试钉住两者一致(跨语言没法共用常量)。
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
38
|
+
/**
|
|
39
|
+
* 总览索引在哪:**在概念目录的上一层**——`Wiki/Concepts/` 的兄弟是 `Wiki/00-Overview.md`。
|
|
40
|
+
*
|
|
41
|
+
* 抽成函数是为了能直接测:这段路径写错过一次(写成 `join(dir, '00-Overview.md')`,于是一直读不到),
|
|
42
|
+
* 而当时那条测试只能断言「有内容或没有」这种形状——真实机器上有索引、CI 上没有,两条路都绿。
|
|
43
|
+
*/
|
|
44
|
+
export function wikiIndexPath(conceptDir) {
|
|
45
|
+
return join(conceptDir, '..', '00-Overview.md');
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* 知识库那两条线的 `Concepts` 目录。**同样可注入**(`WEFLOW_ASSISTANT_VAULT_DIR`),
|
|
49
|
+
* 与上面 `bizDailyDir` / `reviewsDir` 是同一条理由:评测跑在临时家目录里,包内那个
|
|
50
|
+
* `output/wechat-vault/` 必然为空,`get_concepts` 就只能测到「还没编译过」那一支——
|
|
51
|
+
* 要给它写用例,就得能把这个根换掉。(做成函数而不是常量:环境变量要在**每次调用**时读。)
|
|
52
|
+
*/
|
|
53
|
+
const vaultDirs = () => {
|
|
54
|
+
const root = process.env.WEFLOW_ASSISTANT_VAULT_DIR || join(PKG_ROOT, 'output', 'wechat-vault');
|
|
55
|
+
return [join(root, 'Wiki', 'Concepts'), join(root, 'Chat', 'Concepts')];
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* 线的展示名 —— **TS 侧只此一份**。原来有两处各自从路径里找 `Chat` 这个词猜
|
|
59
|
+
* (`wiki_health` 与 `get_vault_stats`),改词的那天只会改到一半。
|
|
60
|
+
*
|
|
61
|
+
* 与 Python 侧 `_utils.LINE_LABELS` 是同一份名单,靠 `test/concept-dirs-agreement.test.ts`
|
|
62
|
+
* 钉住一致(跨语言没法共享一个常量,这个仓库对这类常量的既有做法就是"各写各的、由测试钉住")。
|
|
63
|
+
*
|
|
64
|
+
* **两条线各自一个根之后这里要跟着改**:路径里不会再有 `Wiki`/`Chat` 这一段。
|
|
65
|
+
* 到那时 `wiki_health` 已经直接取 Python 给的 `report.lineLabel`(认得线的目录它都会给),
|
|
66
|
+
* 这里只剩"认不出的目录"这一支要兜底。
|
|
67
|
+
*/
|
|
68
|
+
export const VAULT_LINE_LABELS = { wiki: '文章线', chat: '聊天线' };
|
|
69
|
+
/** 概念目录 → 线 id(`wiki`/`chat`)。按**路径段**认(`…/Wiki/Concepts`);认不出返回 null,不猜。 */
|
|
70
|
+
export function vaultLineId(dir) {
|
|
71
|
+
const segments = dir.split(/[\\/]/).map(s => s.toLowerCase());
|
|
72
|
+
for (const id of Object.keys(VAULT_LINE_LABELS)) {
|
|
73
|
+
if (segments.includes(id))
|
|
74
|
+
return id;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/** 概念目录 → 线的展示名。**认不出线就返回目录名,不猜成某一条**。 */
|
|
79
|
+
export function vaultLineLabel(dir) {
|
|
80
|
+
const id = vaultLineId(dir);
|
|
81
|
+
if (id)
|
|
82
|
+
return VAULT_LINE_LABELS[id];
|
|
83
|
+
return dir.split(/[\\/]/).filter(Boolean).pop() || dir;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 工具参数里的 `line`:`wiki` / `chat` / `all`,不传就是 `wiki`(两条线分开之后,
|
|
87
|
+
* **默认只看文章线**,要合起来看显式给 `all`)。
|
|
88
|
+
*
|
|
89
|
+
* 认不出的值返回 `null` 让调用方**报错**,而不是当 `all`:`line: 'wiki '` 或 `'Wiki'`
|
|
90
|
+
* 悄悄变成"两条线都搜"的话,"我限定住了"就是句假话,而结果里看不出来。
|
|
91
|
+
*/
|
|
92
|
+
export function toolLine(value) {
|
|
93
|
+
const raw = String(value ?? '').trim().toLowerCase();
|
|
94
|
+
if (!raw)
|
|
95
|
+
return 'wiki';
|
|
96
|
+
return raw === 'wiki' || raw === 'chat' || raw === 'all' ? raw : null;
|
|
97
|
+
}
|
|
98
|
+
/** 线 id → 展示名(工具输出里说"搜了哪条"用)。 */
|
|
99
|
+
export function lineLabelOf(line) {
|
|
100
|
+
return line === 'all' ? '两条线' : (VAULT_LINE_LABELS[line] ?? line);
|
|
101
|
+
}
|
|
27
102
|
/**
|
|
28
103
|
* 概念名 → 文件名。**与 `compile_wiki` 是同一套规则**(非法字符换下划线、截到 60 字)。
|
|
29
104
|
*
|
|
@@ -71,21 +146,39 @@ function relatedNames(pagePath) {
|
|
|
71
146
|
* 读不完的图等于没给。截断了要**说出来**(`truncated`),不能默默少给;
|
|
72
147
|
* - **没建页的邻居照旧报数**(`missing`),不静默吞掉。
|
|
73
148
|
*/
|
|
74
|
-
export function conceptSubgraph(pageName, depth = 2, maxNodes = 30, wikiDirs =
|
|
149
|
+
export function conceptSubgraph(pageName, depth = 2, maxNodes = 30, wikiDirs = vaultDirs(), homeDir) {
|
|
75
150
|
// 同 `conceptNeighbors`:传字符串会静默地按字符遍历,所以这里响,不猜。
|
|
76
151
|
if (typeof wikiDirs === 'string') {
|
|
77
152
|
throw new TypeError('conceptSubgraph 的 wikiDirs 要的是目录数组,不是单个字符串');
|
|
78
153
|
}
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
154
|
+
// 邻居可能住在另一个目录(文章概念指向聊天概念、反之亦然),所以查找跨目录。
|
|
155
|
+
// `homeDir` 只影响**起点那一页**:两条线各有一张同名页时,"第一个有这个文件的目录"
|
|
156
|
+
// 永远选出文章线那张,于是聊天线那张根本走不到 —— 而调用方(`search_knowledge`)
|
|
157
|
+
// 正好知道这一趟命中的是哪条线。
|
|
158
|
+
const find = (name, from) => {
|
|
159
|
+
const dirs = from ? [from, ...wikiDirs.filter((d) => d !== from)] : wikiDirs;
|
|
160
|
+
for (const dir of dirs) {
|
|
82
161
|
const candidate = join(dir, safeConceptFile(name));
|
|
83
162
|
if (existsSync(candidate))
|
|
84
163
|
return candidate;
|
|
85
164
|
}
|
|
86
165
|
return null;
|
|
87
166
|
};
|
|
88
|
-
|
|
167
|
+
// **这一趟在哪条线上**:起点由 `homeDir` 定,之后沿 BFS **往下继承** —— 与图谱那边
|
|
168
|
+
// "先在页自己那条线里解析、线里没有才回退到另一条"是**同一条规则**。不继承的话每一跳
|
|
169
|
+
// 都会重新按"第一个有这个文件的目录"选,于是聊天线那一支走着走着就窜到文章线去了。
|
|
170
|
+
const resolved = new Map();
|
|
171
|
+
const resolve = (name, from) => {
|
|
172
|
+
const hit = resolved.get(name);
|
|
173
|
+
if (hit)
|
|
174
|
+
return hit;
|
|
175
|
+
const path = find(name, from);
|
|
176
|
+
if (path)
|
|
177
|
+
resolved.set(name, path);
|
|
178
|
+
return path;
|
|
179
|
+
};
|
|
180
|
+
const start = resolve(pageName, homeDir);
|
|
181
|
+
if (!start)
|
|
89
182
|
return { levels: [], truncated: false, missing: 0 };
|
|
90
183
|
const seen = new Set([pageName]);
|
|
91
184
|
const levels = [];
|
|
@@ -96,13 +189,14 @@ export function conceptSubgraph(pageName, depth = 2, maxNodes = 30, wikiDirs = V
|
|
|
96
189
|
const level = [];
|
|
97
190
|
const next = [];
|
|
98
191
|
for (const from of frontier) {
|
|
99
|
-
const fromPath =
|
|
192
|
+
const fromPath = resolved.get(from) || resolve(from);
|
|
100
193
|
if (!fromPath)
|
|
101
194
|
continue;
|
|
195
|
+
const home = dirname(fromPath);
|
|
102
196
|
for (const name of relatedNames(fromPath)) {
|
|
103
197
|
if (seen.has(name))
|
|
104
198
|
continue;
|
|
105
|
-
const target =
|
|
199
|
+
const target = resolve(name, home);
|
|
106
200
|
if (!target) {
|
|
107
201
|
missing += 1;
|
|
108
202
|
continue;
|
|
@@ -142,7 +236,7 @@ export function conceptBrief(text) {
|
|
|
142
236
|
*
|
|
143
237
|
* 有页的邻居连定义一起给;没有页的只报个数(`missing`),免得模型去追一个取不到的名字。
|
|
144
238
|
*/
|
|
145
|
-
export function conceptNeighbors(pageName, wikiDirs =
|
|
239
|
+
export function conceptNeighbors(pageName, wikiDirs = vaultDirs()) {
|
|
146
240
|
// **传字符串会静默地按字符遍历**(`for (const dir of 'D:\vault')` 挨个字符看过去),
|
|
147
241
|
// 于是 `join('D', '某概念.md')` 不存在,函数安静地回一句「没有邻居」。2026-09-27 把参数
|
|
148
242
|
// 从单个目录改成目录数组时,测试里正好踩到这个——类型拦得住 `src/`(`tsc` 覆盖它),
|
|
@@ -196,12 +290,20 @@ function fmtTime(ts) {
|
|
|
196
290
|
*
|
|
197
291
|
* 与 `fmtTime`(给用户看的工具输出用 `9/23 12:20`)**故意不同**:转录这一行的形状要和
|
|
198
292
|
* Python 侧逐字一致,见 `transcriptLine`。改 `fmtTime` 会连带改用户看到的东西,所以这里单独一个。
|
|
293
|
+
*
|
|
294
|
+
* **时区也是契约的一部分,而且是固定 +8(北京时间),不是本机时区**:Python 那边写的是
|
|
295
|
+
* `TZ = timezone(timedelta(hours=8))`(`scripts/reply_debt.py:39`),同一段对话从面板进来
|
|
296
|
+
* 与从命令行进来必须是同一串字符。用 `getHours()` 取本机时区,在非 +8 的机器上两边会差几小时
|
|
297
|
+
* ——CI(UTC)实测差 8 小时:TS 出 `09-23 04:00`、Python 出 `09-23 12:00`。
|
|
298
|
+
* 中国没有夏令时,固定 +8 是精确的。要改成"跟随本机时区",得连 Python 的欠账天数一起改
|
|
299
|
+
* (那里用同一个 TZ 算"等了多少天"),不是改这一行的事。
|
|
199
300
|
*/
|
|
200
301
|
function transcriptTime(ts) {
|
|
201
302
|
const n = Number(ts);
|
|
202
|
-
const
|
|
303
|
+
const shifted = new Date((n > 1e12 ? n : n * 1000) + 8 * 3600_000);
|
|
203
304
|
const pad = (value) => String(value).padStart(2, '0');
|
|
204
|
-
return `${pad(
|
|
305
|
+
return `${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())} `
|
|
306
|
+
+ `${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}`;
|
|
205
307
|
}
|
|
206
308
|
/** 单条消息进转录的截断长度。**必须等于** `scripts/reply_debt.py` 的 `DRAFT_MSG_CHARS`。 */
|
|
207
309
|
export const TRANSCRIPT_MSG_CHARS = 160;
|
|
@@ -393,16 +495,90 @@ export function extractText(html) {
|
|
|
393
495
|
return stripTags(seg);
|
|
394
496
|
}
|
|
395
497
|
/** 确保数据库已连接 (connect 幂等, 已连接时直接返回) */
|
|
498
|
+
/**
|
|
499
|
+
* 抓页面 HTML:微信内置浏览器 UA + Referer 绕 WAF,最多试两次。`read_favorite` 与 `fetch_article` 共用。
|
|
500
|
+
*
|
|
501
|
+
* `maxRedirects > 0` 时**自己跟跳转并逐跳复检白名单**:默认的 `redirect: 'follow'` 只看入口,
|
|
502
|
+
* 一次 302 就能把白名单绕过去(拿到一个被允许的入口,跳到内网地址)。`fetch_article`
|
|
503
|
+
* 收的是用户/模型给的任意 URL,所以要这道闸;`read_favorite` 收的是用户**自己收藏**的链接,
|
|
504
|
+
* 保持原行为不动(那里用 denylist,且收藏里本来就有各种站)。
|
|
505
|
+
*/
|
|
506
|
+
async function fetchArticleHtml(url, opts = {}) {
|
|
507
|
+
const maxRedirects = opts.maxRedirects ?? 0;
|
|
508
|
+
let current = url;
|
|
509
|
+
let lastStatus = 0;
|
|
510
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
511
|
+
if (maxRedirects > 0 && opts.allowHop && !opts.allowHop(current)) {
|
|
512
|
+
return { status: lastStatus, html: '' }; // 某一跳出了白名单:宁可空手而归
|
|
513
|
+
}
|
|
514
|
+
let html = '';
|
|
515
|
+
let status = 0;
|
|
516
|
+
let followed = false;
|
|
517
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
518
|
+
try {
|
|
519
|
+
const res = await fetch(current, {
|
|
520
|
+
headers: {
|
|
521
|
+
'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',
|
|
522
|
+
'Referer': 'https://mp.weixin.qq.com/',
|
|
523
|
+
'Accept': 'text/html,application/xhtml+xml',
|
|
524
|
+
'Accept-Language': 'zh-CN,zh;q=0.9',
|
|
525
|
+
},
|
|
526
|
+
...(maxRedirects > 0 ? { redirect: 'manual' } : {}),
|
|
527
|
+
signal: AbortSignal.timeout(20_000),
|
|
528
|
+
});
|
|
529
|
+
status = res.status;
|
|
530
|
+
if (maxRedirects > 0 && status >= 300 && status < 400) {
|
|
531
|
+
const location = res.headers.get('location');
|
|
532
|
+
if (!location)
|
|
533
|
+
return { status, html: '' };
|
|
534
|
+
current = new URL(location, current).toString();
|
|
535
|
+
followed = true;
|
|
536
|
+
break; // 换一跳,重来
|
|
537
|
+
}
|
|
538
|
+
html = (await res.text()).slice(0, 500_000);
|
|
539
|
+
// 正文验证: 必须含 js_content (否则是验证页, 重试)
|
|
540
|
+
if (res.ok && html.includes('js_content'))
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
catch { /* 超时/网络错误 → 重试 */ }
|
|
544
|
+
}
|
|
545
|
+
if (html)
|
|
546
|
+
return { status, html };
|
|
547
|
+
lastStatus = status;
|
|
548
|
+
// 没跟跳转就走完了两次尝试:就是抓不到,别再拿同一个地址重试下去
|
|
549
|
+
if (!followed)
|
|
550
|
+
return { status, html: '' };
|
|
551
|
+
}
|
|
552
|
+
return { status: lastStatus, html: '' };
|
|
553
|
+
}
|
|
554
|
+
/** 读响应体但设上限:第三方搜索页可能很大,不让它把内存吃掉(超了就截断,不报错) */
|
|
555
|
+
async function readTextLimited(response, maxBytes) {
|
|
556
|
+
const buf = await response.arrayBuffer();
|
|
557
|
+
return Buffer.from(buf).subarray(0, maxBytes).toString('utf8');
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* 从抓到的 HTML 里取正文:优先正常页面的 `js_content`,取不到再走 WAF 挑战页的 `content_noencode`。
|
|
561
|
+
* 返回的文本已按 `maxChars` 截断。
|
|
562
|
+
*/
|
|
563
|
+
function articleTextFromHtml(html, maxChars) {
|
|
564
|
+
let text = html.includes('js_content') ? extractText(html) : '';
|
|
565
|
+
if (!text || (text.match(/[一-鿿]/g) || []).length < 50) {
|
|
566
|
+
const decoded = extractFromChallengePage(html);
|
|
567
|
+
if (decoded)
|
|
568
|
+
text = stripTags(decoded);
|
|
569
|
+
}
|
|
570
|
+
return text.slice(0, maxChars);
|
|
571
|
+
}
|
|
396
572
|
async function ensureDb() {
|
|
397
573
|
await chatService.connect();
|
|
398
574
|
}
|
|
399
575
|
/** 最近一份**有内容**的日报是哪天,以及那是几天前。算不出来就回空串——不猜。 */
|
|
400
576
|
function lastDailyNote() {
|
|
401
577
|
try {
|
|
402
|
-
const days = readdirSync(
|
|
578
|
+
const days = readdirSync(bizDailyDir())
|
|
403
579
|
.filter(name => /^\d{4}-\d{2}-\d{2}$/.test(name)).sort().reverse().slice(0, 30);
|
|
404
580
|
for (const day of days) {
|
|
405
|
-
const file = join(
|
|
581
|
+
const file = join(bizDailyDir(), day, '.articles.json');
|
|
406
582
|
if (!existsSync(file))
|
|
407
583
|
continue;
|
|
408
584
|
const payload = JSON.parse(readFileSync(file, 'utf8'));
|
|
@@ -417,6 +593,14 @@ function lastDailyNote() {
|
|
|
417
593
|
}
|
|
418
594
|
class ToolInputError extends Error {
|
|
419
595
|
}
|
|
596
|
+
/**
|
|
597
|
+
* 把待办 id 露给模型。**不露出来它就指不到具体哪一条**——`set_todo_status` 只能靠 id
|
|
598
|
+
* 或唯一的任务文字定位,而任务文字常常是长句、几条之间又很像。
|
|
599
|
+
* 给完整 id(CLI 那边支持 id 前缀,但前缀要唯一才安全,这里不赌)。
|
|
600
|
+
*/
|
|
601
|
+
function idTag(t) {
|
|
602
|
+
return t?.id ? ` [id: ${t.id}]` : '';
|
|
603
|
+
}
|
|
420
604
|
export function boundedToolInteger(value, fallback, maximum, field = 'limit') {
|
|
421
605
|
if (value === undefined || value === null || value === '')
|
|
422
606
|
return fallback;
|
|
@@ -571,13 +755,19 @@ export const TOOL_DEFS = [
|
|
|
571
755
|
type: 'function',
|
|
572
756
|
function: {
|
|
573
757
|
name: 'get_daily_report',
|
|
574
|
-
description: '获取公众号日报:
|
|
758
|
+
description: '获取公众号日报: 推送了哪些文章(标题/来源/分类/AI摘要)。'
|
|
759
|
+
+ '适合「今天/某天公众号推了什么」「最近有哪些AI文章」「搜一下关于X的文章」类问题。'
|
|
760
|
+
+ '**给 date 就是那一天的;不给 date 只给关键词/分类就是跨日期搜**(默认最近 7 天,用 days 调)。'
|
|
761
|
+
+ '要当天那份**人读的日报正文**(不是条目清单)加 full。',
|
|
575
762
|
parameters: {
|
|
576
763
|
type: 'object',
|
|
577
764
|
properties: {
|
|
578
|
-
date: { type: 'string', description: '日期 YYYY-MM-DD
|
|
765
|
+
date: { type: 'string', description: '日期 YYYY-MM-DD;省略则最新一期(或配合关键词跨日期搜)' },
|
|
579
766
|
topic: { type: 'string', description: '分类过滤: AI | 学术 | 新闻 | 文学 | 投资' },
|
|
580
|
-
keyword: { type: 'string', description: '
|
|
767
|
+
keyword: { type: 'string', description: '标题/摘要/来源关键词。不给 date 时跨日期搜' },
|
|
768
|
+
days: { type: 'number', description: '跨日期搜的时间窗(天), 默认 7, 上限 60' },
|
|
769
|
+
full: { type: 'boolean', description: '返回当天日报的正文(人读的那份),而不是条目清单' },
|
|
770
|
+
max_chars: { type: 'number', description: 'full 时正文上限, 默认 3000, 上限 6000' },
|
|
581
771
|
limit: { type: 'number', description: '返回条数, 默认15' },
|
|
582
772
|
},
|
|
583
773
|
},
|
|
@@ -601,12 +791,16 @@ export const TOOL_DEFS = [
|
|
|
601
791
|
type: 'function',
|
|
602
792
|
function: {
|
|
603
793
|
name: 'get_weread',
|
|
604
|
-
description: '
|
|
794
|
+
description: '查看用户微信读书数据。mode: shelf(书架) | notebooks(有笔记的书) | search(按书名搜) '
|
|
795
|
+
+ '| stats(近期阅读时长) | book(某本书详情+我的进度) | review(某本书的热门书评) '
|
|
796
|
+
+ '| discover(推荐/相似书) | profile(累计阅读画像)。适合「我在读什么书」「某本书讲什么」「读了多少」类问题。'
|
|
797
|
+
+ 'book / review 需要先用 search 拿到 book_id。',
|
|
605
798
|
parameters: {
|
|
606
799
|
type: 'object',
|
|
607
800
|
properties: {
|
|
608
|
-
mode: { type: 'string', description: 'shelf | notebooks | search, 默认 shelf' },
|
|
801
|
+
mode: { type: 'string', description: 'shelf | notebooks | search | stats | book | review | discover | profile, 默认 shelf' },
|
|
609
802
|
keyword: { type: 'string', description: 'search 模式的书名关键词' },
|
|
803
|
+
book_id: { type: 'string', description: 'book / review 模式的书籍 id(search 或 shelf 的结果里有)' },
|
|
610
804
|
},
|
|
611
805
|
},
|
|
612
806
|
},
|
|
@@ -615,11 +809,13 @@ export const TOOL_DEFS = [
|
|
|
615
809
|
type: 'function',
|
|
616
810
|
function: {
|
|
617
811
|
name: 'get_todos',
|
|
618
|
-
description: '查看用户从聊天记录提取的待办任务清单(含优先级和截止时间)。适合「我最近有什么待办」「有什么紧急的事」类问题。'
|
|
812
|
+
description: '查看用户从聊天记录提取的待办任务清单(含优先级和截止时间)。适合「我最近有什么待办」「有什么紧急的事」类问题。'
|
|
813
|
+
+ '每条后面方括号里的 id 用来改某一条的状态(set_todo_status 需要它)。按紧急度分组看用 group_by。',
|
|
619
814
|
parameters: {
|
|
620
815
|
type: 'object',
|
|
621
816
|
properties: {
|
|
622
817
|
status: { type: 'string', description: 'pending | done, 默认 pending' },
|
|
818
|
+
group_by: { type: 'string', description: '传 urgency 就按紧急度分组列出' },
|
|
623
819
|
},
|
|
624
820
|
},
|
|
625
821
|
},
|
|
@@ -702,6 +898,12 @@ export const TOOL_DEFS = [
|
|
|
702
898
|
type: 'object',
|
|
703
899
|
properties: {
|
|
704
900
|
query: { type: 'string', description: '要找的意思,用自然语言描述' },
|
|
901
|
+
line: {
|
|
902
|
+
type: 'string',
|
|
903
|
+
description: '搜哪一条知识库:wiki=公众号文章线与聊天记录里的文章(默认)、'
|
|
904
|
+
+ 'chat=你的微信聊天记录、all=两条都搜。**默认只搜 wiki**,'
|
|
905
|
+
+ '问"我们聊过什么"「上次说的那个」这类一定要给 chat 或 all,否则会搜不到。',
|
|
906
|
+
},
|
|
705
907
|
top_k: { type: 'number', description: '要几条,默认 8,上限 20' },
|
|
706
908
|
confirm: {
|
|
707
909
|
type: 'boolean',
|
|
@@ -734,7 +936,8 @@ export const TOOL_DEFS = [
|
|
|
734
936
|
type: 'function',
|
|
735
937
|
function: {
|
|
736
938
|
name: 'search_knowledge',
|
|
737
|
-
description: '
|
|
939
|
+
description: '搜索**本地知识库**的概念页(公众号文章沉淀的是一条线、你自己的微信对话沉淀的是另一条),'
|
|
940
|
+
+ '适合查概念解释、找之前整理过的知识。**默认只搜文章那条线**,`line` 可以换成聊天那条或两条都搜。'
|
|
738
941
|
+ '**返回时会一并给出这一页连到的相关概念(每个一句定义)**——可以顺着它们再用本工具逐跳查下去,'
|
|
739
942
|
+ '这样一次追问能走开一小块知识,而不是停在一个孤立页面上。'
|
|
740
943
|
+ '(聊天里说过什么用 search_chats;助手记得的关于你的事用 search_memory)',
|
|
@@ -742,6 +945,13 @@ export const TOOL_DEFS = [
|
|
|
742
945
|
type: 'object',
|
|
743
946
|
properties: {
|
|
744
947
|
keyword: { type: 'string', description: '概念名或关键词, 如 RAG、Agent' },
|
|
948
|
+
line: {
|
|
949
|
+
type: 'string',
|
|
950
|
+
description: '搜哪一条知识库:wiki=公众号文章沉淀的概念(默认)、'
|
|
951
|
+
+ 'chat=你自己的微信对话沉淀的概念、all=两条都搜。'
|
|
952
|
+
+ '**默认只搜 wiki**;问"我们聊过的某个概念"要给 chat 或 all。'
|
|
953
|
+
+ '同一个概念名两条线可能各有一张页(那是设计),返回里会指明给的是哪一条。',
|
|
954
|
+
},
|
|
745
955
|
depth: { type: 'integer', description: '沿「相关概念」走几跳:默认 1 只给直接邻居,2 能一次看到这个概念周围的生态(最多 3)' },
|
|
746
956
|
},
|
|
747
957
|
required: ['keyword'],
|
|
@@ -752,7 +962,8 @@ export const TOOL_DEFS = [
|
|
|
752
962
|
type: 'function',
|
|
753
963
|
function: {
|
|
754
964
|
name: 'get_stats',
|
|
755
|
-
description: '
|
|
965
|
+
description: '本地数据总览:会话数、收藏总数、日报文章数与覆盖天数、主题分布、概念页数。'
|
|
966
|
+
+ '适合「我一共有多少数据」这类总览问题;单看公众号推送用 get_reading_stats。',
|
|
756
967
|
parameters: { type: 'object', properties: {} },
|
|
757
968
|
},
|
|
758
969
|
},
|
|
@@ -805,6 +1016,172 @@ export const TOOL_DEFS = [
|
|
|
805
1016
|
},
|
|
806
1017
|
},
|
|
807
1018
|
},
|
|
1019
|
+
{
|
|
1020
|
+
type: 'function',
|
|
1021
|
+
function: {
|
|
1022
|
+
name: 'list_skills',
|
|
1023
|
+
description: '列出本机装好的技能包(名字+一句话说明+版本)。技能是一份说明文档,'
|
|
1024
|
+
+ '用 read_skill 读它的正文再照着做;不要凭名字猜它怎么用。',
|
|
1025
|
+
parameters: {
|
|
1026
|
+
type: 'object',
|
|
1027
|
+
properties: {
|
|
1028
|
+
query: { type: 'string', description: '可选:按关键词过滤(匹配 id、名称、说明和触发词)' },
|
|
1029
|
+
},
|
|
1030
|
+
},
|
|
1031
|
+
},
|
|
1032
|
+
},
|
|
1033
|
+
{
|
|
1034
|
+
type: 'function',
|
|
1035
|
+
function: {
|
|
1036
|
+
name: 'read_skill',
|
|
1037
|
+
description: '读取某个技能包的完整说明(SKILL.md 正文)。要用某个技能前先读它。'
|
|
1038
|
+
+ 'id 用 list_skills 查到的那个。',
|
|
1039
|
+
parameters: {
|
|
1040
|
+
type: 'object',
|
|
1041
|
+
properties: {
|
|
1042
|
+
id: { type: 'string', description: '技能 id,如 wechat-article-extract' },
|
|
1043
|
+
},
|
|
1044
|
+
required: ['id'],
|
|
1045
|
+
},
|
|
1046
|
+
},
|
|
1047
|
+
},
|
|
1048
|
+
{
|
|
1049
|
+
type: 'function',
|
|
1050
|
+
function: {
|
|
1051
|
+
name: 'list_contacts',
|
|
1052
|
+
description: '列出通讯录里的联系人(显示名、备注、昵称)。**不含 wxid 等本机标识**,'
|
|
1053
|
+
+ '只有能叫得出名字的人。'
|
|
1054
|
+
+ '(要看最近跟谁聊过用 list_sessions;要按名字查某个会话的聊天用 get_messages)',
|
|
1055
|
+
parameters: {
|
|
1056
|
+
type: 'object',
|
|
1057
|
+
properties: {
|
|
1058
|
+
keyword: { type: 'string', description: '按名字过滤(可省略)' },
|
|
1059
|
+
limit: { type: 'number', description: '最多几条, 默认 50, 上限 200' },
|
|
1060
|
+
},
|
|
1061
|
+
},
|
|
1062
|
+
},
|
|
1063
|
+
},
|
|
1064
|
+
{
|
|
1065
|
+
type: 'function',
|
|
1066
|
+
function: {
|
|
1067
|
+
name: 'get_review',
|
|
1068
|
+
description: '读某一天的学习回顾(对当天日报文章做的二次整理:要点、联系、值得回看的)。'
|
|
1069
|
+
+ '**与 get_daily_report 不是一回事**:那个是当天日报的原始条目清单,这个是复盘。',
|
|
1070
|
+
parameters: {
|
|
1071
|
+
type: 'object',
|
|
1072
|
+
properties: {
|
|
1073
|
+
date: { type: 'string', description: '日期 YYYY-MM-DD,省略则取最近一份' },
|
|
1074
|
+
},
|
|
1075
|
+
},
|
|
1076
|
+
},
|
|
1077
|
+
},
|
|
1078
|
+
{
|
|
1079
|
+
type: 'function',
|
|
1080
|
+
function: {
|
|
1081
|
+
name: 'get_concepts',
|
|
1082
|
+
description: '读知识库的两份总览索引(文章线、聊天线各一份)。这是**目录**,'
|
|
1083
|
+
+ '用来回答"我的知识库里都有什么";要按名字找**某一页**的内容用 search_knowledge。',
|
|
1084
|
+
parameters: {
|
|
1085
|
+
type: 'object',
|
|
1086
|
+
properties: {
|
|
1087
|
+
limit: { type: 'number', description: '每份索引最多返回多少行, 默认 40' },
|
|
1088
|
+
},
|
|
1089
|
+
},
|
|
1090
|
+
},
|
|
1091
|
+
},
|
|
1092
|
+
{
|
|
1093
|
+
type: 'function',
|
|
1094
|
+
function: {
|
|
1095
|
+
name: 'fetch_article',
|
|
1096
|
+
description: '抓取一篇公众号文章的正文(需要完整链接)。只接受 https 的 mp.weixin.qq.com 链接。'
|
|
1097
|
+
+ '与 read_favorite 的区别:那个从你自己的收藏里找,这个由用户给出链接。',
|
|
1098
|
+
parameters: {
|
|
1099
|
+
type: 'object',
|
|
1100
|
+
properties: {
|
|
1101
|
+
url: { type: 'string', description: '公众号文章完整链接' },
|
|
1102
|
+
max_chars: { type: 'number', description: '正文上限, 默认 3000, 上限 6000' },
|
|
1103
|
+
},
|
|
1104
|
+
required: ['url'],
|
|
1105
|
+
},
|
|
1106
|
+
},
|
|
1107
|
+
},
|
|
1108
|
+
{
|
|
1109
|
+
type: 'function',
|
|
1110
|
+
function: {
|
|
1111
|
+
name: 'search_public',
|
|
1112
|
+
description: '在公开的公众号文章里按关键词搜标题与摘要(走第三方搜索页)。'
|
|
1113
|
+
+ '只搜公开文章,搜不到你自己的收藏、聊天记录或知识库。',
|
|
1114
|
+
parameters: {
|
|
1115
|
+
type: 'object',
|
|
1116
|
+
properties: {
|
|
1117
|
+
keyword: { type: 'string', description: '搜索词' },
|
|
1118
|
+
limit: { type: 'number', description: '最多几条, 默认 8, 上限 20' },
|
|
1119
|
+
},
|
|
1120
|
+
required: ['keyword'],
|
|
1121
|
+
},
|
|
1122
|
+
},
|
|
1123
|
+
},
|
|
1124
|
+
{
|
|
1125
|
+
type: 'function',
|
|
1126
|
+
function: {
|
|
1127
|
+
name: 'format_article',
|
|
1128
|
+
description: '把 Markdown 转成能直接粘进公众号编辑器的 HTML 片段(纯本地转换,不联网、不写文件)。'
|
|
1129
|
+
+ '主题名用 list_themes 查。',
|
|
1130
|
+
parameters: {
|
|
1131
|
+
type: 'object',
|
|
1132
|
+
properties: {
|
|
1133
|
+
// 参数名保持 `content` —— 这个名字在 MCP 那条路上已经被外部客户端用了,
|
|
1134
|
+
// 改名的代价是别人的调用直接失效(工具名不变、参数变了,最难查的那种坏法)
|
|
1135
|
+
content: { type: 'string', description: '要排版的 Markdown 正文' },
|
|
1136
|
+
theme: { type: 'string', description: '主题名(省略用 default)' },
|
|
1137
|
+
},
|
|
1138
|
+
required: ['content'],
|
|
1139
|
+
},
|
|
1140
|
+
},
|
|
1141
|
+
},
|
|
1142
|
+
{
|
|
1143
|
+
type: 'function',
|
|
1144
|
+
function: {
|
|
1145
|
+
name: 'list_themes',
|
|
1146
|
+
description: '列出可用的公众号排版主题(配合 format_article 用)。',
|
|
1147
|
+
parameters: { type: 'object', properties: {} },
|
|
1148
|
+
},
|
|
1149
|
+
},
|
|
1150
|
+
{
|
|
1151
|
+
type: 'function',
|
|
1152
|
+
function: {
|
|
1153
|
+
name: 'lint_wiki',
|
|
1154
|
+
description: '知识库体检:断链、孤儿页、空页、同名页。**只读**,不改任何页。',
|
|
1155
|
+
parameters: { type: 'object', properties: {} },
|
|
1156
|
+
},
|
|
1157
|
+
},
|
|
1158
|
+
{
|
|
1159
|
+
type: 'function',
|
|
1160
|
+
function: {
|
|
1161
|
+
name: 'check_skills',
|
|
1162
|
+
description: '技能体检:报出读不出来的 SKILL.md、跨目录同名、id 不合规范、被禁用的技能。'
|
|
1163
|
+
+ '与 list_skills 的区别:那个是清单,这个是**问题清单**。',
|
|
1164
|
+
parameters: { type: 'object', properties: {} },
|
|
1165
|
+
},
|
|
1166
|
+
},
|
|
1167
|
+
{
|
|
1168
|
+
type: 'function',
|
|
1169
|
+
function: {
|
|
1170
|
+
name: 'set_todo_status',
|
|
1171
|
+
description: '把一条待办标记为已完成 / 取消完成。**不能删除待办、不能新增**。'
|
|
1172
|
+
+ '要精确指到那一条:优先用 get_todos 给出的 id;也可以给一段任务文字,'
|
|
1173
|
+
+ '但**匹配到零条或多条都会被拒绝**(猜错条目比拒绝更糟)。',
|
|
1174
|
+
parameters: {
|
|
1175
|
+
type: 'object',
|
|
1176
|
+
properties: {
|
|
1177
|
+
id: { type: 'string', description: '待办 id(get_todos 输出里的那个)' },
|
|
1178
|
+
task: { type: 'string', description: '或用任务文字精确匹配(必须唯一)' },
|
|
1179
|
+
status: { type: 'string', description: 'done = 已完成, pending = 取消完成' },
|
|
1180
|
+
},
|
|
1181
|
+
required: ['status'],
|
|
1182
|
+
},
|
|
1183
|
+
},
|
|
1184
|
+
},
|
|
808
1185
|
];
|
|
809
1186
|
/**
|
|
810
1187
|
* MCP 那条路看得见的工具表。
|
|
@@ -825,6 +1202,10 @@ export const TOOL_DEFS = [
|
|
|
825
1202
|
export const MCP_EXCLUDED = {
|
|
826
1203
|
save_memory: 'MCP 没有微信用户身份,挂的记忆桶是单独一条,让它写没有意义',
|
|
827
1204
|
look_at_image: 'MCP 只把工具返回的文本交给客户端,它挂进侧信道的图没人接,回话会变成一句假话',
|
|
1205
|
+
// 这条是**故意留在外面的**(docs/MCP.md 的天花板:「mutating todos ... stay out」):
|
|
1206
|
+
// 助手在微信/面板里可以替你翻转一条待办状态,但外部 MCP 客户端不行——
|
|
1207
|
+
// 那条路上没有"你",只有调用方;一条机器指令顺手改掉你的待办,不该是默认能力。
|
|
1208
|
+
set_todo_status: '待办是本地状态,MCP 那条路上没有用户可确认;改状态请在微信/面板里说,或用 CLI 的两段式 todos done',
|
|
828
1209
|
};
|
|
829
1210
|
export const MCP_TOOL_DEFS = TOOL_DEFS.filter(tool => !(tool.function.name in MCP_EXCLUDED));
|
|
830
1211
|
/** 脚本类工具的失败回话:桥接层已经分好类(超时/退出码/没有 JSON/脚本自己报错),
|
|
@@ -1116,38 +1497,12 @@ export async function executeTool(name, args, ctx) {
|
|
|
1116
1497
|
}
|
|
1117
1498
|
if (!isSafeUrl(art.link))
|
|
1118
1499
|
return `(链接不安全, 拒绝抓取: ${art.link.slice(0, 60)})`;
|
|
1119
|
-
// 微信内置浏览器 UA + Referer 绕 WAF
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1123
|
-
try {
|
|
1124
|
-
const res = await fetch(art.link, {
|
|
1125
|
-
headers: {
|
|
1126
|
-
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.38(0x18002633) NetType/WIFI Language/zh_CN',
|
|
1127
|
-
'Referer': 'https://mp.weixin.qq.com/',
|
|
1128
|
-
'Accept': 'text/html,application/xhtml+xml',
|
|
1129
|
-
'Accept-Language': 'zh-CN,zh;q=0.9',
|
|
1130
|
-
},
|
|
1131
|
-
signal: AbortSignal.timeout(20_000),
|
|
1132
|
-
});
|
|
1133
|
-
status = res.status;
|
|
1134
|
-
html = (await res.text()).slice(0, 500_000);
|
|
1135
|
-
// 正文验证: 必须含 js_content (否则是验证页, 重试)
|
|
1136
|
-
if (res.ok && html.includes('js_content'))
|
|
1137
|
-
break;
|
|
1138
|
-
}
|
|
1139
|
-
catch { /* 超时/网络错误 → 重试 */ }
|
|
1140
|
-
}
|
|
1500
|
+
// 微信内置浏览器 UA + Referer 绕 WAF(与 biz_daily.py 同策略);
|
|
1501
|
+
// 这段与 fetch_article 是同一份实现——两处各写一份必然会漂
|
|
1502
|
+
const { status, html } = await fetchArticleHtml(art.link);
|
|
1141
1503
|
if (!html)
|
|
1142
1504
|
return `(抓取失败 HTTP ${status})`;
|
|
1143
|
-
|
|
1144
|
-
let text = html.includes('js_content') ? extractText(html) : '';
|
|
1145
|
-
if (!text || (text.match(/[\u4e00-\u9fff]/g) || []).length < 50) {
|
|
1146
|
-
const decoded = extractFromChallengePage(html);
|
|
1147
|
-
if (decoded)
|
|
1148
|
-
text = stripTags(decoded);
|
|
1149
|
-
}
|
|
1150
|
-
text = text.slice(0, maxChars);
|
|
1505
|
+
let text = articleTextFromHtml(html, maxChars);
|
|
1151
1506
|
if (html.includes('已被发布者删除'))
|
|
1152
1507
|
return `(「${art.title}」已被发布者删除)`;
|
|
1153
1508
|
if (!text.trim() || (text.match(/[\u4e00-\u9fff]/g) || []).length < 20) {
|
|
@@ -1156,17 +1511,76 @@ export async function executeTool(name, args, ctx) {
|
|
|
1156
1511
|
return `「${art.title || '无标题'}」(${art.source_name || '未知来源'}) 正文:\n${text}`;
|
|
1157
1512
|
}
|
|
1158
1513
|
case 'get_daily_report': {
|
|
1159
|
-
const dates = existsSync(
|
|
1160
|
-
? readdirSync(
|
|
1514
|
+
const dates = existsSync(bizDailyDir())
|
|
1515
|
+
? readdirSync(bizDailyDir()).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse()
|
|
1161
1516
|
: [];
|
|
1162
1517
|
if (!dates.length)
|
|
1163
1518
|
return '(还没有日报数据, 先运行 biz_daily 生成)';
|
|
1164
|
-
const
|
|
1165
|
-
|
|
1166
|
-
|
|
1519
|
+
const readArticles = (date) => {
|
|
1520
|
+
const file = join(bizDailyDir(), date, '.articles.json');
|
|
1521
|
+
if (!existsSync(file))
|
|
1522
|
+
return null;
|
|
1523
|
+
try {
|
|
1524
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
1525
|
+
return Array.isArray(raw) ? raw : (raw.articles || Object.values(raw));
|
|
1526
|
+
}
|
|
1527
|
+
catch {
|
|
1528
|
+
return null;
|
|
1529
|
+
}
|
|
1530
|
+
};
|
|
1531
|
+
const fmt = (a) => {
|
|
1532
|
+
const summary = (a.summary || '').replace(/\s+/g, ' ').slice(0, 80);
|
|
1533
|
+
const where = a.date ? `${a.date} ` : '';
|
|
1534
|
+
return `· ${where}[${a.topic}] ${a.title} (${a.source})${summary ? `\n ${summary}` : ''}`;
|
|
1535
|
+
};
|
|
1536
|
+
const limit = boundedToolInteger(args.limit, 15, 30);
|
|
1537
|
+
// 不给 date 但给了关键词 → 跨日期搜(MCP 那边原本叫 search_articles,两个工具搜的
|
|
1538
|
+
// 是同一批文章、只是范围不同,模型无从判断该用哪个,所以合并成一个)。
|
|
1539
|
+
const wantDate = args.date ? safeDate(args.date) : null;
|
|
1540
|
+
if (args.date && !wantDate)
|
|
1541
|
+
return `(日期格式不对: ${args.date},要 YYYY-MM-DD)`;
|
|
1542
|
+
if (!wantDate && (args.keyword || args.topic)) {
|
|
1543
|
+
const days = boundedToolInteger(args.days, 7, 60);
|
|
1544
|
+
const window = dates.slice(0, days);
|
|
1545
|
+
const hits = [];
|
|
1546
|
+
let scanned = 0;
|
|
1547
|
+
for (const d of window) {
|
|
1548
|
+
const arts = readArticles(d);
|
|
1549
|
+
if (!arts)
|
|
1550
|
+
continue;
|
|
1551
|
+
scanned += arts.length;
|
|
1552
|
+
for (const a of arts) {
|
|
1553
|
+
if (args.topic && a.topic !== args.topic)
|
|
1554
|
+
continue;
|
|
1555
|
+
if (args.keyword) {
|
|
1556
|
+
const kw = String(args.keyword).toLowerCase();
|
|
1557
|
+
const hay = `${a.title || ''} ${a.summary || ''} ${a.source || ''}`.toLowerCase();
|
|
1558
|
+
if (!hay.includes(kw))
|
|
1559
|
+
continue;
|
|
1560
|
+
}
|
|
1561
|
+
hits.push({ ...a, date: d });
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
if (!hits.length) {
|
|
1565
|
+
return `(最近 ${window.length} 天的日报里(共 ${scanned} 篇)没有匹配`
|
|
1566
|
+
+ `${args.keyword ? `「${args.keyword}」` : `主题 ${args.topic}`} 的文章)`;
|
|
1567
|
+
}
|
|
1568
|
+
return `最近 ${window.length} 天里匹配 ${hits.length} 篇(共扫 ${scanned} 篇):\n`
|
|
1569
|
+
+ hits.slice(0, limit).map(fmt).join('\n');
|
|
1570
|
+
}
|
|
1571
|
+
const date = wantDate || dates[0];
|
|
1572
|
+
const all = readArticles(date);
|
|
1573
|
+
if (!all)
|
|
1167
1574
|
return `(${date} 没有日报。可用日期: ${dates.slice(0, 5).join(', ')})`;
|
|
1168
|
-
|
|
1169
|
-
|
|
1575
|
+
// `full`:给的是当天那份人读的日报正文(README.md),不是条目清单。
|
|
1576
|
+
// 不加这个参数,模型想"把那天的日报念一段"就只能拿到标题列表。
|
|
1577
|
+
if (args.full) {
|
|
1578
|
+
const readme = join(bizDailyDir(), date, 'README.md');
|
|
1579
|
+
if (!existsSync(readme))
|
|
1580
|
+
return `(${date} 没有日报正文文件,只有条目)`;
|
|
1581
|
+
const maxChars = boundedToolInteger(args.max_chars, 3000, 6000);
|
|
1582
|
+
return `${date} 日报正文(${all.length} 篇):\n${readFileSync(readme, 'utf8').slice(0, maxChars)}`;
|
|
1583
|
+
}
|
|
1170
1584
|
let list = all;
|
|
1171
1585
|
if (args.topic)
|
|
1172
1586
|
list = list.filter(a => a.topic === args.topic);
|
|
@@ -1174,7 +1588,6 @@ export async function executeTool(name, args, ctx) {
|
|
|
1174
1588
|
const kw = String(args.keyword).toLowerCase();
|
|
1175
1589
|
list = list.filter(a => (a.title || '').toLowerCase().includes(kw) || (a.summary || '').toLowerCase().includes(kw));
|
|
1176
1590
|
}
|
|
1177
|
-
const limit = boundedToolInteger(args.limit, 15, 30);
|
|
1178
1591
|
if (!list.length)
|
|
1179
1592
|
return `(${date} 日报共 ${all.length} 篇, 过滤后无匹配)`;
|
|
1180
1593
|
const byTopic = {};
|
|
@@ -1182,10 +1595,7 @@ export async function executeTool(name, args, ctx) {
|
|
|
1182
1595
|
byTopic[a.topic] = (byTopic[a.topic] || 0) + 1;
|
|
1183
1596
|
const topicStat = Object.entries(byTopic).map(([t, n]) => `${t}${n}篇`).join(' ');
|
|
1184
1597
|
return `${date} 日报共 ${all.length} 篇 (${topicStat}), 匹配 ${list.length} 篇:\n` +
|
|
1185
|
-
list.slice(0, limit).map(
|
|
1186
|
-
const summary = (a.summary || '').replace(/\s+/g, ' ').slice(0, 80);
|
|
1187
|
-
return `· [${a.topic}] ${a.title} (${a.source})${summary ? `\n ${summary}` : ''}`;
|
|
1188
|
-
}).join('\n');
|
|
1598
|
+
list.slice(0, limit).map(fmt).join('\n');
|
|
1189
1599
|
}
|
|
1190
1600
|
case 'get_sns': {
|
|
1191
1601
|
await ensureDb();
|
|
@@ -1240,6 +1650,60 @@ export async function executeTool(name, args, ctx) {
|
|
|
1240
1650
|
return `有笔记的书 ${r.data.books.length} 本:\n` +
|
|
1241
1651
|
r.data.books.slice(0, 15).map((b) => `· ${titleOf(b)} (${authorOf(b)}) — ${b.noteCount || 0} 条笔记`).join('\n');
|
|
1242
1652
|
}
|
|
1653
|
+
if (mode === 'profile') {
|
|
1654
|
+
const r = await wereadService.profile();
|
|
1655
|
+
if (!r.ok || !r.data)
|
|
1656
|
+
return `(读不到阅读画像: ${r.error || ''})`;
|
|
1657
|
+
const p = r.data;
|
|
1658
|
+
return `阅读画像: ${p.name || '(未给昵称)'}\n`
|
|
1659
|
+
+ `累计阅读 ${Math.round((p.totalReadTime || 0) / 60)} 分钟、${p.totalReadDays || 0} 天\n`
|
|
1660
|
+
+ `书架 ${p.totalBooks || 0} 本,读完 ${p.totalFinished || 0} 本`;
|
|
1661
|
+
}
|
|
1662
|
+
if (mode === 'stats') {
|
|
1663
|
+
const r = await wereadService.readData('monthly');
|
|
1664
|
+
if (!r.ok || !r.data)
|
|
1665
|
+
return `(读不到阅读统计: ${r.error || ''})`;
|
|
1666
|
+
const d = r.data;
|
|
1667
|
+
const top = (d.readLongest || []).slice(0, 5).map((x) => `· ${x?.book?.title || x?.albumInfo?.title || '(未知)'} ${Math.round((x?.readTime || 0) / 60)} 分钟`);
|
|
1668
|
+
return `近期阅读 ${Math.round((d.totalReadTime || 0) / 60)} 分钟、读了 ${d.readDays || 0} 天`
|
|
1669
|
+
+ `,日均 ${Math.round((d.dayAverageReadTime || 0) / 60)} 分钟`
|
|
1670
|
+
+ (top.length ? `\n读得最久的:\n${top.join('\n')}` : '');
|
|
1671
|
+
}
|
|
1672
|
+
if (mode === 'book') {
|
|
1673
|
+
const bookId = String(args.book_id || '').trim();
|
|
1674
|
+
if (!bookId)
|
|
1675
|
+
return '(book 模式需要 book_id —— 先用 search 模式按书名找到它)';
|
|
1676
|
+
const info = await wereadService.bookInfo(bookId);
|
|
1677
|
+
if (!info.ok || !info.data)
|
|
1678
|
+
return `(读不到这本书: ${info.error || ''})`;
|
|
1679
|
+
const b = info.data;
|
|
1680
|
+
const prog = await wereadService.getProgress(bookId);
|
|
1681
|
+
const pct = prog.ok && prog.data
|
|
1682
|
+
? `\n我的进度: ${prog.data.progress}%${prog.data.chapterTitle ? `(${prog.data.chapterTitle})` : ''}`
|
|
1683
|
+
: '';
|
|
1684
|
+
return `《${b.title}》${b.author || ''}\n评分 ${b.rating ?? '未知'}(${b.ratingCount || 0} 人)`
|
|
1685
|
+
+ `| ${b.category || '未分类'} | ${b.wordCount || '未知'}字${pct}`;
|
|
1686
|
+
}
|
|
1687
|
+
if (mode === 'review') {
|
|
1688
|
+
const bookId = String(args.book_id || '').trim();
|
|
1689
|
+
if (!bookId)
|
|
1690
|
+
return '(review 模式需要 book_id —— 先用 search 模式按书名找到它)';
|
|
1691
|
+
const r = await wereadService.reviews(bookId, 5);
|
|
1692
|
+
if (!r.ok || !r.data?.reviews?.length)
|
|
1693
|
+
return `(这本书还没有热门书评: ${r.error || ''})`;
|
|
1694
|
+
return `热门书评 ${r.data.reviews.length} 条:\n`
|
|
1695
|
+
+ r.data.reviews.map((v) => `· ${String(v.content || '').replace(/\s+/g, ' ').slice(0, 120)}`
|
|
1696
|
+
+ `(${v.user?.name || '匿名'},${v.rating ? `评 ${v.rating} 分` : '未评分'},${v.likeCount || 0} 赞)`).join('\n');
|
|
1697
|
+
}
|
|
1698
|
+
if (mode === 'discover') {
|
|
1699
|
+
const bookId = String(args.book_id || '').trim();
|
|
1700
|
+
const r = bookId ? await wereadService.similar(bookId, 6) : await wereadService.recommend(8);
|
|
1701
|
+
if (!r.ok || !r.data?.books?.length)
|
|
1702
|
+
return `(读不到推荐: ${r.error || ''})`;
|
|
1703
|
+
return (bookId ? '与这本书相似的' : '为你推荐的') + `书 ${r.data.books.length} 本:\n`
|
|
1704
|
+
+ r.data.books.map((b) => `· 《${b.title}》${b.author || ''}${b.rating ? ` 评分 ${b.rating}` : ''}`
|
|
1705
|
+
+ (b.intro ? `\n ${String(b.intro).replace(/\s+/g, ' ').slice(0, 80)}` : '')).join('\n');
|
|
1706
|
+
}
|
|
1243
1707
|
if (mode === 'search') {
|
|
1244
1708
|
const kw = String(args.keyword || '');
|
|
1245
1709
|
if (!kw)
|
|
@@ -1281,8 +1745,19 @@ export async function executeTool(name, args, ctx) {
|
|
|
1281
1745
|
}
|
|
1282
1746
|
return `(没有${status === 'done' ? '已完成' : '待办'}任务)`;
|
|
1283
1747
|
}
|
|
1748
|
+
// 分组:`todos remind` 其实就是同一个文件按紧急度重排,所以不另开一个工具,
|
|
1749
|
+
// 加个参数就够(多一个近义工具只会让模型在两者之间瞎挑)
|
|
1750
|
+
if (String(args.group_by || '') === 'urgency') {
|
|
1751
|
+
const order = { 高: 0, 中: 1, 低: 2 };
|
|
1752
|
+
const sorted = [...todos].sort((a, b) => (order[a.urgency] ?? 3) - (order[b.urgency] ?? 3));
|
|
1753
|
+
const groups = {};
|
|
1754
|
+
for (const t of sorted)
|
|
1755
|
+
(groups[t.urgency || '未标'] = groups[t.urgency || '未标'] || []).push(t);
|
|
1756
|
+
return `${status === 'done' ? '已完成' : '待办'} ${todos.length} 项(按紧急度分组):\n` +
|
|
1757
|
+
Object.entries(groups).map(([u, list]) => `【${u}】${list.length} 项\n` + list.slice(0, 10).map((t) => `· ${t.task || t.content || t.text || t.title}${t.deadline && t.deadline !== '未提及' ? ` (截止 ${t.deadline})` : ''}${idTag(t)}`).join('\n')).join('\n');
|
|
1758
|
+
}
|
|
1284
1759
|
return `${status === 'done' ? '已完成' : '待办'} ${todos.length} 项:\n` +
|
|
1285
|
-
todos.slice(0, 15).map((t) => `· [${t.urgency || '中'}] ${t.task || t.content || t.text || t.title}${t.deadline && t.deadline !== '未提及' ? ` (截止 ${t.deadline})` : ''}`).join('\n');
|
|
1760
|
+
todos.slice(0, 15).map((t) => `· [${t.urgency || '中'}] ${t.task || t.content || t.text || t.title}${t.deadline && t.deadline !== '未提及' ? ` (截止 ${t.deadline})` : ''}${idTag(t)}`).join('\n');
|
|
1286
1761
|
}
|
|
1287
1762
|
case 'search_chats': {
|
|
1288
1763
|
// 跨会话检索:本机 message_fts 索引 + 判断模型挑查询词(**只发候选词与问题,不发聊天内容**)
|
|
@@ -1454,6 +1929,9 @@ export async function executeTool(name, args, ctx) {
|
|
|
1454
1929
|
const query = String(args.query || '').trim();
|
|
1455
1930
|
if (!query)
|
|
1456
1931
|
return '(缺少 query 参数)';
|
|
1932
|
+
const line = toolLine(args.line);
|
|
1933
|
+
if (!line)
|
|
1934
|
+
return `(line 只能是 wiki / chat / all,收到「${String(args.line)}」)`;
|
|
1457
1935
|
const topK = boundedToolInteger(args.top_k, 8, 20, 'top_k');
|
|
1458
1936
|
// 外部机器默认只给预览(`semantic_search.py` 没有 --dry-run,按入参说清)
|
|
1459
1937
|
if (ctx.requiresConfirm && args.confirm !== true) {
|
|
@@ -1461,13 +1939,26 @@ export async function executeTool(name, args, ctx) {
|
|
|
1461
1939
|
+ `再把前 ${topK} 条命中的本地片段(可能含聊天或文章原文)发给判断模型 Jev 重排。`);
|
|
1462
1940
|
}
|
|
1463
1941
|
// **查询词走环境变量、不进 argv**:仓库写进测试的隐私纪律(进程列表里看不到正文)
|
|
1464
|
-
const result = await runPythonJson('semantic_search.py', ['search', '--top-k', String(topK)], { env: { WEFLOW_SEARCH_QUERY: query }, timeoutMs: 90_000 });
|
|
1942
|
+
const result = await runPythonJson('semantic_search.py', ['search', '--top-k', String(topK), '--line', line], { env: { WEFLOW_SEARCH_QUERY: query }, timeoutMs: 90_000 });
|
|
1465
1943
|
if (!result.ok)
|
|
1466
1944
|
return fail('语义检索失败(索引可能还没建:weflow-cli search-index)', result);
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1945
|
+
if (result.data && typeof result.data.error === 'string') {
|
|
1946
|
+
return `(语义检索没跑成:${String(result.data.error).slice(0, 120)})`;
|
|
1947
|
+
}
|
|
1948
|
+
if (!result.data || !Array.isArray(result.data.results)) {
|
|
1949
|
+
// **形状不对要响,不能降级成"没有结果"**:脚本那边 `search` 的输出是一个带
|
|
1950
|
+
// `line` / `results` 的对象,而"读不到"和"搜不到"在界面上长得一模一样
|
|
1951
|
+
// (`(语义检索没有结果)`)—— 那正是这个仓库一直在防的那种静默。
|
|
1952
|
+
return `(语义检索返回了看不懂的形状:${JSON.stringify(result.data).slice(0, 120)})`;
|
|
1953
|
+
}
|
|
1954
|
+
const rows = result.data.results;
|
|
1955
|
+
const note = String(result.data.note || '');
|
|
1956
|
+
const scope = String(result.data.lineLabel || lineLabelOf(line));
|
|
1957
|
+
if (!rows.length) {
|
|
1958
|
+
return `(语义检索在${scope}里没有结果「${query}」${note ? ':' + note : ''}`
|
|
1959
|
+
+ `${line === 'all' ? ')' : ';`line: \'all\'` 两条线一起搜)'}`;
|
|
1960
|
+
}
|
|
1961
|
+
return `语义检索「${query}」(范围:${scope})前 ${rows.length} 条:` + String.fromCharCode(10) + rows.map((row) => {
|
|
1471
1962
|
const title = String(row.title || row.source || '(无标题)').slice(0, 40);
|
|
1472
1963
|
const score = typeof row.score === 'number' ? `(${row.score.toFixed(2)})` : '';
|
|
1473
1964
|
const text = privacyGate.maskMessageBody(String(row.text || row.content || '').replace(/\s+/g, ' ').slice(0, 80));
|
|
@@ -1516,16 +2007,24 @@ export async function executeTool(name, args, ctx) {
|
|
|
1516
2007
|
const kw = String(args.keyword || '');
|
|
1517
2008
|
if (!kw)
|
|
1518
2009
|
return '(缺少 keyword 参数)';
|
|
1519
|
-
const
|
|
1520
|
-
if (!
|
|
2010
|
+
const line = toolLine(args.line);
|
|
2011
|
+
if (!line)
|
|
2012
|
+
return `(line 只能是 wiki / chat / all,收到「${String(args.line)}」)`;
|
|
2013
|
+
const all = vaultDirs().filter(dir => existsSync(dir));
|
|
2014
|
+
if (!all.length)
|
|
1521
2015
|
return '(知识库尚未生成, 先运行 weflow-cli wiki compile)';
|
|
1522
|
-
//
|
|
1523
|
-
//
|
|
2016
|
+
// **默认只搜文章线**(两条线分开是用户要求的);`line: 'all'` 才两条一起搜。
|
|
2017
|
+
// 范围一定要写在输出里:不说的话,"这一条线里没有"会被读成"整个知识库里没有"。
|
|
2018
|
+
const liveDirs = line === 'all' ? all : all.filter(dir => vaultLineId(dir) === line);
|
|
2019
|
+
if (!liveDirs.length) {
|
|
2020
|
+
return `(${lineLabelOf(line)}还没有概念页——知识库里现在只有${all.map(vaultLineLabel).join('、')};`
|
|
2021
|
+
+ `要看那些用 line: 'all')`;
|
|
2022
|
+
}
|
|
1524
2023
|
const entries = [];
|
|
1525
2024
|
let total = 0;
|
|
1526
2025
|
for (const dir of liveDirs) {
|
|
1527
2026
|
// 标注来源,好让模型(和读日志的人)知道这条来自哪个知识库
|
|
1528
|
-
const where =
|
|
2027
|
+
const where = vaultLineId(dir) === 'chat' ? '聊天' : '文章';
|
|
1529
2028
|
for (const file of readdirSync(dir).filter(f => f.endsWith('.md'))) {
|
|
1530
2029
|
entries.push({ dir, file, where });
|
|
1531
2030
|
total += 1;
|
|
@@ -1545,24 +2044,34 @@ export async function executeTool(name, args, ctx) {
|
|
|
1545
2044
|
const at = text.toLowerCase().indexOf(kw.toLowerCase());
|
|
1546
2045
|
if (at === -1)
|
|
1547
2046
|
return null;
|
|
1548
|
-
|
|
2047
|
+
// (局部变量别叫 `line`:外层那个 `line` 是"搜哪条知识库",同名会把它遮住)
|
|
2048
|
+
const snippetLine = text.slice(Math.max(0, at - 80), at + 160)
|
|
1549
2049
|
.split('\n').find(l => l.includes(kw)) || '';
|
|
1550
|
-
return { ...e, snippet:
|
|
2050
|
+
return { ...e, snippet: snippetLine.trim().slice(0, 180) };
|
|
1551
2051
|
}).filter((e) => e !== null)
|
|
1552
2052
|
.slice(0, 5);
|
|
1553
|
-
if (!contentHits.length)
|
|
1554
|
-
|
|
1555
|
-
|
|
2053
|
+
if (!contentHits.length) {
|
|
2054
|
+
// **说清是哪个范围里没收录**:只写"知识库未收录"会被读成整个知识库都没有
|
|
2055
|
+
return `(${lineLabelOf(line)}没收录「${kw}」, 这一趟看了 ${total} 个概念页`
|
|
2056
|
+
+ (line === 'all' ? ')' : ';`line: \'all\'` 可以连另一条线一起搜)');
|
|
2057
|
+
}
|
|
2058
|
+
return `正文提及「${kw}」的概念页(${lineLabelOf(line)}):` + '\n'
|
|
1556
2059
|
+ contentHits.map(e => `· ${e.file.replace('.md', '')}(${e.where}): ${e.snippet}`).join('\n');
|
|
1557
2060
|
}
|
|
1558
2061
|
const hit = hits[0];
|
|
1559
2062
|
const name = hit.file.replace('.md', '');
|
|
1560
2063
|
const page = readFileSync(join(hit.dir, hit.file), 'utf8');
|
|
2064
|
+
// 两条线各有一张同名页是**设计**(两个命名空间分开)。只给第一条会让模型以为另一条
|
|
2065
|
+
// 没有这个概念 —— 而它可能正是用户问的那一条("我们聊过的"vs"公众号里读到的")。
|
|
2066
|
+
const alsoIn = hits.filter(h => h.where !== hit.where).map(h => h.where);
|
|
2067
|
+
const alsoNote = alsoIn.length ? `(另一个同名页在${alsoIn.join('、')}知识库:line: 'all' 两条都看)` : '';
|
|
1561
2068
|
// **顺带把邻居给出去**(见 `conceptNeighbors`):一次调用拿到"这个概念 + 连着它的一小块子图"。
|
|
1562
2069
|
// 不点明"可以再用本工具查其中任意一个"的话,模型会把它当装饰性文字读过去。
|
|
1563
2070
|
const depth = boundedToolInteger(args.depth, 1, 3, 'depth');
|
|
1564
|
-
|
|
1565
|
-
|
|
2071
|
+
// **从命中的那一条线开始**(`hit.dir`):同名概念两条线各一张,不指定起点的话
|
|
2072
|
+
// 永远走文章线那张 —— 而上面刚刚才告诉模型"另一条线也有这个概念"
|
|
2073
|
+
const { levels, truncated, missing } = conceptSubgraph(name, depth, 30, vaultDirs(), hit.dir);
|
|
2074
|
+
const out = [`「${name}」概念页(${hit.where}知识库)${alsoNote}:`, page.slice(0, 2000)];
|
|
1566
2075
|
if (levels.length) {
|
|
1567
2076
|
out.push('', depth > 1
|
|
1568
2077
|
? `—— 它周围的概念(${depth} 跳;「←」标出是从哪个概念连过来的)——`
|
|
@@ -1576,7 +2085,7 @@ export async function executeTool(name, args, ctx) {
|
|
|
1576
2085
|
});
|
|
1577
2086
|
// 封顶了要**说出来**:默默少给的话,模型会以为这就是全部
|
|
1578
2087
|
if (truncated)
|
|
1579
|
-
out.push(`(图太大,只给了前 ${levels.flat().length} 个概念;
|
|
2088
|
+
out.push(`(图太大,只给了前 ${levels.flat().length} 个概念;
|
|
1580
2089
|
想看某个分支可以单独查它)`);
|
|
1581
2090
|
}
|
|
1582
2091
|
if (missing)
|
|
@@ -1587,7 +2096,48 @@ export async function executeTool(name, args, ctx) {
|
|
|
1587
2096
|
await ensureDb();
|
|
1588
2097
|
const sessions = await chatService.listSessions(undefined, 1000);
|
|
1589
2098
|
const fav = await chatService.getFavorites({ limit: 1 });
|
|
1590
|
-
|
|
2099
|
+
const lines = [`会话数: ${sessions.length}`, `收藏总数: ${fav.success ? fav.total : '未知'}`];
|
|
2100
|
+
// 知识库那一半:MCP 的手写 get_stats 一直有、助手这边缺。同一个工具名在两个界面
|
|
2101
|
+
// 报的东西不一样,是最容易让人误判成数据丢了的那种不一致。
|
|
2102
|
+
const dates = existsSync(bizDailyDir())
|
|
2103
|
+
? readdirSync(bizDailyDir()).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort()
|
|
2104
|
+
: [];
|
|
2105
|
+
let articleCount = 0;
|
|
2106
|
+
const topics = {};
|
|
2107
|
+
for (const d of dates) {
|
|
2108
|
+
const file = join(bizDailyDir(), d, '.articles.json');
|
|
2109
|
+
if (!existsSync(file))
|
|
2110
|
+
continue;
|
|
2111
|
+
try {
|
|
2112
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
2113
|
+
const arts = Array.isArray(raw) ? raw : (raw.articles || []);
|
|
2114
|
+
articleCount += arts.length;
|
|
2115
|
+
for (const a of arts)
|
|
2116
|
+
topics[a.topic || '未知'] = (topics[a.topic || '未知'] || 0) + 1;
|
|
2117
|
+
}
|
|
2118
|
+
catch { /* 某一天的文件坏了不该让整个统计失败 */ }
|
|
2119
|
+
}
|
|
2120
|
+
if (dates.length) {
|
|
2121
|
+
lines.push(`日报文章: ${articleCount} 篇,覆盖 ${dates.length} 天(${dates[0]} ~ ${dates[dates.length - 1]})`);
|
|
2122
|
+
const top = Object.entries(topics).sort((a, b) => b[1] - a[1]).slice(0, 6);
|
|
2123
|
+
if (top.length)
|
|
2124
|
+
lines.push(`主题分布: ${top.map(([t, n]) => `${t}${n}`).join('、')}`);
|
|
2125
|
+
}
|
|
2126
|
+
else {
|
|
2127
|
+
lines.push('日报文章: 还没有日报数据');
|
|
2128
|
+
}
|
|
2129
|
+
const conceptCounts = [];
|
|
2130
|
+
for (const dir of vaultDirs()) {
|
|
2131
|
+
const idx = wikiIndexPath(dir);
|
|
2132
|
+
if (!existsSync(idx))
|
|
2133
|
+
continue;
|
|
2134
|
+
const m = readFileSync(idx, 'utf8').match(/共\s*(\d+)\s*个概念/);
|
|
2135
|
+
if (m)
|
|
2136
|
+
conceptCounts.push(`${vaultLineLabel(dir)} ${m[1]}`);
|
|
2137
|
+
}
|
|
2138
|
+
if (conceptCounts.length)
|
|
2139
|
+
lines.push(`概念页: ${conceptCounts.join('、')}`);
|
|
2140
|
+
return lines.join('\n');
|
|
1591
2141
|
}
|
|
1592
2142
|
case 'get_reading_stats': {
|
|
1593
2143
|
const days = boundedToolInteger(args.days, 7, 90, 'days');
|
|
@@ -1626,6 +2176,299 @@ export async function executeTool(name, args, ctx) {
|
|
|
1626
2176
|
const added = ctx.memory.addFact(ctx.userId, content);
|
|
1627
2177
|
return added ? '(已存入长期记忆)' : '(与已有记忆重复, 未保存)';
|
|
1628
2178
|
}
|
|
2179
|
+
case 'list_skills': {
|
|
2180
|
+
const scan = scanSkills();
|
|
2181
|
+
const q = String(args.query || '').trim().toLowerCase();
|
|
2182
|
+
const hit = (s) => !q || [s.id, s.name, s.description, ...s.triggers].some(x => String(x).toLowerCase().includes(q));
|
|
2183
|
+
const rows = scan.skills.filter(hit);
|
|
2184
|
+
if (!rows.length) {
|
|
2185
|
+
// 一个技能都没有时**说清是"没装"还是"目录配错了"**:后者会把用户引到错的地方找
|
|
2186
|
+
if (!scan.skills.length) {
|
|
2187
|
+
return `本机没有扫描到技能。技能根目录:${skillRoots().join('、')}`
|
|
2188
|
+
+ (scan.missingRoots.length ? `(其中这些目录不存在:${scan.missingRoots.join('、')})` : '');
|
|
2189
|
+
}
|
|
2190
|
+
return `没有匹配「${args.query}」的技能(本机共有 ${scan.skills.length} 个)`;
|
|
2191
|
+
}
|
|
2192
|
+
const disabled = disabledIds();
|
|
2193
|
+
const lines = rows.map(s => {
|
|
2194
|
+
const state = skillState(s, disabled);
|
|
2195
|
+
const tag = state === 'disabled' ? '[已禁用] ' : '';
|
|
2196
|
+
const ver = s.version ? ` v${s.version}` : '';
|
|
2197
|
+
return `· ${tag}${s.id}${ver}:${s.name} —— ${s.description || '(无描述)'}`;
|
|
2198
|
+
});
|
|
2199
|
+
lines.push(`(共 ${rows.length} 个,正文用 read_skill 读)`);
|
|
2200
|
+
return lines.join('\n');
|
|
2201
|
+
}
|
|
2202
|
+
case 'read_skill': {
|
|
2203
|
+
const id = String(args.id || '').trim();
|
|
2204
|
+
if (!id)
|
|
2205
|
+
return '(参数错误: 缺少技能 id)';
|
|
2206
|
+
const body = readSkillBody(id);
|
|
2207
|
+
if (!body.ok)
|
|
2208
|
+
return body.text;
|
|
2209
|
+
// 技能正文是**磁盘上的内容**,可能含 `</weflow-local-data>` —— 必须包起来再进上下文
|
|
2210
|
+
return frameLocalData(`skill.${id}`, body.text);
|
|
2211
|
+
}
|
|
2212
|
+
case 'list_contacts': {
|
|
2213
|
+
await ensureDb();
|
|
2214
|
+
const keyword = String(args.keyword || '').trim();
|
|
2215
|
+
const limit = boundedToolInteger(args.limit, 50, 200);
|
|
2216
|
+
const contacts = await chatService.listContacts(keyword || undefined, limit);
|
|
2217
|
+
if (!contacts.length) {
|
|
2218
|
+
return keyword
|
|
2219
|
+
? `(通讯录里没有匹配「${keyword}」的名字)`
|
|
2220
|
+
: '(通讯录是空的或读不到——可以用 check 看本机数据源的状态)';
|
|
2221
|
+
}
|
|
2222
|
+
// **只给名字**:`username` 是 wxid,`avatarUrl` 里也可能带本机标识,
|
|
2223
|
+
// 本仓的纪律是本机标识不进模型上下文(同 speakerLabel 那边的处理)
|
|
2224
|
+
const names = [];
|
|
2225
|
+
for (const c of contacts) {
|
|
2226
|
+
const n = String(c.remark || c.displayName || c.nickname || '').trim();
|
|
2227
|
+
if (n && !names.includes(n))
|
|
2228
|
+
names.push(n);
|
|
2229
|
+
}
|
|
2230
|
+
if (!names.length)
|
|
2231
|
+
return '(通讯录读到了, 但都没有可显示的名字)';
|
|
2232
|
+
return `通讯录里 ${names.length} 个名字:\n` + names.slice(0, limit).map(n => `· ${n}`).join('\n');
|
|
2233
|
+
}
|
|
2234
|
+
case 'get_review': {
|
|
2235
|
+
const dir = reviewsDir();
|
|
2236
|
+
if (!existsSync(dir)) {
|
|
2237
|
+
// 说清"这个目录本来就还没生成",而不是让模型以为"今天没有回顾"
|
|
2238
|
+
return '(还没有学习回顾——它不是日报自动产的,要单独跑 `weflow-cli review`)';
|
|
2239
|
+
}
|
|
2240
|
+
const files = readdirSync(dir).filter(f => /^Daily-\d{4}-\d{2}-\d{2}\.md$/.test(f)).sort().reverse();
|
|
2241
|
+
if (!files.length)
|
|
2242
|
+
return '(学习回顾目录是空的)';
|
|
2243
|
+
const want = args.date ? safeDate(args.date) : null;
|
|
2244
|
+
if (args.date && !want)
|
|
2245
|
+
return `(日期格式不对: ${args.date},要 YYYY-MM-DD)`;
|
|
2246
|
+
const pick = want ? files.find(f => f.includes(want)) : files[0];
|
|
2247
|
+
if (!pick) {
|
|
2248
|
+
return `(${want} 没有学习回顾。最近几份: ${files.slice(0, 3).map(f => f.slice(6, 16)).join(', ')})`;
|
|
2249
|
+
}
|
|
2250
|
+
const maxChars = boundedToolInteger(args.max_chars, 3000, 6000);
|
|
2251
|
+
const body = readFileSync(join(dir, pick), 'utf8');
|
|
2252
|
+
const date = pick.slice(6, 16);
|
|
2253
|
+
return `${date} 学习回顾:\n${body.slice(0, maxChars)}`;
|
|
2254
|
+
}
|
|
2255
|
+
case 'get_concepts': {
|
|
2256
|
+
const limit = boundedToolInteger(args.limit, 40, 120);
|
|
2257
|
+
const out = [];
|
|
2258
|
+
for (const dir of vaultDirs()) {
|
|
2259
|
+
const line = dir.split(/[\\/]/).includes('Chat') ? '聊天线' : '文章线';
|
|
2260
|
+
const index = wikiIndexPath(dir);
|
|
2261
|
+
if (!existsSync(index)) {
|
|
2262
|
+
out.push(`【${line}】还没有总览索引(还没编译过这一线的知识库)`);
|
|
2263
|
+
continue;
|
|
2264
|
+
}
|
|
2265
|
+
const text = readFileSync(index, 'utf8');
|
|
2266
|
+
const rows = text.split(/\r?\n/).filter(l => l.trim()).slice(0, limit);
|
|
2267
|
+
out.push(`【${line}】${rows.length} 行:\n${rows.join('\n')}`);
|
|
2268
|
+
}
|
|
2269
|
+
return out.join('\n\n');
|
|
2270
|
+
}
|
|
2271
|
+
case 'format_article': {
|
|
2272
|
+
const content = String(args.content || '');
|
|
2273
|
+
if (!content.trim())
|
|
2274
|
+
return '(参数错误: content 不能为空)';
|
|
2275
|
+
const { formatWeChatArticle } = await import('./wechat-formatter.js');
|
|
2276
|
+
const theme = String(args.theme || 'default');
|
|
2277
|
+
const html = formatWeChatArticle(content, { theme: theme });
|
|
2278
|
+
const preview = html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').slice(0, 200);
|
|
2279
|
+
return `主题: ${theme}\n总长度: ${html.length} 字符\n正文预览: ${preview}…\n---\n${html}`;
|
|
2280
|
+
}
|
|
2281
|
+
case 'list_themes': {
|
|
2282
|
+
const { listThemes } = await import('./wechat-formatter.js');
|
|
2283
|
+
const themes = listThemes();
|
|
2284
|
+
if (!themes.length)
|
|
2285
|
+
return '(没有可用主题)';
|
|
2286
|
+
return `可用主题 ${themes.length} 个:\n`
|
|
2287
|
+
+ themes.map(t => `· ${t.id} — ${t.name}: ${t.description}`).join('\n');
|
|
2288
|
+
}
|
|
2289
|
+
case 'lint_wiki': {
|
|
2290
|
+
// 两个知识库都要查(本仓的分线纪律:只查一个,另一半的断链永远看不见)
|
|
2291
|
+
const parts = [];
|
|
2292
|
+
let totalBad = 0;
|
|
2293
|
+
// **真正体检过几条线。"跳过"不算"没问题"**:CI 上(检出里没有 `output/wechat-vault`)
|
|
2294
|
+
// 两条线都只打「目录不存在,跳过」,而下面那一支原来照样会加一句"两个知识库都没有断链…"
|
|
2295
|
+
// —— 没看过却说干净。用户没编译过知识库时也会撞上同一句话。
|
|
2296
|
+
let checked = 0;
|
|
2297
|
+
for (const dir of vaultDirs()) {
|
|
2298
|
+
// **线名优先由 Python 那边给**(`report.lineLabel`,源头是 `_utils.LINE_LABELS`):
|
|
2299
|
+
// 两条线各自一个根之后聊天线很可能不再叫 `Chat`,按路径猜会把聊天线报成
|
|
2300
|
+
// **文章线**,还不报错。`guess` 只在目录不存在(来不及问 Python)与自定义
|
|
2301
|
+
// `--dir` 认不出线时兜底。
|
|
2302
|
+
const guess = vaultLineLabel(dir);
|
|
2303
|
+
if (!existsSync(dir)) {
|
|
2304
|
+
parts.push(`【${guess}】目录不存在,跳过`);
|
|
2305
|
+
continue;
|
|
2306
|
+
}
|
|
2307
|
+
// 超时给 60 秒:本机最大的一条线(3,623 页)实测 **3.4 秒**(脚本里的 O(n²) 修掉之后,
|
|
2308
|
+
// 见 wiki_lint.py 那条注释),17 倍余量够用。默认的 30 秒曾经也超——那是因为脚本本身慢,
|
|
2309
|
+
// 不是超时给得小。
|
|
2310
|
+
const r = await runPythonJson('wiki_lint.py', ['--dir', dir, '--json'], { timeoutMs: 60_000 });
|
|
2311
|
+
if (!r.ok || !r.data) {
|
|
2312
|
+
// 失败要说出来:静默跳过会让"体检通过"变成一句假话
|
|
2313
|
+
parts.push(`【${guess}】体检失败: ${r.error || '读不到结果'}`);
|
|
2314
|
+
totalBad += 1;
|
|
2315
|
+
continue;
|
|
2316
|
+
}
|
|
2317
|
+
const d = r.data;
|
|
2318
|
+
const line = d.lineLabel || guess;
|
|
2319
|
+
const broken = (d.broken || []).length;
|
|
2320
|
+
const orphans = (d.orphans || []).length;
|
|
2321
|
+
const empty = (d.empty || []).length;
|
|
2322
|
+
const dup = Object.keys(d.duplicateTitles || {}).length;
|
|
2323
|
+
// 跨线同名**不计入问题数**:两条线各留一张同名页是设计(两个命名空间分开),
|
|
2324
|
+
// 把它算成"要修的"会让人去修一个设计如此的东西。
|
|
2325
|
+
const cross = (d.crossLineSameName || []).length;
|
|
2326
|
+
checked += 1;
|
|
2327
|
+
totalBad += broken + orphans + empty + dup;
|
|
2328
|
+
parts.push(`【${line}】${d.pages || 0} 张页:断链 ${broken}、孤儿 ${orphans}、空页 ${empty}、同名 ${dup} 组`
|
|
2329
|
+
+ (cross ? `(另有 ${cross} 个概念两条线都有,设计如此)` : ''));
|
|
2330
|
+
}
|
|
2331
|
+
return (checked && !totalBad ? '两个知识库都没有断链/孤儿/空页/同名。\n' : '') + parts.join('\n');
|
|
2332
|
+
}
|
|
2333
|
+
case 'check_skills': {
|
|
2334
|
+
const scan = scanSkills();
|
|
2335
|
+
const disabled = disabledIds();
|
|
2336
|
+
const lines = [`技能目录: ${skillRoots().join('、')}`, `扫描到 ${scan.skills.length} 个技能`];
|
|
2337
|
+
for (const dir of scan.missingRoots)
|
|
2338
|
+
lines.push(`· 目录不存在: ${dir}`);
|
|
2339
|
+
if (scan.unreadable.length) {
|
|
2340
|
+
lines.push(`读不出来的 ${scan.unreadable.length} 个(frontmatter 缺失或不闭合):`);
|
|
2341
|
+
for (const u of scan.unreadable)
|
|
2342
|
+
lines.push(`· ${u.file} —— ${u.reason}`);
|
|
2343
|
+
}
|
|
2344
|
+
if (scan.conflicts.length) {
|
|
2345
|
+
lines.push(`跨目录同名的 ${scan.conflicts.length} 个(靠前的目录胜出): ${scan.conflicts.map(c => c.id).join('、')}`);
|
|
2346
|
+
}
|
|
2347
|
+
const warned = scan.skills.filter(s => s.notes.length);
|
|
2348
|
+
if (warned.length) {
|
|
2349
|
+
lines.push(`有提醒的 ${warned.length} 个:`);
|
|
2350
|
+
for (const w of warned)
|
|
2351
|
+
for (const n of w.notes)
|
|
2352
|
+
lines.push(`· ${w.id}: ${n}`);
|
|
2353
|
+
}
|
|
2354
|
+
const off = scan.skills.filter(s => skillState(s, disabled) === 'disabled').map(s => s.id);
|
|
2355
|
+
if (off.length)
|
|
2356
|
+
lines.push(`已禁用: ${off.join('、')}`);
|
|
2357
|
+
if (!scan.unreadable.length && !scan.conflicts.length && !warned.length) {
|
|
2358
|
+
lines.push('没有读不出来或撞名的技能。');
|
|
2359
|
+
}
|
|
2360
|
+
return lines.join('\n');
|
|
2361
|
+
}
|
|
2362
|
+
case 'fetch_article': {
|
|
2363
|
+
const url = String(args.url || '').trim();
|
|
2364
|
+
// **严格白名单,不是 denylist**:只认 https 的 mp.weixin.qq.com。
|
|
2365
|
+
// 这条比 read_favorite 那条严,因为那里的链接是用户自己收藏的,这里的 URL 是别人给的。
|
|
2366
|
+
if (!isAllowedWeChatArticleUrl(url)) {
|
|
2367
|
+
return `(只接受 https 的 mp.weixin.qq.com 文章链接,拒绝抓取: ${url.slice(0, 60)})`;
|
|
2368
|
+
}
|
|
2369
|
+
const maxChars = boundedToolInteger(args.max_chars, 3000, 6000);
|
|
2370
|
+
// 逐跳复检:一次 302 就能把入口白名单绕过去
|
|
2371
|
+
const { status, html } = await fetchArticleHtml(url, { maxRedirects: 3, allowHop: isAllowedWeChatArticleUrl });
|
|
2372
|
+
if (!html)
|
|
2373
|
+
return `(抓取失败 HTTP ${status} —— 链接可能不是公众号文章,或者跳转到了别处)`;
|
|
2374
|
+
if (html.includes('已被发布者删除'))
|
|
2375
|
+
return '(这篇文章已被发布者删除)';
|
|
2376
|
+
const body = articleTextFromHtml(html, maxChars);
|
|
2377
|
+
if (!body.trim() || (body.match(/[一-鿿]/g) || []).length < 20) {
|
|
2378
|
+
return '(抓到了页面但取不出正文——可能是验证页,或者这篇文章要登录/已失效)';
|
|
2379
|
+
}
|
|
2380
|
+
const title = html.match(/<meta[^>]*property="og:title"[^>]*content="([^"]+)"/i)?.[1]
|
|
2381
|
+
|| html.match(/<title>([^<]+)<\/title>/i)?.[1]?.trim() || '';
|
|
2382
|
+
const author = html.match(/var\s+nickname\s*=\s*"([^"]+)"/i)?.[1]
|
|
2383
|
+
|| html.match(/<meta[^>]*property="og:article:author"[^>]*content="([^"]+)"/i)?.[1] || '';
|
|
2384
|
+
// 配图只报数量与前几个链接:正文是纯文本,但图往往就是这篇文章的要点
|
|
2385
|
+
const imgs = [...html.matchAll(/data-src="(https?:\/\/[^"]+)"/g)].map(m => m[1]).slice(0, 5);
|
|
2386
|
+
const head = [title && `标题: ${title}`, author && `公众号: ${author}`].filter(Boolean).join(' | ');
|
|
2387
|
+
const tail = imgs.length ? `\n\n配图 ${imgs.length} 张(前几张):\n${imgs.map(u => `· ${u}`).join('\n')}` : '';
|
|
2388
|
+
return (head ? `${head}\n---\n` : '') + body + tail;
|
|
2389
|
+
}
|
|
2390
|
+
case 'search_public': {
|
|
2391
|
+
const keyword = String(args.keyword || '').trim();
|
|
2392
|
+
if (!keyword)
|
|
2393
|
+
return '(参数错误: 缺少 keyword)';
|
|
2394
|
+
const limit = boundedToolInteger(args.limit, 8, 20);
|
|
2395
|
+
let html = '';
|
|
2396
|
+
try {
|
|
2397
|
+
const resp = await fetch(`https://weixin.sogou.com/weixin?type=2&query=${encodeURIComponent(keyword)}`, {
|
|
2398
|
+
headers: {
|
|
2399
|
+
'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',
|
|
2400
|
+
'Referer': 'https://weixin.sogou.com/',
|
|
2401
|
+
'Accept': 'text/html',
|
|
2402
|
+
'Accept-Language': 'zh-CN,zh;q=0.9',
|
|
2403
|
+
},
|
|
2404
|
+
signal: AbortSignal.timeout(20_000),
|
|
2405
|
+
});
|
|
2406
|
+
html = await readTextLimited(resp, 2 * 1024 * 1024);
|
|
2407
|
+
}
|
|
2408
|
+
catch {
|
|
2409
|
+
return '(搜索失败——检查网络,或者稍后再试(第三方搜索页会限流))';
|
|
2410
|
+
}
|
|
2411
|
+
const grab = (re, clean) => {
|
|
2412
|
+
const out = [];
|
|
2413
|
+
for (const m of html.matchAll(re)) {
|
|
2414
|
+
const v = clean(m[1]);
|
|
2415
|
+
if (v)
|
|
2416
|
+
out.push(v);
|
|
2417
|
+
}
|
|
2418
|
+
return out;
|
|
2419
|
+
};
|
|
2420
|
+
const titles = grab(/<h3[^>]*>\s*<a[^>]*>(.*?)<\/a>\s*<\/h3>/gs, s => s.replace(/<em[^>]*>/g, '').replace(/<\/em>/g, '').replace(/<!--[^>]*-->/g, '').trim());
|
|
2421
|
+
const accounts = grab(/<span[^>]*class="[^"]*all-time-y2[^"]*"[^>]*>(.*?)<\/span>/gs, s => s.replace(/<[^>]+>/g, '').trim());
|
|
2422
|
+
const descs = grab(/<p[^>]*class="[^"]*txt-info[^"]*"[^>]*>(.*?)<\/p>/gs, s => s.replace(/<[^>]+>/g, '').replace(/…/g, '…').replace(/→/g, '→').replace(/—/g, '—').trim())
|
|
2423
|
+
.filter(d => d.length > 10);
|
|
2424
|
+
const count = Math.max(titles.length, accounts.length, descs.length);
|
|
2425
|
+
if (!count) {
|
|
2426
|
+
return `(没搜到与「${keyword}」相关的公开文章。换个说法、或加上公众号名试试;`
|
|
2427
|
+
+ '找到链接后可以用 fetch_article 抓全文)';
|
|
2428
|
+
}
|
|
2429
|
+
const rows = [];
|
|
2430
|
+
for (let i = 0; i < Math.min(count, limit); i++) {
|
|
2431
|
+
rows.push(`${i + 1}. ${titles[i] || '(未知标题)'}\n 公众号: ${accounts[i] || '(未知)'}`
|
|
2432
|
+
+ (descs[i] ? `\n ${descs[i]}` : ''));
|
|
2433
|
+
}
|
|
2434
|
+
return `公开文章里搜「${keyword}」,命中 ${Math.min(count, limit)} 条:\n`
|
|
2435
|
+
+ rows.join('\n')
|
|
2436
|
+
+ '\n(这是第三方搜索页的标题与摘要,不是全文;要正文用 fetch_article 抓)';
|
|
2437
|
+
}
|
|
2438
|
+
case 'set_todo_status': {
|
|
2439
|
+
const status = String(args.status || '');
|
|
2440
|
+
if (status !== 'done' && status !== 'pending')
|
|
2441
|
+
return '(参数错误: status 只能是 done(已完成)或 pending(取消完成))';
|
|
2442
|
+
const id = String(args.id || '').trim();
|
|
2443
|
+
const task = String(args.task || '').trim();
|
|
2444
|
+
if (!id && !task)
|
|
2445
|
+
return '(参数错误: 要给 id 或 task 指到哪一条待办)';
|
|
2446
|
+
let target = id;
|
|
2447
|
+
if (!target) {
|
|
2448
|
+
// 按任务文字找:**只认唯一**。命中 0 条或 ≥2 条都拒绝——
|
|
2449
|
+
// 猜错条目比拒绝更糟(用户会以为自己的待办被改了,而改的是另一条)
|
|
2450
|
+
const listed = await runPythonJson('extract_todos.py', ['list', '--json']);
|
|
2451
|
+
if (!listed.ok)
|
|
2452
|
+
return fail('待办读取失败', listed);
|
|
2453
|
+
const items = Array.isArray(listed.data) ? listed.data : (listed.data?.items || []);
|
|
2454
|
+
const norm = (s) => String(s || '').replace(/\s+/g, '');
|
|
2455
|
+
const hit = items.filter(t => norm(t.task).includes(norm(task)));
|
|
2456
|
+
if (!hit.length)
|
|
2457
|
+
return `(没有任务文字包含「${task}」的待办)`;
|
|
2458
|
+
if (hit.length > 1) {
|
|
2459
|
+
return `(「${task}」匹配到 ${hit.length} 条待办,不能确定是哪一条,请用 id 指定:\n`
|
|
2460
|
+
+ hit.slice(0, 5).map(t => `· ${t.task}`).join('\n') + ')';
|
|
2461
|
+
}
|
|
2462
|
+
if (!hit[0].id)
|
|
2463
|
+
return '(这条待办没有 id——本机的待办文件格式比预期旧,请在电脑上用 `todos done` 处理)';
|
|
2464
|
+
target = String(hit[0].id);
|
|
2465
|
+
}
|
|
2466
|
+
const r = await runPythonJson('extract_todos.py', [status === 'done' ? 'done' : 'undone', target, '--json']);
|
|
2467
|
+
if (!r.ok)
|
|
2468
|
+
return fail(status === 'done' ? '标记完成失败' : '取消完成失败', r);
|
|
2469
|
+
// 括号开头 = "没有产出内容"(本仓的约定,见 producedContent):这条只报告动作做成了
|
|
2470
|
+
return status === 'done' ? `(已标记完成: ${target})` : `(已取消完成标记: ${target})`;
|
|
2471
|
+
}
|
|
1629
2472
|
default:
|
|
1630
2473
|
return `(未知工具: ${name})`;
|
|
1631
2474
|
}
|