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
|
@@ -1,1640 +1,2452 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Agent 工具注册表 (OpenAI function-calling 格式, DeepSeek 兼容)。
|
|
3
|
-
* 所有工具在本机执行; 结果经 PrivacyGate 脱敏后才进入 LLM 上下文。
|
|
4
|
-
*/
|
|
5
|
-
import { chatService } from './chatService.js'
|
|
6
|
-
import { configService } from './configService.js'
|
|
7
|
-
import { runPythonJson } from './pythonBridge.js'
|
|
8
|
-
import { exportService } from './exportService.js'
|
|
9
|
-
import type { AssistantMemory } from './assistantMemory.js'
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* 与 `
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
*/
|
|
321
|
-
export
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
return
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
/**
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
{
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
+ '
|
|
724
|
-
+ '
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
+ '
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
parameters: {
|
|
811
|
-
type: 'object',
|
|
812
|
-
properties: {
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
},
|
|
817
|
-
},
|
|
818
|
-
},
|
|
819
|
-
{
|
|
820
|
-
type: 'function',
|
|
821
|
-
function: {
|
|
822
|
-
name: '
|
|
823
|
-
description: '
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
:
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
//
|
|
1409
|
-
//
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
const
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
//
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
//
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
const
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
//
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
const
|
|
1505
|
-
|
|
1506
|
-
const
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
const
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
if (!
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
if (!
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
}
|
|
1586
|
-
}
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
const
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
if (
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Agent 工具注册表 (OpenAI function-calling 格式, DeepSeek 兼容)。
|
|
3
|
+
* 所有工具在本机执行; 结果经 PrivacyGate 脱敏后才进入 LLM 上下文。
|
|
4
|
+
*/
|
|
5
|
+
import { chatService } from './chatService.js'
|
|
6
|
+
import { configService } from './configService.js'
|
|
7
|
+
import { runPythonJson } from './pythonBridge.js'
|
|
8
|
+
import { exportService } from './exportService.js'
|
|
9
|
+
import type { AssistantMemory } from './assistantMemory.js'
|
|
10
|
+
import { frameLocalData } from './assistantMemory.js'
|
|
11
|
+
import { disabledIds, readSkillBody, scanSkills, skillRoots, skillState } from './assistantSkills.js'
|
|
12
|
+
import { privacyGate } from './assistantPrivacy.js'
|
|
13
|
+
import { existsSync, readFileSync, readdirSync } from 'fs'
|
|
14
|
+
import { basename, dirname, join } from 'path'
|
|
15
|
+
// 直接调进程的写法已收敛进 pythonBridge:这里不再 import child_process
|
|
16
|
+
import { resolvePackageRoot } from '../utils/packageRoot.js'
|
|
17
|
+
import type { Contact } from '../types.js'
|
|
18
|
+
import { clipWithMarker } from '../utils/text.js'
|
|
19
|
+
import { resolveSince, resolveUntil } from '../utils/dateRange.js'
|
|
20
|
+
import { isAllowedWeChatArticleUrl, safeDate } from '../utils/mcpSecurity.js'
|
|
21
|
+
|
|
22
|
+
const PKG_ROOT = resolvePackageRoot(import.meta.url)
|
|
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')
|
|
38
|
+
/** 单条聊天消息进上下文的字数上限(见 get_messages:引用消息要放得下正文+被引原文) */
|
|
39
|
+
const MSG_BODY_CHARS = 160
|
|
40
|
+
// 知识页住在哪几个目录。**两个是有意的**:文章知识库与聊天知识库分开(用户 2026-09-27
|
|
41
|
+
// 要求),所以读的一方要把**两个都读**——只读一个,分出去的那一半就静默地搜不到。
|
|
42
|
+
// 与 Python 的 `_utils.CONCEPT_DIRS` 是同一份清单,有测试钉住两者一致(跨语言没法共用常量)。
|
|
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
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 线的展示名 —— **TS 侧只此一份**。原来有两处各自从路径里找 `Chat` 这个词猜
|
|
66
|
+
* (`wiki_health` 与 `get_vault_stats`),改词的那天只会改到一半。
|
|
67
|
+
*
|
|
68
|
+
* 与 Python 侧 `_utils.LINE_LABELS` 是同一份名单,靠 `test/concept-dirs-agreement.test.ts`
|
|
69
|
+
* 钉住一致(跨语言没法共享一个常量,这个仓库对这类常量的既有做法就是"各写各的、由测试钉住")。
|
|
70
|
+
*
|
|
71
|
+
* **两条线各自一个根之后这里要跟着改**:路径里不会再有 `Wiki`/`Chat` 这一段。
|
|
72
|
+
* 到那时 `wiki_health` 已经直接取 Python 给的 `report.lineLabel`(认得线的目录它都会给),
|
|
73
|
+
* 这里只剩"认不出的目录"这一支要兜底。
|
|
74
|
+
*/
|
|
75
|
+
export const VAULT_LINE_LABELS: Record<string, string> = { wiki: '文章线', chat: '聊天线' }
|
|
76
|
+
|
|
77
|
+
/** 概念目录 → 线 id(`wiki`/`chat`)。按**路径段**认(`…/Wiki/Concepts`);认不出返回 null,不猜。 */
|
|
78
|
+
export function vaultLineId(dir: string): string | null {
|
|
79
|
+
const segments = dir.split(/[\\/]/).map(s => s.toLowerCase())
|
|
80
|
+
for (const id of Object.keys(VAULT_LINE_LABELS)) {
|
|
81
|
+
if (segments.includes(id)) return id
|
|
82
|
+
}
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 概念目录 → 线的展示名。**认不出线就返回目录名,不猜成某一条**。 */
|
|
87
|
+
export function vaultLineLabel(dir: string): string {
|
|
88
|
+
const id = vaultLineId(dir)
|
|
89
|
+
if (id) return VAULT_LINE_LABELS[id]
|
|
90
|
+
return dir.split(/[\\/]/).filter(Boolean).pop() || dir
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 工具参数里的 `line`:`wiki` / `chat` / `all`,不传就是 `wiki`(两条线分开之后,
|
|
95
|
+
* **默认只看文章线**,要合起来看显式给 `all`)。
|
|
96
|
+
*
|
|
97
|
+
* 认不出的值返回 `null` 让调用方**报错**,而不是当 `all`:`line: 'wiki '` 或 `'Wiki'`
|
|
98
|
+
* 悄悄变成"两条线都搜"的话,"我限定住了"就是句假话,而结果里看不出来。
|
|
99
|
+
*/
|
|
100
|
+
export function toolLine(value: unknown): 'wiki' | 'chat' | 'all' | null {
|
|
101
|
+
const raw = String(value ?? '').trim().toLowerCase()
|
|
102
|
+
if (!raw) return 'wiki'
|
|
103
|
+
return raw === 'wiki' || raw === 'chat' || raw === 'all' ? raw : null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 线 id → 展示名(工具输出里说"搜了哪条"用)。 */
|
|
107
|
+
export function lineLabelOf(line: 'wiki' | 'chat' | 'all'): string {
|
|
108
|
+
return line === 'all' ? '两条线' : (VAULT_LINE_LABELS[line] ?? line)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 概念名 → 文件名。**与 `compile_wiki` 是同一套规则**(非法字符换下划线、截到 60 字)。
|
|
113
|
+
*
|
|
114
|
+
* 这里必须消毒:邻居名是从**文件内容**里读出来的,直接拼进路径就能走出目录
|
|
115
|
+
* (`../../` 那类)。文件名规则两边不一致也会让"查得到"变成"查不到"。
|
|
116
|
+
*/
|
|
117
|
+
export function safeConceptFile(name: string): string {
|
|
118
|
+
return `${name.replace(/[\\/:*?"<>|]/g, '_').slice(0, 60)}.md`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 概念页正文的第一句(跳过 frontmatter、标题、引用、列表)——给"这个邻居是什么"。 */
|
|
122
|
+
/**
|
|
123
|
+
* 页面里「正文」的那一部分:`## 来源` 之前。
|
|
124
|
+
*
|
|
125
|
+
* **来源段不算正文** —— 它列的是文章标题(可能 300+ 条),拿它做「这个词在这页里吗」的判断,
|
|
126
|
+
* 会把「某篇来源的标题里含这个词」报成「正文提及」。
|
|
127
|
+
*/
|
|
128
|
+
export function pageBody(text: string): string {
|
|
129
|
+
const at = text.indexOf('## 来源')
|
|
130
|
+
return at === -1 ? text : text.slice(0, at)
|
|
131
|
+
}
|
|
132
|
+
/** 概念页里「相关概念」那一节指向的名字(按出现顺序)。 */
|
|
133
|
+
function relatedNames(pagePath: string): string[] {
|
|
134
|
+
const text = readFileSync(pagePath, 'utf8')
|
|
135
|
+
const start = text.indexOf('## 相关概念')
|
|
136
|
+
if (start < 0) return []
|
|
137
|
+
const rest = text.slice(start + 1)
|
|
138
|
+
const end = rest.indexOf('\n## ')
|
|
139
|
+
const section = end > 0 ? rest.slice(0, end) : rest
|
|
140
|
+
return [...section.matchAll(/\[\[([^\]|]+)\]\]/g)]
|
|
141
|
+
.map(match => match[1].replace(/\.md$/, '').trim())
|
|
142
|
+
.filter(Boolean)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 从一页出发,**沿着图走若干跳**,逐层返回 `{名字, 一句定义, 从谁连过来}`。
|
|
147
|
+
*
|
|
148
|
+
* 与 `conceptNeighbors` 的分工:那个给"一跳"(本页 + 直接邻居),这个给"一片"。助手此前只能
|
|
149
|
+
* 一跳一跳地查(工具说明里写着"可以顺着它们再用本工具逐跳查下去"),但那要**模型自己决定**
|
|
150
|
+
* 下一跳查谁。一次给两跳,它就能直接看到"这个概念周围的生态"。
|
|
151
|
+
*
|
|
152
|
+
* 三条规矩:
|
|
153
|
+
* - **去重**:一个概念在图上有多条路径时只留**最先到达**的那条(BFS 保证它跳数最少),
|
|
154
|
+
* 否则同一个名字会在好几层里重复出现,读的人分不清;
|
|
155
|
+
* - **封顶**(`maxNodes`):4 个邻居乘 4 乘 4 就是 64 —— 不封顶第三跳能把输出撑爆,而模型
|
|
156
|
+
* 读不完的图等于没给。截断了要**说出来**(`truncated`),不能默默少给;
|
|
157
|
+
* - **没建页的邻居照旧报数**(`missing`),不静默吞掉。
|
|
158
|
+
*/
|
|
159
|
+
export function conceptSubgraph(pageName: string, depth = 2, maxNodes = 30,
|
|
160
|
+
wikiDirs: string[] = vaultDirs(), homeDir?: string): {
|
|
161
|
+
levels: Array<Array<{ name: string; brief: string; via: string }>>
|
|
162
|
+
truncated: boolean
|
|
163
|
+
missing: number
|
|
164
|
+
} {
|
|
165
|
+
// 同 `conceptNeighbors`:传字符串会静默地按字符遍历,所以这里响,不猜。
|
|
166
|
+
if (typeof wikiDirs === 'string') {
|
|
167
|
+
throw new TypeError('conceptSubgraph 的 wikiDirs 要的是目录数组,不是单个字符串')
|
|
168
|
+
}
|
|
169
|
+
// 邻居可能住在另一个目录(文章概念指向聊天概念、反之亦然),所以查找跨目录。
|
|
170
|
+
// `homeDir` 只影响**起点那一页**:两条线各有一张同名页时,"第一个有这个文件的目录"
|
|
171
|
+
// 永远选出文章线那张,于是聊天线那张根本走不到 —— 而调用方(`search_knowledge`)
|
|
172
|
+
// 正好知道这一趟命中的是哪条线。
|
|
173
|
+
const find = (name: string, from?: string): string | null => {
|
|
174
|
+
const dirs = from ? [from, ...wikiDirs.filter((d) => d !== from)] : wikiDirs
|
|
175
|
+
for (const dir of dirs) {
|
|
176
|
+
const candidate = join(dir, safeConceptFile(name))
|
|
177
|
+
if (existsSync(candidate)) return candidate
|
|
178
|
+
}
|
|
179
|
+
return null
|
|
180
|
+
}
|
|
181
|
+
// **这一趟在哪条线上**:起点由 `homeDir` 定,之后沿 BFS **往下继承** —— 与图谱那边
|
|
182
|
+
// "先在页自己那条线里解析、线里没有才回退到另一条"是**同一条规则**。不继承的话每一跳
|
|
183
|
+
// 都会重新按"第一个有这个文件的目录"选,于是聊天线那一支走着走着就窜到文章线去了。
|
|
184
|
+
const resolved = new Map<string, string>()
|
|
185
|
+
const resolve = (name: string, from?: string): string | null => {
|
|
186
|
+
const hit = resolved.get(name)
|
|
187
|
+
if (hit) return hit
|
|
188
|
+
const path = find(name, from)
|
|
189
|
+
if (path) resolved.set(name, path)
|
|
190
|
+
return path
|
|
191
|
+
}
|
|
192
|
+
const start = resolve(pageName, homeDir)
|
|
193
|
+
if (!start) return { levels: [], truncated: false, missing: 0 }
|
|
194
|
+
|
|
195
|
+
const seen = new Set<string>([pageName])
|
|
196
|
+
const levels: Array<Array<{ name: string; brief: string; via: string }>> = []
|
|
197
|
+
let frontier = [pageName]
|
|
198
|
+
let truncated = false
|
|
199
|
+
let missing = 0
|
|
200
|
+
for (let hop = 0; hop < depth && frontier.length; hop += 1) {
|
|
201
|
+
const level: Array<{ name: string; brief: string; via: string }> = []
|
|
202
|
+
const next: string[] = []
|
|
203
|
+
for (const from of frontier) {
|
|
204
|
+
const fromPath = resolved.get(from) || resolve(from)
|
|
205
|
+
if (!fromPath) continue
|
|
206
|
+
const home = dirname(fromPath)
|
|
207
|
+
for (const name of relatedNames(fromPath)) {
|
|
208
|
+
if (seen.has(name)) continue
|
|
209
|
+
const target = resolve(name, home)
|
|
210
|
+
if (!target) {
|
|
211
|
+
missing += 1
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
if (seen.size >= maxNodes) {
|
|
215
|
+
truncated = true
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
seen.add(name)
|
|
219
|
+
level.push({ name, brief: conceptBrief(readFileSync(target, 'utf8')), via: from })
|
|
220
|
+
next.push(name)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (level.length) levels.push(level)
|
|
224
|
+
frontier = next
|
|
225
|
+
}
|
|
226
|
+
return { levels, truncated, missing }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function conceptBrief(text: string): string {
|
|
230
|
+
const body = text.startsWith('---') ? text.slice(text.indexOf('---', 3) + 3) : text
|
|
231
|
+
for (const raw of body.split('\n')) {
|
|
232
|
+
const line = raw.trim()
|
|
233
|
+
if (!line || /^[#>\-|`\[]/.test(line)) continue
|
|
234
|
+
return line.length > 60 ? `${line.slice(0, 60)}…` : line
|
|
235
|
+
}
|
|
236
|
+
return '(这页没写定义)'
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 一个概念页在 `## 相关概念` 里连到的邻居——**图谱在 Agent 侧的落地**。
|
|
241
|
+
*
|
|
242
|
+
* 为什么要有:Obsidian 的关系图谱是人眼视图,助手那条路一直读不到"图"。但概念页里
|
|
243
|
+
* 本来就写着它的邻居,把它们一并给出去,"查一个概念"就变成"拿到一小块子图"。
|
|
244
|
+
* 实测:全部概念页都短于这个工具返回的 2,000 字上限,所以那段一直在返回内容里
|
|
245
|
+
* ——但**没人点明它可以顺着走**,也没人把邻居的一句话定义取出来。
|
|
246
|
+
*
|
|
247
|
+
* 有页的邻居连定义一起给;没有页的只报个数(`missing`),免得模型去追一个取不到的名字。
|
|
248
|
+
*/
|
|
249
|
+
export function conceptNeighbors(pageName: string, wikiDirs: string[] = vaultDirs()): { linked: Array<{ name: string; brief: string }>; missing: number } {
|
|
250
|
+
// **传字符串会静默地按字符遍历**(`for (const dir of 'D:\vault')` 挨个字符看过去),
|
|
251
|
+
// 于是 `join('D', '某概念.md')` 不存在,函数安静地回一句「没有邻居」。2026-09-27 把参数
|
|
252
|
+
// 从单个目录改成目录数组时,测试里正好踩到这个——类型拦得住 `src/`(`tsc` 覆盖它),
|
|
253
|
+
// 拦不住测试与将来的 JS 调用方。所以这里响,不猜。
|
|
254
|
+
if (typeof wikiDirs === 'string') {
|
|
255
|
+
throw new TypeError('conceptNeighbors 的 wikiDirs 要的是目录数组,不是单个字符串')
|
|
256
|
+
}
|
|
257
|
+
// **邻居可能住在另一个目录**(文章概念指向聊天概念、反之亦然),所以查找也是跨目录的
|
|
258
|
+
const find = (name: string): string | null => {
|
|
259
|
+
for (const dir of wikiDirs) {
|
|
260
|
+
const candidate = join(dir, safeConceptFile(name))
|
|
261
|
+
if (existsSync(candidate)) return candidate
|
|
262
|
+
}
|
|
263
|
+
return null
|
|
264
|
+
}
|
|
265
|
+
const pagePath = find(pageName)
|
|
266
|
+
if (!pagePath) return { linked: [], missing: 0 }
|
|
267
|
+
const text = readFileSync(pagePath, 'utf8')
|
|
268
|
+
const start = text.indexOf('## 相关概念')
|
|
269
|
+
if (start < 0) return { linked: [], missing: 0 }
|
|
270
|
+
const rest = text.slice(start + 1)
|
|
271
|
+
const end = rest.indexOf('\n## ')
|
|
272
|
+
const section = end > 0 ? rest.slice(0, end) : rest
|
|
273
|
+
|
|
274
|
+
const linked: Array<{ name: string; brief: string }> = []
|
|
275
|
+
let missing = 0
|
|
276
|
+
for (const match of section.matchAll(/\[\[([^\]|]+)\]\]/g)) {
|
|
277
|
+
const name = match[1].replace(/\.md$/, '').trim()
|
|
278
|
+
if (!name) continue
|
|
279
|
+
const target = find(name)
|
|
280
|
+
if (!target) {
|
|
281
|
+
missing += 1
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
linked.push({ name, brief: conceptBrief(readFileSync(target, 'utf8')) })
|
|
285
|
+
}
|
|
286
|
+
return { linked, missing }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export interface ToolDef {
|
|
290
|
+
type: 'function'
|
|
291
|
+
function: {
|
|
292
|
+
name: string
|
|
293
|
+
description: string
|
|
294
|
+
parameters: Record<string, unknown>
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export interface ToolContext {
|
|
299
|
+
userId: string
|
|
300
|
+
memory: AssistantMemory
|
|
301
|
+
/**
|
|
302
|
+
* 工具这一轮取到的图片,由 `look_at_image` 填。
|
|
303
|
+
*
|
|
304
|
+
* 图片走**侧信道**而不是工具返回值:工具返回值是字符串,要往里塞 base64 就得约定一个
|
|
305
|
+
* 魔法前缀再解析——那是这个仓库反复吃亏的"用文本承载结构"。服务层拿这个字段去把图片
|
|
306
|
+
* 附到对话里(见 `assistantService.toApiMessages`)。
|
|
307
|
+
*/
|
|
308
|
+
pendingImages?: AttachedImage[]
|
|
309
|
+
/**
|
|
310
|
+
* 调用方是**外部机器**(MCP 那条路)时置 true。
|
|
311
|
+
*
|
|
312
|
+
* 与面板/微信那条路的区别不是审美:MCP 客户端是**别人家的进程**,它那边谁也看不到这一轮
|
|
313
|
+
* 是在什么上下文里决定调用的。所以对"会把用户数据送出去"的工具,机器调用默认只给**预览**,
|
|
314
|
+
* 要显式带 `confirm: true` 才真出境——与 CLI 那条 `--dry-run` / `--yes` 是同一套纪律,
|
|
315
|
+
* 也与 `capabilities.read.draft.confirmationRequired` 对得上。
|
|
316
|
+
*/
|
|
317
|
+
requiresConfirm?: boolean
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** 随某条消息一起发出去的图片。只在构造请求那一刻存在,不进记忆、不进审计内容。 */
|
|
321
|
+
export interface AttachedImage {
|
|
322
|
+
b64: string
|
|
323
|
+
mime: string
|
|
324
|
+
/** 来源消息的 local_id,只为了审计里能对上"发了哪条" */
|
|
325
|
+
localId?: number
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** 一轮对话里最多看几张图。每次调用一张,这是上限——图片按体积算钱,也按体积算隐私。 */
|
|
329
|
+
const MAX_IMAGES_PER_TURN = 2
|
|
330
|
+
|
|
331
|
+
function fmtTime(ts: number): string {
|
|
332
|
+
const n = Number(ts)
|
|
333
|
+
const d = n > 1e12 ? new Date(n) : new Date(n * 1000)
|
|
334
|
+
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* 转录里的时刻:`09-23 12:20`。
|
|
339
|
+
*
|
|
340
|
+
* 与 `fmtTime`(给用户看的工具输出用 `9/23 12:20`)**故意不同**:转录这一行的形状要和
|
|
341
|
+
* Python 侧逐字一致,见 `transcriptLine`。改 `fmtTime` 会连带改用户看到的东西,所以这里单独一个。
|
|
342
|
+
*
|
|
343
|
+
* **时区也是契约的一部分,而且是固定 +8(北京时间),不是本机时区**:Python 那边写的是
|
|
344
|
+
* `TZ = timezone(timedelta(hours=8))`(`scripts/reply_debt.py:39`),同一段对话从面板进来
|
|
345
|
+
* 与从命令行进来必须是同一串字符。用 `getHours()` 取本机时区,在非 +8 的机器上两边会差几小时
|
|
346
|
+
* ——CI(UTC)实测差 8 小时:TS 出 `09-23 04:00`、Python 出 `09-23 12:00`。
|
|
347
|
+
* 中国没有夏令时,固定 +8 是精确的。要改成"跟随本机时区",得连 Python 的欠账天数一起改
|
|
348
|
+
* (那里用同一个 TZ 算"等了多少天"),不是改这一行的事。
|
|
349
|
+
*/
|
|
350
|
+
function transcriptTime(ts: number): string {
|
|
351
|
+
const n = Number(ts)
|
|
352
|
+
const shifted = new Date((n > 1e12 ? n : n * 1000) + 8 * 3600_000)
|
|
353
|
+
const pad = (value: number) => String(value).padStart(2, '0')
|
|
354
|
+
return `${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())} `
|
|
355
|
+
+ `${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}`
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** 单条消息进转录的截断长度。**必须等于** `scripts/reply_debt.py` 的 `DRAFT_MSG_CHARS`。 */
|
|
359
|
+
export const TRANSCRIPT_MSG_CHARS = 160
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* 转录取**多少条**消息。**必须等于** `scripts/reply_debt.py` 的 `TRANSCRIPT_SIZE`。
|
|
363
|
+
*
|
|
364
|
+
* 与上面那个字数上限是一对:一个决定"多长的一段对话",一个决定"每条多长"。两条入口
|
|
365
|
+
* (面板/微信走 `--stdin`,命令行走 `--talker`)必须给出同一段对话——否则同一件事在两边
|
|
366
|
+
* 判断出的结果会不一样,而**不会报任何错**。两边今天都是 30,但在这之前谁都没盯着它。
|
|
367
|
+
*/
|
|
368
|
+
export const TRANSCRIPT_MESSAGES = 30
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* 消息是**谁**说的(对方那一侧):**有解析出来的名字就用名字,没有就写「对方」**。
|
|
372
|
+
*
|
|
373
|
+
* **`senderUsername` 不参与**:它是数据库原列的 wxid(`wcdbCore` 的 `sender_username`、
|
|
374
|
+
* `sqlcipherCore` 的 `StrTalker`),不是名字。拿它当标签有两个坏处——把**账号标识**交给云端
|
|
375
|
+
* 模型,而且对模型没用(wxid 不告诉它这是谁)。
|
|
376
|
+
*
|
|
377
|
+
* 实测(本机 8 个会话、179 条别人发的消息):`senderDisplay` 是有中文的名字 174 条,
|
|
378
|
+
* `senderUsername` 是 wxid 形状 176 条,两者**从不相同**。所以这个替换不丢信息:
|
|
379
|
+
* 群聊里照样分得清谁说的(名字比 wxid 还更有用),而标识不再出境。
|
|
380
|
+
*/
|
|
381
|
+
export function speakerLabel(message: any): string {
|
|
382
|
+
return message.senderDisplay || '对方'
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* 对话转录里的一行:`[09-23 12:20] 老王:那个文件你什么时候发我`。
|
|
387
|
+
*
|
|
388
|
+
* **这段形状在 Python 侧还有一份**:`reply_debt.format_line`(`draft_reply.py --talker`
|
|
389
|
+
* 那条路自己读库时用它渲染)。两处必须逐字一致,否则同一段对话走不同入口会得到不同的转录——
|
|
390
|
+
* 而它们**已经漂过**,实测三处不同:
|
|
391
|
+
*
|
|
392
|
+
* | | 这边(`--stdin`,面板/微信那条路) | Python `format_line`(`--talker` 命令行) |
|
|
393
|
+
* | --- | --- | --- |
|
|
394
|
+
* | 时刻 | `9/23 12:20` | `09-23 12:20` |
|
|
395
|
+
* | 对方那句 | 写死「对方」 | `senderDisplay` 优先(单聊里就是人名) |
|
|
396
|
+
* | 截断 | 160 字 | 120 字(留个 `…` vs 直接切掉) |
|
|
397
|
+
*
|
|
398
|
+
* 漂移里最危险的是「我」那个标记:脚本靠 `'] 我:'` 挑语气样本、靠 `lastFromMe` 判最后一条,
|
|
399
|
+
* 改错一个字全都**静默失效**(不报错,只是判断与语气样本都变差)。
|
|
400
|
+
* 所以有 `test/transcript-format-contract.test.ts`:同一批消息(含一条超长、一条非文本)
|
|
401
|
+
* 把两边逐字比一遍。谁改了这一行的形状而不改另一边,那条会红。
|
|
402
|
+
*
|
|
403
|
+
* **`senderUsername` 永远不参与**(实测过):它是**数据库原列的 wxid**
|
|
404
|
+
* (`wcdbCore` 的 `sender_username`、`sqlcipherCore` 的 `StrTalker`),不是名字。
|
|
405
|
+
* 拿它当发言人的标签会把账号标识喂给两个云端模型——而这一行的目的只是让模型分得清谁说的。
|
|
406
|
+
* 所以规则是:**有解析出来的名字就用名字,没有就写「对方」**,绝不退回原列。
|
|
407
|
+
* `senderDisplay` 在真实路径上是有值的(实测一条会话:文本与图片消息都带),所以这边
|
|
408
|
+
* 正常会写成人名,「对方」只是兜底。
|
|
409
|
+
*/
|
|
410
|
+
export function transcriptLine(message: any): string {
|
|
411
|
+
const raw = message.localType === 1
|
|
412
|
+
? (message.content || message.parsedContent || '')
|
|
413
|
+
: (message.parsedContent || message.content || '')
|
|
414
|
+
const body = privacyGate.maskMessageBody(
|
|
415
|
+
clipWithMarker(String(raw).replace(/\n/g, ' '), TRANSCRIPT_MSG_CHARS),
|
|
416
|
+
{ isText: message.localType === 1 })
|
|
417
|
+
const speaker = message.isSend ? '我' : speakerLabel(message)
|
|
418
|
+
return `[${transcriptTime(message.createTime)}] ${speaker}:${body}`
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** SSRF 防护: 仅 http(s), 拒绝内网/环回地址
|
|
422
|
+
*
|
|
423
|
+
* 导出是为了能测它:这是**抓取前的安全边界**,而它守着的是"别让助手被一条收藏里的链接
|
|
424
|
+
* 带去访问内网"。纯函数,没有副作用。
|
|
425
|
+
*/
|
|
426
|
+
export function isSafeUrl(raw: string): boolean {
|
|
427
|
+
try {
|
|
428
|
+
const u = new URL(raw)
|
|
429
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false
|
|
430
|
+
const h = u.hostname.toLowerCase()
|
|
431
|
+
if (!h) return false
|
|
432
|
+
|
|
433
|
+
// IPv6 字面量(URL 里带方括号)。Node 会把 [0:0:0:0:0:0:0:1] 归一成 [::1],
|
|
434
|
+
// 但不碰 [fd00::1] 这类——所以下面按前缀逐个挡。
|
|
435
|
+
if (h.startsWith('[')) {
|
|
436
|
+
const inner = h.slice(1, -1)
|
|
437
|
+
if (inner === '::' || inner === '::1') return false
|
|
438
|
+
if (inner.startsWith('::ffff:')) return false // IPv4 映射(可能是 127.0.0.1)
|
|
439
|
+
if (/^f[cd]/.test(inner)) return false // fc00::/7 唯一本地
|
|
440
|
+
if (/^fe[89ab]/.test(inner)) return false // fe80::/10 链路本地
|
|
441
|
+
return true
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (h === 'localhost') return false
|
|
445
|
+
// 纯数字或 0x 形式的整型主机名:内核会把它当 IPv4 用,而域名不可能是这种形状。
|
|
446
|
+
// (实测 Node 对 2130706433 / 0x7f000001 会归一成 127.0.0.1,这道是双保险。)
|
|
447
|
+
if (/^(0x[0-9a-f]+|[0-9]+)$/.test(h)) return false
|
|
448
|
+
// 私网前缀**只在真的是点分四段时**才判:否则 `10.example.com` 这种公网域名会被
|
|
449
|
+
// `^10\.` 误杀,工具会回一句「链接不安全,拒绝抓取」,而链接其实是好的。
|
|
450
|
+
if (/^[0-9]{1,3}(\.[0-9]{1,3}){3}$/.test(h)) {
|
|
451
|
+
if (/^(127\.|10\.|192\.168\.|169\.254\.|0\.)/.test(h)) return false
|
|
452
|
+
if (/^172\.(1[6-9]|2[0-9]|3[01])\./.test(h)) return false
|
|
453
|
+
}
|
|
454
|
+
// 这是一份已知形态的黑名单,不是"这地址一定可公网路由"的证明:
|
|
455
|
+
// NAT64(64:ff9b::/96)等映射形式不在覆盖范围内。
|
|
456
|
+
return true
|
|
457
|
+
} catch { return false }
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
/** 标签剥离 → 纯文本 */
|
|
462
|
+
export function stripTags(seg: string): string {
|
|
463
|
+
return seg.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
464
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
465
|
+
.replace(/<[^>]+>/g, '\n')
|
|
466
|
+
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<')
|
|
467
|
+
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
|
|
468
|
+
.split('\n').map(s => s.trim()).filter(Boolean).join('\n')
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** WAF 挑战页兜底: 正文藏在 cgiDataNew.content_noencode (JS 转义字符串) */
|
|
472
|
+
export function extractFromChallengePage(html: string): string | null {
|
|
473
|
+
const start = html.indexOf("content_noencode: '")
|
|
474
|
+
if (start < 0) return null
|
|
475
|
+
let i = start + "content_noencode: '".length
|
|
476
|
+
let out = ''
|
|
477
|
+
while (i < html.length && out.length < 1_000_000) {
|
|
478
|
+
const ch = html[i]
|
|
479
|
+
if (ch === "'") break
|
|
480
|
+
if (ch === '\\' && i + 1 < html.length) {
|
|
481
|
+
const n = html[i + 1]
|
|
482
|
+
if (n === 'x' && i + 3 < html.length) {
|
|
483
|
+
out += String.fromCharCode(parseInt(html.slice(i + 2, i + 4), 16))
|
|
484
|
+
i += 4
|
|
485
|
+
continue
|
|
486
|
+
}
|
|
487
|
+
if (n === 'n') { out += '\n'; i += 2; continue }
|
|
488
|
+
if (n === 'r') { out += '\r'; i += 2; continue }
|
|
489
|
+
if (n === 't') { out += '\t'; i += 2; continue }
|
|
490
|
+
if (n === '\\' || n === "'" || n === '"') { out += n; i += 2; continue }
|
|
491
|
+
}
|
|
492
|
+
out += ch
|
|
493
|
+
i++
|
|
494
|
+
}
|
|
495
|
+
return out.length > 100 ? out : null
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** 从 HTML 提取正文文本 (微信公众号 js_content 优先, 配平 div 边界; 兜底 body) */
|
|
499
|
+
export function extractText(html: string): string {
|
|
500
|
+
let seg = html
|
|
501
|
+
const jc = html.indexOf('id="js_content"')
|
|
502
|
+
if (jc >= 0) {
|
|
503
|
+
// 配平 <div/</div> 找 js_content 完整区块; depth 从 1 起算 (自身开标签位于 jc 之前)
|
|
504
|
+
let depth = 1, end = -1
|
|
505
|
+
const tag = /<\/?div\b/g
|
|
506
|
+
tag.lastIndex = jc
|
|
507
|
+
let m: RegExpExecArray | null
|
|
508
|
+
while ((m = tag.exec(html))) {
|
|
509
|
+
if (m[0] === '</div') { depth--; if (depth === 0) { end = m.index; break } }
|
|
510
|
+
else depth++
|
|
511
|
+
}
|
|
512
|
+
seg = end > jc ? html.slice(jc, end) : html.slice(jc, jc + 100_000)
|
|
513
|
+
} else {
|
|
514
|
+
const body = html.indexOf('<body')
|
|
515
|
+
if (body >= 0) seg = html.slice(body)
|
|
516
|
+
}
|
|
517
|
+
return stripTags(seg)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** 确保数据库已连接 (connect 幂等, 已连接时直接返回) */
|
|
521
|
+
/**
|
|
522
|
+
* 抓页面 HTML:微信内置浏览器 UA + Referer 绕 WAF,最多试两次。`read_favorite` 与 `fetch_article` 共用。
|
|
523
|
+
*
|
|
524
|
+
* `maxRedirects > 0` 时**自己跟跳转并逐跳复检白名单**:默认的 `redirect: 'follow'` 只看入口,
|
|
525
|
+
* 一次 302 就能把白名单绕过去(拿到一个被允许的入口,跳到内网地址)。`fetch_article`
|
|
526
|
+
* 收的是用户/模型给的任意 URL,所以要这道闸;`read_favorite` 收的是用户**自己收藏**的链接,
|
|
527
|
+
* 保持原行为不动(那里用 denylist,且收藏里本来就有各种站)。
|
|
528
|
+
*/
|
|
529
|
+
async function fetchArticleHtml(
|
|
530
|
+
url: string,
|
|
531
|
+
opts: { maxRedirects?: number; allowHop?: (u: string) => boolean } = {},
|
|
532
|
+
): Promise<{ status: number; html: string }> {
|
|
533
|
+
const maxRedirects = opts.maxRedirects ?? 0
|
|
534
|
+
let current = url
|
|
535
|
+
let lastStatus = 0
|
|
536
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
537
|
+
if (maxRedirects > 0 && opts.allowHop && !opts.allowHop(current)) {
|
|
538
|
+
return { status: lastStatus, html: '' } // 某一跳出了白名单:宁可空手而归
|
|
539
|
+
}
|
|
540
|
+
let html = ''
|
|
541
|
+
let status = 0
|
|
542
|
+
let followed = false
|
|
543
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
544
|
+
try {
|
|
545
|
+
const res = await fetch(current, {
|
|
546
|
+
headers: {
|
|
547
|
+
'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',
|
|
548
|
+
'Referer': 'https://mp.weixin.qq.com/',
|
|
549
|
+
'Accept': 'text/html,application/xhtml+xml',
|
|
550
|
+
'Accept-Language': 'zh-CN,zh;q=0.9',
|
|
551
|
+
},
|
|
552
|
+
...(maxRedirects > 0 ? { redirect: 'manual' as const } : {}),
|
|
553
|
+
signal: AbortSignal.timeout(20_000),
|
|
554
|
+
})
|
|
555
|
+
status = res.status
|
|
556
|
+
if (maxRedirects > 0 && status >= 300 && status < 400) {
|
|
557
|
+
const location = res.headers.get('location')
|
|
558
|
+
if (!location) return { status, html: '' }
|
|
559
|
+
current = new URL(location, current).toString()
|
|
560
|
+
followed = true
|
|
561
|
+
break // 换一跳,重来
|
|
562
|
+
}
|
|
563
|
+
html = (await res.text()).slice(0, 500_000)
|
|
564
|
+
// 正文验证: 必须含 js_content (否则是验证页, 重试)
|
|
565
|
+
if (res.ok && html.includes('js_content')) break
|
|
566
|
+
} catch { /* 超时/网络错误 → 重试 */ }
|
|
567
|
+
}
|
|
568
|
+
if (html) return { status, html }
|
|
569
|
+
lastStatus = status
|
|
570
|
+
// 没跟跳转就走完了两次尝试:就是抓不到,别再拿同一个地址重试下去
|
|
571
|
+
if (!followed) return { status, html: '' }
|
|
572
|
+
}
|
|
573
|
+
return { status: lastStatus, html: '' }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** 读响应体但设上限:第三方搜索页可能很大,不让它把内存吃掉(超了就截断,不报错) */
|
|
577
|
+
async function readTextLimited(response: Response, maxBytes: number): Promise<string> {
|
|
578
|
+
const buf = await response.arrayBuffer()
|
|
579
|
+
return Buffer.from(buf).subarray(0, maxBytes).toString('utf8')
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* 从抓到的 HTML 里取正文:优先正常页面的 `js_content`,取不到再走 WAF 挑战页的 `content_noencode`。
|
|
584
|
+
* 返回的文本已按 `maxChars` 截断。
|
|
585
|
+
*/
|
|
586
|
+
function articleTextFromHtml(html: string, maxChars: number): string {
|
|
587
|
+
let text = html.includes('js_content') ? extractText(html) : ''
|
|
588
|
+
if (!text || (text.match(/[一-鿿]/g) || []).length < 50) {
|
|
589
|
+
const decoded = extractFromChallengePage(html)
|
|
590
|
+
if (decoded) text = stripTags(decoded)
|
|
591
|
+
}
|
|
592
|
+
return text.slice(0, maxChars)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async function ensureDb(): Promise<void> {
|
|
596
|
+
await chatService.connect()
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** 最近一份**有内容**的日报是哪天,以及那是几天前。算不出来就回空串——不猜。 */
|
|
600
|
+
function lastDailyNote(): string {
|
|
601
|
+
try {
|
|
602
|
+
const days = readdirSync(bizDailyDir())
|
|
603
|
+
.filter(name => /^\d{4}-\d{2}-\d{2}$/.test(name)).sort().reverse().slice(0, 30)
|
|
604
|
+
for (const day of days) {
|
|
605
|
+
const file = join(bizDailyDir(), day, '.articles.json')
|
|
606
|
+
if (!existsSync(file)) continue
|
|
607
|
+
const payload = JSON.parse(readFileSync(file, 'utf8'))
|
|
608
|
+
if ((payload?.articles ?? []).length) {
|
|
609
|
+
const ago = Math.round((Date.now() - new Date(`${day}T00:00:00`).getTime()) / 86_400_000)
|
|
610
|
+
return `;最近一次有内容的日报是 ${day}(${ago} 天前)`
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
} catch { /* 扫不出来就什么都不说,而不是猜一个日期 */ }
|
|
614
|
+
return ''
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
interface TalkerCandidate {
|
|
618
|
+
username: string
|
|
619
|
+
displayName?: string | null
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
class ToolInputError extends Error {}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* 把待办 id 露给模型。**不露出来它就指不到具体哪一条**——`set_todo_status` 只能靠 id
|
|
626
|
+
* 或唯一的任务文字定位,而任务文字常常是长句、几条之间又很像。
|
|
627
|
+
* 给完整 id(CLI 那边支持 id 前缀,但前缀要唯一才安全,这里不赌)。
|
|
628
|
+
*/
|
|
629
|
+
function idTag(t: any): string {
|
|
630
|
+
return t?.id ? ` [id: ${t.id}]` : ''
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export function boundedToolInteger(value: unknown, fallback: number, maximum: number, field = 'limit'): number {
|
|
634
|
+
if (value === undefined || value === null || value === '') return fallback
|
|
635
|
+
const parsed = Number(value)
|
|
636
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > maximum) {
|
|
637
|
+
throw new ToolInputError(`${field} 必须是 1-${maximum} 的整数`)
|
|
638
|
+
}
|
|
639
|
+
return parsed
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export function resolveUniqueTalker(name: string, sessions: TalkerCandidate[]): string {
|
|
643
|
+
const query = name.trim()
|
|
644
|
+
if (!query) throw new ToolInputError('contact 不能为空')
|
|
645
|
+
|
|
646
|
+
const unique = (matches: TalkerCandidate[]) => [...new Set(matches.map(item => item.username))]
|
|
647
|
+
const usernameMatches = unique(sessions.filter(item => item.username === query))
|
|
648
|
+
if (usernameMatches.length === 1) return usernameMatches[0]
|
|
649
|
+
|
|
650
|
+
const exactNameMatches = unique(sessions.filter(item => item.displayName === query))
|
|
651
|
+
if (exactNameMatches.length === 1) return exactNameMatches[0]
|
|
652
|
+
if (exactNameMatches.length > 1) {
|
|
653
|
+
throw new ToolInputError(`会话名称“${query}”对应多个会话,请改用会话 ID`)
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const partialMatches = unique(sessions.filter(item => item.displayName?.includes(query)))
|
|
657
|
+
if (partialMatches.length === 1) return partialMatches[0]
|
|
658
|
+
if (partialMatches.length > 1) {
|
|
659
|
+
throw new ToolInputError(`会话名称“${query}”匹配多个会话,请提供更完整名称或会话 ID`)
|
|
660
|
+
}
|
|
661
|
+
return query
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* 通讯录里按 **备注 / 显示名 / 昵称 / 别名 / username** 找唯一的一个;找不到或有歧义回 null。
|
|
666
|
+
*
|
|
667
|
+
* 与 `resolveUniqueTalker` 同一套纪律:**只认唯一**,不猜。
|
|
668
|
+
*/
|
|
669
|
+
export async function lookupContactUsername(name: string): Promise<string | null> {
|
|
670
|
+
const query = name.trim()
|
|
671
|
+
if (!query) return null
|
|
672
|
+
// 窗口给宽一点:关键字命中的是**包含**它的所有名字,一个字的查询能匹配到几十个人,
|
|
673
|
+
// 而精确那一个可能排在 20 名之后。实测(378 个真实联系人,都不在最近会话里):
|
|
674
|
+
// 20 名时漏 6 个、60 名时漏 5 个,**认错都是 0 个**。剩下那 5 个都是单字名——它匹配到
|
|
675
|
+
// 几十个人,精确的那个仍在窗口之外。这个残余是安全的:回 null,助手说"没找到",
|
|
676
|
+
// 而不会读到别人的聊天。
|
|
677
|
+
const contacts = await chatService.listContacts(query, 60)
|
|
678
|
+
const fields = (contact: Contact) => [contact.remark, contact.displayName, contact.nickname, contact.alias]
|
|
679
|
+
.filter(Boolean) as string[]
|
|
680
|
+
const exact = contacts.filter(c => c.username === query || fields(c).some(f => f === query))
|
|
681
|
+
const pool = exact.length ? exact : contacts.filter(c => fields(c).some(f => f.includes(query)))
|
|
682
|
+
const usernames = [...new Set(pool.map(c => c.username))]
|
|
683
|
+
return usernames.length === 1 ? usernames[0] : null
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* 显示名 → username 解析。
|
|
688
|
+
*
|
|
689
|
+
* 会话列表**只取最近 300 个**,而通讯录是完整的:名字不在那 300 个里时(对话很多的人,或
|
|
690
|
+
* 很久没聊过的人),旧行为是把查询词原样当 talker 返回——于是下游读到空、助手说"没找到
|
|
691
|
+
* 消息",而那个人其实在通讯录里。所以**匹配不上时再查一次通讯录**。
|
|
692
|
+
*
|
|
693
|
+
* 兜底只做"名字 → username",不去验证这个会话有没有消息:没有消息时下游会如实说
|
|
694
|
+
* "没找到消息",那是对的答案,不该在这里变成"查无此人"。
|
|
695
|
+
*/
|
|
696
|
+
async function resolveTalker(name: string): Promise<string> {
|
|
697
|
+
const sessions = await chatService.listSessions(undefined, 300)
|
|
698
|
+
const talker = resolveUniqueTalker(name, sessions) // 歧义会抛,让它抛
|
|
699
|
+
if (sessions.some(session => session.username === talker)) return talker
|
|
700
|
+
const fromBook = await lookupContactUsername(name)
|
|
701
|
+
return fromBook ?? talker
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
export const TOOL_DEFS: ToolDef[] = [
|
|
705
|
+
{
|
|
706
|
+
type: 'function',
|
|
707
|
+
function: {
|
|
708
|
+
name: 'list_sessions',
|
|
709
|
+
description: '列出用户最近的微信聊天会话(显示名+最后一条消息摘要)。用于了解用户最近在和谁聊天。',
|
|
710
|
+
parameters: {
|
|
711
|
+
type: 'object',
|
|
712
|
+
properties: {
|
|
713
|
+
limit: { type: 'number', description: '返回条数, 默认15' },
|
|
714
|
+
},
|
|
715
|
+
},
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
type: 'function',
|
|
720
|
+
function: {
|
|
721
|
+
name: 'get_messages',
|
|
722
|
+
description: '读取用户与某位联系人的聊天记录。默认给最近若干条;'
|
|
723
|
+
+ '问**某一天或某段时间**时给 since/until —— 不给的话久远的日子按条数是取不到的。'
|
|
724
|
+
+ '联系人名用会话里出现的显示名。',
|
|
725
|
+
parameters: {
|
|
726
|
+
type: 'object',
|
|
727
|
+
properties: {
|
|
728
|
+
contact: { type: 'string', description: '联系人显示名或备注名' },
|
|
729
|
+
limit: { type: 'number', description: '消息条数, 默认20, 上限50' },
|
|
730
|
+
since: { type: 'string', description: '起点:2026-09-16 或相对写法 3d / 2w / 12h' },
|
|
731
|
+
until: { type: 'string', description: '终点(含当天):2026-09-16' },
|
|
732
|
+
},
|
|
733
|
+
required: ['contact'],
|
|
734
|
+
},
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
{
|
|
738
|
+
type: 'function',
|
|
739
|
+
function: {
|
|
740
|
+
name: 'look_at_image',
|
|
741
|
+
description: '看聊天里的一张图片(把图片本身交给模型看,不只是"这里有一张图")。'
|
|
742
|
+
+ '`image` 填 get_messages 输出里 `[图片 #1234]` 的编号。'
|
|
743
|
+
+ '一轮最多看两张;隐私模式为 strict 时不可用(聊天图片不出发本机)。',
|
|
744
|
+
parameters: {
|
|
745
|
+
type: 'object',
|
|
746
|
+
properties: {
|
|
747
|
+
contact: { type: 'string', description: '这张图所在会话的联系人显示名' },
|
|
748
|
+
image: { type: 'string', description: 'get_messages 里 [图片 #N] 的编号 N' },
|
|
749
|
+
},
|
|
750
|
+
required: ['contact', 'image'],
|
|
751
|
+
},
|
|
752
|
+
},
|
|
753
|
+
},
|
|
754
|
+
{
|
|
755
|
+
type: 'function',
|
|
756
|
+
function: {
|
|
757
|
+
name: 'search_favorites',
|
|
758
|
+
description: '搜索用户的微信收藏(主要是收藏的公众号文章);**不给关键词就是最近收藏了什么**。',
|
|
759
|
+
parameters: {
|
|
760
|
+
type: 'object',
|
|
761
|
+
properties: {
|
|
762
|
+
keyword: { type: 'string', description: '搜索关键词' },
|
|
763
|
+
limit: { type: 'number', description: '返回条数, 默认8' },
|
|
764
|
+
},
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
},
|
|
768
|
+
{
|
|
769
|
+
type: 'function',
|
|
770
|
+
function: {
|
|
771
|
+
name: 'read_favorite',
|
|
772
|
+
description: '读取一篇收藏文章的正文内容。先用 search_favorites 或按关键词找到文章标题, 再用标题关键词调本工具抓取正文。适合「这篇文章讲了啥」「总结第一篇」类问题。',
|
|
773
|
+
parameters: {
|
|
774
|
+
type: 'object',
|
|
775
|
+
properties: {
|
|
776
|
+
keyword: { type: 'string', description: '文章标题中的关键词' },
|
|
777
|
+
max_chars: { type: 'number', description: '返回正文字数上限, 默认3000' },
|
|
778
|
+
},
|
|
779
|
+
required: ['keyword'],
|
|
780
|
+
},
|
|
781
|
+
},
|
|
782
|
+
},
|
|
783
|
+
{
|
|
784
|
+
type: 'function',
|
|
785
|
+
function: {
|
|
786
|
+
name: 'get_daily_report',
|
|
787
|
+
description: '获取公众号日报: 推送了哪些文章(标题/来源/分类/AI摘要)。'
|
|
788
|
+
+ '适合「今天/某天公众号推了什么」「最近有哪些AI文章」「搜一下关于X的文章」类问题。'
|
|
789
|
+
+ '**给 date 就是那一天的;不给 date 只给关键词/分类就是跨日期搜**(默认最近 7 天,用 days 调)。'
|
|
790
|
+
+ '要当天那份**人读的日报正文**(不是条目清单)加 full。',
|
|
791
|
+
parameters: {
|
|
792
|
+
type: 'object',
|
|
793
|
+
properties: {
|
|
794
|
+
date: { type: 'string', description: '日期 YYYY-MM-DD;省略则最新一期(或配合关键词跨日期搜)' },
|
|
795
|
+
topic: { type: 'string', description: '分类过滤: AI | 学术 | 新闻 | 文学 | 投资' },
|
|
796
|
+
keyword: { type: 'string', description: '标题/摘要/来源关键词。不给 date 时跨日期搜' },
|
|
797
|
+
days: { type: 'number', description: '跨日期搜的时间窗(天), 默认 7, 上限 60' },
|
|
798
|
+
full: { type: 'boolean', description: '返回当天日报的正文(人读的那份),而不是条目清单' },
|
|
799
|
+
max_chars: { type: 'number', description: 'full 时正文上限, 默认 3000, 上限 6000' },
|
|
800
|
+
limit: { type: 'number', description: '返回条数, 默认15' },
|
|
801
|
+
},
|
|
802
|
+
},
|
|
803
|
+
},
|
|
804
|
+
},
|
|
805
|
+
{
|
|
806
|
+
type: 'function',
|
|
807
|
+
function: {
|
|
808
|
+
name: 'get_sns',
|
|
809
|
+
description: '查看用户朋友圈: 最新动态时间线或统计概览(发帖数/好友数)。适合「朋友圈最近发了啥」「谁常发朋友圈」类问题。',
|
|
810
|
+
parameters: {
|
|
811
|
+
type: 'object',
|
|
812
|
+
properties: {
|
|
813
|
+
mode: { type: 'string', description: 'timeline (最新动态) / stats (统计) / users (谁发得最多), 默认 timeline' },
|
|
814
|
+
limit: { type: 'number', description: 'timeline 模式条数, 默认10' },
|
|
815
|
+
},
|
|
816
|
+
},
|
|
817
|
+
},
|
|
818
|
+
},
|
|
819
|
+
{
|
|
820
|
+
type: 'function',
|
|
821
|
+
function: {
|
|
822
|
+
name: 'get_weread',
|
|
823
|
+
description: '查看用户微信读书数据。mode: shelf(书架) | notebooks(有笔记的书) | search(按书名搜) '
|
|
824
|
+
+ '| stats(近期阅读时长) | book(某本书详情+我的进度) | review(某本书的热门书评) '
|
|
825
|
+
+ '| discover(推荐/相似书) | profile(累计阅读画像)。适合「我在读什么书」「某本书讲什么」「读了多少」类问题。'
|
|
826
|
+
+ 'book / review 需要先用 search 拿到 book_id。',
|
|
827
|
+
parameters: {
|
|
828
|
+
type: 'object',
|
|
829
|
+
properties: {
|
|
830
|
+
mode: { type: 'string', description: 'shelf | notebooks | search | stats | book | review | discover | profile, 默认 shelf' },
|
|
831
|
+
keyword: { type: 'string', description: 'search 模式的书名关键词' },
|
|
832
|
+
book_id: { type: 'string', description: 'book / review 模式的书籍 id(search 或 shelf 的结果里有)' },
|
|
833
|
+
},
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
},
|
|
837
|
+
{
|
|
838
|
+
type: 'function',
|
|
839
|
+
function: {
|
|
840
|
+
name: 'get_todos',
|
|
841
|
+
description: '查看用户从聊天记录提取的待办任务清单(含优先级和截止时间)。适合「我最近有什么待办」「有什么紧急的事」类问题。'
|
|
842
|
+
+ '每条后面方括号里的 id 用来改某一条的状态(set_todo_status 需要它)。按紧急度分组看用 group_by。',
|
|
843
|
+
parameters: {
|
|
844
|
+
type: 'object',
|
|
845
|
+
properties: {
|
|
846
|
+
status: { type: 'string', description: 'pending | done, 默认 pending' },
|
|
847
|
+
group_by: { type: 'string', description: '传 urgency 就按紧急度分组列出' },
|
|
848
|
+
},
|
|
849
|
+
},
|
|
850
|
+
},
|
|
851
|
+
},
|
|
852
|
+
{
|
|
853
|
+
type: 'function',
|
|
854
|
+
function: {
|
|
855
|
+
name: 'search_chats',
|
|
856
|
+
description: '在**聊天记录**里检索「在哪聊过某件事」,按字面词匹配。适合「上次说的那个部署方案是在哪聊的」。'
|
|
857
|
+
+ '(知识库用 search_knowledge;助手记得的关于你的事用 search_memory)'
|
|
858
|
+
+ '「谁提过这个客户」这类问题。它只匹配字面词(同义改写要靠别的路子),所以问题描述得具体些。'
|
|
859
|
+
+ '代价:会把你的问题与候选词发给判断模型(不发聊天正文),约 1-2 秒。',
|
|
860
|
+
parameters: {
|
|
861
|
+
type: 'object',
|
|
862
|
+
properties: {
|
|
863
|
+
question: { type: 'string', description: '要找的事,用自然语言描述' },
|
|
864
|
+
per_card: { type: 'number', description: '每个会话最多回几条消息,默认 3' },
|
|
865
|
+
confirm: {
|
|
866
|
+
type: 'boolean',
|
|
867
|
+
description: '仅当用户**明确同意**这个工具会把内容发出去时才传 true。不传或传 false 时只返回预览(要发什么、发给谁),零出境。',
|
|
868
|
+
},
|
|
869
|
+
},
|
|
870
|
+
required: ['question'],
|
|
871
|
+
},
|
|
872
|
+
},
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
type: 'function',
|
|
876
|
+
function: {
|
|
877
|
+
name: 'draft_reply',
|
|
878
|
+
description: '帮用户**起草**一条回复(只产出候选文本,绝不替他发送)。适合「帮我回老王那句话」'
|
|
879
|
+
+ '「这条怎么回」。会先判断对方要什么、该不该给实质内容、风险多高,再给几条候选。'
|
|
880
|
+
+ '涉及钱或风险很高时**不给草稿**,只说明风险与该先确认什么——别把这种情况当失败。'
|
|
881
|
+
+ '(想看对方写了什么用 get_messages;想知道谁在等用 who_owes_reply。)'
|
|
882
|
+
+ '代价:要把这段对话发给判断模型与生成模型(两个云端模型),约几秒;'
|
|
883
|
+
+ '**外部机器调用(MCP)不带 confirm 时只返回预览**(发多少字符给谁),不实际出境。',
|
|
884
|
+
parameters: {
|
|
885
|
+
type: 'object',
|
|
886
|
+
properties: {
|
|
887
|
+
contact: { type: 'string', description: '给谁起草(联系人名,和 get_messages 一样解析)' },
|
|
888
|
+
count: { type: 'number', description: '要几条候选,默认 3(最多 5)' },
|
|
889
|
+
confirm: {
|
|
890
|
+
type: 'boolean',
|
|
891
|
+
description: '仅当用户**明确同意**把这段对话发给两个云端模型时才传 true。'
|
|
892
|
+
+ '不传或传 false 时只返回预览(多少条消息、多少字符、发给哪两个模型),零出境。',
|
|
893
|
+
},
|
|
894
|
+
},
|
|
895
|
+
required: ['contact'],
|
|
896
|
+
},
|
|
897
|
+
},
|
|
898
|
+
},
|
|
899
|
+
{
|
|
900
|
+
type: 'function',
|
|
901
|
+
function: {
|
|
902
|
+
name: 'who_owes_reply',
|
|
903
|
+
description: '看看谁在等你回话(对方说完就没下文的那种)。适合「我有没有漏回谁的消息」。'
|
|
904
|
+
+ '代价:逐会话问一次判断模型,可能要几十秒;只报谁在等,不回正文。',
|
|
905
|
+
parameters: {
|
|
906
|
+
type: 'object',
|
|
907
|
+
properties: {
|
|
908
|
+
days: { type: 'number', description: '只看最近多少天有动静的会话,默认 14' },
|
|
909
|
+
confirm: {
|
|
910
|
+
type: 'boolean',
|
|
911
|
+
description: '仅当用户**明确同意**这个工具会把内容发出去时才传 true。不传或传 false 时只返回预览(要发什么、发给谁),零出境。',
|
|
912
|
+
},
|
|
913
|
+
},
|
|
914
|
+
},
|
|
915
|
+
},
|
|
916
|
+
},
|
|
917
|
+
{
|
|
918
|
+
type: 'function',
|
|
919
|
+
function: {
|
|
920
|
+
name: 'search_semantic',
|
|
921
|
+
description: '按**意思**找聊天记录(语义/同义检索),而 search_chats 只匹配字面词——'
|
|
922
|
+
+ '想不起原话、只记得大意时用它。'
|
|
923
|
+
+ '「上次说的那个部署方案是在哪聊的」用 search_chats;「和钱有关的讨论」这种同义改写用这条。'
|
|
924
|
+
+ '代价:查询词会发给阿里云百炼做嵌入、候选片段会发给判断模型重排(都是仓库既有的云端路径),'
|
|
925
|
+
+ '需要先建过索引(weflow-cli search-index)。',
|
|
926
|
+
parameters: {
|
|
927
|
+
type: 'object',
|
|
928
|
+
properties: {
|
|
929
|
+
query: { type: 'string', description: '要找的意思,用自然语言描述' },
|
|
930
|
+
line: {
|
|
931
|
+
type: 'string',
|
|
932
|
+
description: '搜哪一条知识库:wiki=公众号文章线与聊天记录里的文章(默认)、'
|
|
933
|
+
+ 'chat=你的微信聊天记录、all=两条都搜。**默认只搜 wiki**,'
|
|
934
|
+
+ '问"我们聊过什么"「上次说的那个」这类一定要给 chat 或 all,否则会搜不到。',
|
|
935
|
+
},
|
|
936
|
+
top_k: { type: 'number', description: '要几条,默认 8,上限 20' },
|
|
937
|
+
confirm: {
|
|
938
|
+
type: 'boolean',
|
|
939
|
+
description: '仅当用户**明确同意**这个工具会把内容发出去时才传 true。不传或传 false 时只返回预览(要发什么、发给谁),零出境。',
|
|
940
|
+
},
|
|
941
|
+
},
|
|
942
|
+
required: ['query'],
|
|
943
|
+
},
|
|
944
|
+
},
|
|
945
|
+
},
|
|
946
|
+
{
|
|
947
|
+
type: 'function',
|
|
948
|
+
function: {
|
|
949
|
+
name: 'export_chat',
|
|
950
|
+
description: '把某个人的聊天记录导出成 HTML(含图片),落到 output/exports/ 下。'
|
|
951
|
+
+ '适合「帮我把和某某的聊天导出备份一下」。**会写文件**:每次新建一个带时间戳的目录,绝不覆盖已有的,'
|
|
952
|
+
+ '路径也不是你给而是固定的。',
|
|
953
|
+
parameters: {
|
|
954
|
+
type: 'object',
|
|
955
|
+
properties: {
|
|
956
|
+
contact: { type: 'string', description: '联系人显示名或备注名' },
|
|
957
|
+
limit: { type: 'number', description: '最多导出多少条,默认 500,上限 5000' },
|
|
958
|
+
format: { type: 'string', description: 'html(默认,含图片) / txt / json / excel' },
|
|
959
|
+
},
|
|
960
|
+
required: ['contact'],
|
|
961
|
+
},
|
|
962
|
+
},
|
|
963
|
+
},
|
|
964
|
+
{
|
|
965
|
+
type: 'function',
|
|
966
|
+
function: {
|
|
967
|
+
name: 'search_knowledge',
|
|
968
|
+
description: '搜索**本地知识库**的概念页(公众号文章沉淀的是一条线、你自己的微信对话沉淀的是另一条),'
|
|
969
|
+
+ '适合查概念解释、找之前整理过的知识。**默认只搜文章那条线**,`line` 可以换成聊天那条或两条都搜。'
|
|
970
|
+
+ '**返回时会一并给出这一页连到的相关概念(每个一句定义)**——可以顺着它们再用本工具逐跳查下去,'
|
|
971
|
+
+ '这样一次追问能走开一小块知识,而不是停在一个孤立页面上。'
|
|
972
|
+
+ '(聊天里说过什么用 search_chats;助手记得的关于你的事用 search_memory)',
|
|
973
|
+
parameters: {
|
|
974
|
+
type: 'object',
|
|
975
|
+
properties: {
|
|
976
|
+
keyword: { type: 'string', description: '概念名或关键词, 如 RAG、Agent' },
|
|
977
|
+
line: {
|
|
978
|
+
type: 'string',
|
|
979
|
+
description: '搜哪一条知识库:wiki=公众号文章沉淀的概念(默认)、'
|
|
980
|
+
+ 'chat=你自己的微信对话沉淀的概念、all=两条都搜。'
|
|
981
|
+
+ '**默认只搜 wiki**;问"我们聊过的某个概念"要给 chat 或 all。'
|
|
982
|
+
+ '同一个概念名两条线可能各有一张页(那是设计),返回里会指明给的是哪一条。',
|
|
983
|
+
},
|
|
984
|
+
depth: { type: 'integer', description: '沿「相关概念」走几跳:默认 1 只给直接邻居,2 能一次看到这个概念周围的生态(最多 3)' },
|
|
985
|
+
},
|
|
986
|
+
required: ['keyword'],
|
|
987
|
+
},
|
|
988
|
+
},
|
|
989
|
+
},
|
|
990
|
+
{
|
|
991
|
+
type: 'function',
|
|
992
|
+
function: {
|
|
993
|
+
name: 'get_stats',
|
|
994
|
+
description: '本地数据总览:会话数、收藏总数、日报文章数与覆盖天数、主题分布、概念页数。'
|
|
995
|
+
+ '适合「我一共有多少数据」这类总览问题;单看公众号推送用 get_reading_stats。',
|
|
996
|
+
parameters: { type: 'object', properties: {} },
|
|
997
|
+
},
|
|
998
|
+
},
|
|
999
|
+
{
|
|
1000
|
+
type: 'function',
|
|
1001
|
+
function: {
|
|
1002
|
+
name: 'get_reading_stats',
|
|
1003
|
+
description: '**公众号**推送与日报处理的统计:哪些号发得多、哪些被日报处理得多。'
|
|
1004
|
+
+ '适合"哪个号发得最多""日报处理了多少篇"这类问题。'
|
|
1005
|
+
// 原文写的是「回答"我最近都在读什么/哪个号发得最多"这类问题时用它」——
|
|
1006
|
+
// 而"我最近在读什么"**正是微信读书的问题**。2026-09-28 实测:用户问"我在读什么书",
|
|
1007
|
+
// 模型抓住这一句用了它,返回一串公众号推送量("发得最多的是红星新闻"),
|
|
1008
|
+
// 用户看到的就是一个答非所问的答复。工具没错,错在这句说明侵占了读书的问题空间。
|
|
1009
|
+
+ '**它统计的是公众号推送与日报,不含读书数据**——"我在读什么书"那类问题不要用它。'
|
|
1010
|
+
+ '**日报没在跑的时候它会直说**(那不是"没内容")。',
|
|
1011
|
+
parameters: {
|
|
1012
|
+
type: 'object',
|
|
1013
|
+
properties: {
|
|
1014
|
+
days: { type: 'number', description: '统计最近多少天,默认 7,上限 90' },
|
|
1015
|
+
},
|
|
1016
|
+
},
|
|
1017
|
+
},
|
|
1018
|
+
},
|
|
1019
|
+
{
|
|
1020
|
+
type: 'function',
|
|
1021
|
+
function: {
|
|
1022
|
+
name: 'search_memory',
|
|
1023
|
+
description: '搜索**助手记得的关于你的事**(此前对话里提取的长期事实:偏好、项目、关系)。'
|
|
1024
|
+
+ '(聊天正文用 search_chats,整理过的知识用 search_knowledge)',
|
|
1025
|
+
parameters: {
|
|
1026
|
+
type: 'object',
|
|
1027
|
+
properties: {
|
|
1028
|
+
keyword: { type: 'string', description: '搜索关键词' },
|
|
1029
|
+
},
|
|
1030
|
+
required: ['keyword'],
|
|
1031
|
+
},
|
|
1032
|
+
},
|
|
1033
|
+
},
|
|
1034
|
+
{
|
|
1035
|
+
type: 'function',
|
|
1036
|
+
function: {
|
|
1037
|
+
name: 'save_memory',
|
|
1038
|
+
description: '把关于用户的重要持久事实存入长期记忆(如偏好、项目背景、重要关系)。',
|
|
1039
|
+
parameters: {
|
|
1040
|
+
type: 'object',
|
|
1041
|
+
properties: {
|
|
1042
|
+
content: { type: 'string', description: '事实内容, 40字以内' },
|
|
1043
|
+
},
|
|
1044
|
+
required: ['content'],
|
|
1045
|
+
},
|
|
1046
|
+
},
|
|
1047
|
+
},
|
|
1048
|
+
{
|
|
1049
|
+
type: 'function',
|
|
1050
|
+
function: {
|
|
1051
|
+
name: 'list_skills',
|
|
1052
|
+
description: '列出本机装好的技能包(名字+一句话说明+版本)。技能是一份说明文档,'
|
|
1053
|
+
+ '用 read_skill 读它的正文再照着做;不要凭名字猜它怎么用。',
|
|
1054
|
+
parameters: {
|
|
1055
|
+
type: 'object',
|
|
1056
|
+
properties: {
|
|
1057
|
+
query: { type: 'string', description: '可选:按关键词过滤(匹配 id、名称、说明和触发词)' },
|
|
1058
|
+
},
|
|
1059
|
+
},
|
|
1060
|
+
},
|
|
1061
|
+
},
|
|
1062
|
+
{
|
|
1063
|
+
type: 'function',
|
|
1064
|
+
function: {
|
|
1065
|
+
name: 'read_skill',
|
|
1066
|
+
description: '读取某个技能包的完整说明(SKILL.md 正文)。要用某个技能前先读它。'
|
|
1067
|
+
+ 'id 用 list_skills 查到的那个。',
|
|
1068
|
+
parameters: {
|
|
1069
|
+
type: 'object',
|
|
1070
|
+
properties: {
|
|
1071
|
+
id: { type: 'string', description: '技能 id,如 wechat-article-extract' },
|
|
1072
|
+
},
|
|
1073
|
+
required: ['id'],
|
|
1074
|
+
},
|
|
1075
|
+
},
|
|
1076
|
+
},
|
|
1077
|
+
{
|
|
1078
|
+
type: 'function',
|
|
1079
|
+
function: {
|
|
1080
|
+
name: 'list_contacts',
|
|
1081
|
+
description: '列出通讯录里的联系人(显示名、备注、昵称)。**不含 wxid 等本机标识**,'
|
|
1082
|
+
+ '只有能叫得出名字的人。'
|
|
1083
|
+
+ '(要看最近跟谁聊过用 list_sessions;要按名字查某个会话的聊天用 get_messages)',
|
|
1084
|
+
parameters: {
|
|
1085
|
+
type: 'object',
|
|
1086
|
+
properties: {
|
|
1087
|
+
keyword: { type: 'string', description: '按名字过滤(可省略)' },
|
|
1088
|
+
limit: { type: 'number', description: '最多几条, 默认 50, 上限 200' },
|
|
1089
|
+
},
|
|
1090
|
+
},
|
|
1091
|
+
},
|
|
1092
|
+
},
|
|
1093
|
+
{
|
|
1094
|
+
type: 'function',
|
|
1095
|
+
function: {
|
|
1096
|
+
name: 'get_review',
|
|
1097
|
+
description: '读某一天的学习回顾(对当天日报文章做的二次整理:要点、联系、值得回看的)。'
|
|
1098
|
+
+ '**与 get_daily_report 不是一回事**:那个是当天日报的原始条目清单,这个是复盘。',
|
|
1099
|
+
parameters: {
|
|
1100
|
+
type: 'object',
|
|
1101
|
+
properties: {
|
|
1102
|
+
date: { type: 'string', description: '日期 YYYY-MM-DD,省略则取最近一份' },
|
|
1103
|
+
},
|
|
1104
|
+
},
|
|
1105
|
+
},
|
|
1106
|
+
},
|
|
1107
|
+
{
|
|
1108
|
+
type: 'function',
|
|
1109
|
+
function: {
|
|
1110
|
+
name: 'get_concepts',
|
|
1111
|
+
description: '读知识库的两份总览索引(文章线、聊天线各一份)。这是**目录**,'
|
|
1112
|
+
+ '用来回答"我的知识库里都有什么";要按名字找**某一页**的内容用 search_knowledge。',
|
|
1113
|
+
parameters: {
|
|
1114
|
+
type: 'object',
|
|
1115
|
+
properties: {
|
|
1116
|
+
limit: { type: 'number', description: '每份索引最多返回多少行, 默认 40' },
|
|
1117
|
+
},
|
|
1118
|
+
},
|
|
1119
|
+
},
|
|
1120
|
+
},
|
|
1121
|
+
{
|
|
1122
|
+
type: 'function',
|
|
1123
|
+
function: {
|
|
1124
|
+
name: 'fetch_article',
|
|
1125
|
+
description: '抓取一篇公众号文章的正文(需要完整链接)。只接受 https 的 mp.weixin.qq.com 链接。'
|
|
1126
|
+
+ '与 read_favorite 的区别:那个从你自己的收藏里找,这个由用户给出链接。',
|
|
1127
|
+
parameters: {
|
|
1128
|
+
type: 'object',
|
|
1129
|
+
properties: {
|
|
1130
|
+
url: { type: 'string', description: '公众号文章完整链接' },
|
|
1131
|
+
max_chars: { type: 'number', description: '正文上限, 默认 3000, 上限 6000' },
|
|
1132
|
+
},
|
|
1133
|
+
required: ['url'],
|
|
1134
|
+
},
|
|
1135
|
+
},
|
|
1136
|
+
},
|
|
1137
|
+
{
|
|
1138
|
+
type: 'function',
|
|
1139
|
+
function: {
|
|
1140
|
+
name: 'search_public',
|
|
1141
|
+
description: '在公开的公众号文章里按关键词搜标题与摘要(走第三方搜索页)。'
|
|
1142
|
+
+ '只搜公开文章,搜不到你自己的收藏、聊天记录或知识库。',
|
|
1143
|
+
parameters: {
|
|
1144
|
+
type: 'object',
|
|
1145
|
+
properties: {
|
|
1146
|
+
keyword: { type: 'string', description: '搜索词' },
|
|
1147
|
+
limit: { type: 'number', description: '最多几条, 默认 8, 上限 20' },
|
|
1148
|
+
},
|
|
1149
|
+
required: ['keyword'],
|
|
1150
|
+
},
|
|
1151
|
+
},
|
|
1152
|
+
},
|
|
1153
|
+
{
|
|
1154
|
+
type: 'function',
|
|
1155
|
+
function: {
|
|
1156
|
+
name: 'format_article',
|
|
1157
|
+
description: '把 Markdown 转成能直接粘进公众号编辑器的 HTML 片段(纯本地转换,不联网、不写文件)。'
|
|
1158
|
+
+ '主题名用 list_themes 查。',
|
|
1159
|
+
parameters: {
|
|
1160
|
+
type: 'object',
|
|
1161
|
+
properties: {
|
|
1162
|
+
// 参数名保持 `content` —— 这个名字在 MCP 那条路上已经被外部客户端用了,
|
|
1163
|
+
// 改名的代价是别人的调用直接失效(工具名不变、参数变了,最难查的那种坏法)
|
|
1164
|
+
content: { type: 'string', description: '要排版的 Markdown 正文' },
|
|
1165
|
+
theme: { type: 'string', description: '主题名(省略用 default)' },
|
|
1166
|
+
},
|
|
1167
|
+
required: ['content'],
|
|
1168
|
+
},
|
|
1169
|
+
},
|
|
1170
|
+
},
|
|
1171
|
+
{
|
|
1172
|
+
type: 'function',
|
|
1173
|
+
function: {
|
|
1174
|
+
name: 'list_themes',
|
|
1175
|
+
description: '列出可用的公众号排版主题(配合 format_article 用)。',
|
|
1176
|
+
parameters: { type: 'object', properties: {} },
|
|
1177
|
+
},
|
|
1178
|
+
},
|
|
1179
|
+
{
|
|
1180
|
+
type: 'function',
|
|
1181
|
+
function: {
|
|
1182
|
+
name: 'lint_wiki',
|
|
1183
|
+
description: '知识库体检:断链、孤儿页、空页、同名页。**只读**,不改任何页。',
|
|
1184
|
+
parameters: { type: 'object', properties: {} },
|
|
1185
|
+
},
|
|
1186
|
+
},
|
|
1187
|
+
{
|
|
1188
|
+
type: 'function',
|
|
1189
|
+
function: {
|
|
1190
|
+
name: 'check_skills',
|
|
1191
|
+
description: '技能体检:报出读不出来的 SKILL.md、跨目录同名、id 不合规范、被禁用的技能。'
|
|
1192
|
+
+ '与 list_skills 的区别:那个是清单,这个是**问题清单**。',
|
|
1193
|
+
parameters: { type: 'object', properties: {} },
|
|
1194
|
+
},
|
|
1195
|
+
},
|
|
1196
|
+
{
|
|
1197
|
+
type: 'function',
|
|
1198
|
+
function: {
|
|
1199
|
+
name: 'set_todo_status',
|
|
1200
|
+
description: '把一条待办标记为已完成 / 取消完成。**不能删除待办、不能新增**。'
|
|
1201
|
+
+ '要精确指到那一条:优先用 get_todos 给出的 id;也可以给一段任务文字,'
|
|
1202
|
+
+ '但**匹配到零条或多条都会被拒绝**(猜错条目比拒绝更糟)。',
|
|
1203
|
+
parameters: {
|
|
1204
|
+
type: 'object',
|
|
1205
|
+
properties: {
|
|
1206
|
+
id: { type: 'string', description: '待办 id(get_todos 输出里的那个)' },
|
|
1207
|
+
task: { type: 'string', description: '或用任务文字精确匹配(必须唯一)' },
|
|
1208
|
+
status: { type: 'string', description: 'done = 已完成, pending = 取消完成' },
|
|
1209
|
+
},
|
|
1210
|
+
required: ['status'],
|
|
1211
|
+
},
|
|
1212
|
+
},
|
|
1213
|
+
},
|
|
1214
|
+
]
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* MCP 那条路看得见的工具表。
|
|
1218
|
+
*
|
|
1219
|
+
* **它不叫 read-only**(曾经叫,而那是假的):里面一直有写文件的 `export_chat`,也有几个会把
|
|
1220
|
+
* 用户数据发给云端模型、需要 `confirm: true` 的工具。这里只排除**在那条路上根本做不了事**的,
|
|
1221
|
+
* 而"为什么做不了"写在下面那张表里(值就是原因,不是 true):
|
|
1222
|
+
*
|
|
1223
|
+
* - `save_memory`:写记忆。MCP 没有微信用户身份,挂的记忆桶是单独一条;让它写没有意义。
|
|
1224
|
+
* - `look_at_image`:它的做法是把图**挂进侧信道**(`ctx.pendingImages`)交给助手自己的模型看,
|
|
1225
|
+
* 而 MCP 那条路只把工具返回的**文本**交给客户端(见 `mcp-server/index.ts` 的 default 分支)——
|
|
1226
|
+
* 图没人接,回话里却写着"已附上图片…你能看到它了",那是**一句假话**。
|
|
1227
|
+
* 按本仓"不摆一个跑不了的工具"那条立论(同 `unavailableToolReason`),它不该出现在这里。
|
|
1228
|
+
*
|
|
1229
|
+
* 做成表而不是写进 filter:这是**注册表的一部分**,`test/tool-registry.test.ts` 会盯着
|
|
1230
|
+
* 表里的每个名字都真的存在于 `TOOL_DEFS`(写错一个名字 = 静默地没排除掉任何东西)。
|
|
1231
|
+
*/
|
|
1232
|
+
export const MCP_EXCLUDED: Record<string, string> = {
|
|
1233
|
+
save_memory: 'MCP 没有微信用户身份,挂的记忆桶是单独一条,让它写没有意义',
|
|
1234
|
+
look_at_image: 'MCP 只把工具返回的文本交给客户端,它挂进侧信道的图没人接,回话会变成一句假话',
|
|
1235
|
+
// 这条是**故意留在外面的**(docs/MCP.md 的天花板:「mutating todos ... stay out」):
|
|
1236
|
+
// 助手在微信/面板里可以替你翻转一条待办状态,但外部 MCP 客户端不行——
|
|
1237
|
+
// 那条路上没有"你",只有调用方;一条机器指令顺手改掉你的待办,不该是默认能力。
|
|
1238
|
+
set_todo_status: '待办是本地状态,MCP 那条路上没有用户可确认;改状态请在微信/面板里说,或用 CLI 的两段式 todos done',
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
export const MCP_TOOL_DEFS = TOOL_DEFS.filter(tool => !(tool.function.name in MCP_EXCLUDED))
|
|
1242
|
+
|
|
1243
|
+
/** 脚本类工具的失败回话:桥接层已经分好类(超时/退出码/没有 JSON/脚本自己报错),
|
|
1244
|
+
* 这里把它和 stderr 尾巴合起来**过一遍脱敏**再交给模型——stderr 里可能有密钥形状的东西,
|
|
1245
|
+
* 而工具结果是要出境的。
|
|
1246
|
+
*/
|
|
1247
|
+
function fail(what: string, result: { error?: string; stderr?: string }): string {
|
|
1248
|
+
const detail = [result.error, result.stderr].filter(Boolean).join(' · ')
|
|
1249
|
+
const { safe } = privacyGate.redact(detail)
|
|
1250
|
+
return `(${what}${safe ? ": " + safe : ""})`
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* **括号起头 = 工具没能给出内容**。这是本仓库既有的写法:`fail()` 与各分支的
|
|
1255
|
+
* "查不到 / 缺参数 / 拒绝抓取"一律写成 `(…)`,而正常内容不带括号。
|
|
1256
|
+
*
|
|
1257
|
+
* 这张白名单是**唯一的例外**:成功的句子也可以是括号起头(不然它就不叫例外了)。
|
|
1258
|
+
* 有一条测试盯着它——白名单里每一条都必须在 `assistantTools.ts` 里真的以 `return` 出现,
|
|
1259
|
+
* 改措辞忘了同步就会红。反过来(有新的成功消息带括号、但没进白名单)测试抓不到,
|
|
1260
|
+
* 这是这个信号的真实边界:**它靠约定,不是一个机器可读的成败字段**。
|
|
1261
|
+
*
|
|
1262
|
+
* 想要真字段,得把 outcome 从 ~40 个 return 处一路带出来(`ToolContext.outcome`)。
|
|
1263
|
+
* 那件事记在 D-043 的"已知边界"里,没有做——这里用它是因为轨迹只想知道"这一步有没有
|
|
1264
|
+
* 产出东西",而这个判断错了的代价是报告里多一个/少一个「无内容」标记。
|
|
1265
|
+
*/
|
|
1266
|
+
export const PAREN_SUCCESS_PREFIXES = ['(已附上图片']
|
|
1267
|
+
|
|
1268
|
+
/** 工具这次**有没有给出内容**。括号起头(除白名单外)都算没有。 */
|
|
1269
|
+
export function producedContent(text: string): boolean {
|
|
1270
|
+
const head = (text ?? '').trimStart()
|
|
1271
|
+
if (!head.startsWith('(')) return true
|
|
1272
|
+
return PAREN_SUCCESS_PREFIXES.some(prefix => head.startsWith(prefix))
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* "这台机器上跑不了"的**声明**:缺哪个配置键、缺了之后怎么跟用户说(值就是那句话)。
|
|
1277
|
+
*
|
|
1278
|
+
* 做成一张表而不是一串 `if (name === '...')`:加一个带前置条件的工具,现在是在这里加**一行**,
|
|
1279
|
+
* 而不是在 if 链里再插一段;`test/tool-registry.test.ts` 盯着表里的每个名字都真的存在于 `TOOL_DEFS`
|
|
1280
|
+
* (写错名字 = 那条规则永远不生效,而且不会有任何报错)。
|
|
1281
|
+
*
|
|
1282
|
+
* **只写"缺了就跑不了"的前置条件**,不写"暂时没数据"——没数据时工具自己会说该运行什么
|
|
1283
|
+
* (`先运行 weflow-cli wiki compile`),那是有用的回答。
|
|
1284
|
+
*/
|
|
1285
|
+
export const TOOL_REQUIREMENTS: Record<string, { key: string; why: string }> = {
|
|
1286
|
+
search_semantic: { key: 'dashscopeApiKey', why: '语义检索需要 dashscopeApiKey' },
|
|
1287
|
+
get_weread: { key: 'wereadApiKey', why: '微信读书需要 wereadApiKey' },
|
|
1288
|
+
draft_reply: {
|
|
1289
|
+
key: 'deepseekApiKey',
|
|
1290
|
+
// **只有 DeepSeek 是必须的**。判断那一步(Jev)现在可降级:调不通就只用生成模型起草,
|
|
1291
|
+
// 输出里会写明"这次没有判断、闸门没生效"(2026-09-25 Jev 免费期结束那天改的)。
|
|
1292
|
+
// 所以缺 typesafeApiKey **不**该把这个工具收起来——那会让"能跑的工具"被藏掉。
|
|
1293
|
+
why: '起草回复需要 deepseekApiKey(生成那一步)',
|
|
1294
|
+
},
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* 这台机器上**跑不了**的工具,不该出现在工具表里。规则本身在上面的 `TOOL_REQUIREMENTS` 里,这里只是执行它。
|
|
1299
|
+
*
|
|
1300
|
+
* 为什么按配置过滤:模型看见工具就会去试。这台机器没配 `dashscopeApiKey` 时 `search_semantic`
|
|
1301
|
+
* 必然失败——实测(评测的 `ambiguous-contact` 那次)模型连试两个检索工具、两个都报错,最后答复
|
|
1302
|
+
* 里带着"两个检索工具都跑不通"。那不是助手的问题,是**我们摆了一个跑不了的工具**。工具越少,
|
|
1303
|
+
* 选择也越准。
|
|
1304
|
+
*/
|
|
1305
|
+
export function unavailableToolReason(name: string, config: (key: any) => any = configService.get.bind(configService)): string | null {
|
|
1306
|
+
const need = TOOL_REQUIREMENTS[name]
|
|
1307
|
+
if (!need) return null
|
|
1308
|
+
return String(config(need.key) || '').trim() ? null : need.why
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* 这台机器上**没启用**的能力,一句话一条,给系统提示词用。
|
|
1313
|
+
*
|
|
1314
|
+
* 为什么需要它:工具被藏起来之后,模型**看不见它存在**,于是用户问"我在读什么书"时它答
|
|
1315
|
+
* "我没有 get_weread 这个工具" —— 2026-09-28 实测就是这么一轮对话,用户因此以为功能没做
|
|
1316
|
+
* (其实做了、只是没配 key)。把"存在但没启用"告诉模型,它就能给出正确的下一步,
|
|
1317
|
+
* 而不是否认这个功能存在。
|
|
1318
|
+
*
|
|
1319
|
+
* **这不等于把工具摆回去**:模型依然调不到它们,只是知道它们存在。把跑不了的工具放进工具表
|
|
1320
|
+
* 的代价是实测过的(模型会去试,答复里带着一串报错,见 `availableToolDefs` 的说明)。
|
|
1321
|
+
*/
|
|
1322
|
+
export function unavailableCapabilities(
|
|
1323
|
+
config: (key: any) => any = configService.get.bind(configService),
|
|
1324
|
+
): string[] {
|
|
1325
|
+
return Object.entries(TOOL_REQUIREMENTS)
|
|
1326
|
+
.filter(([name]) => unavailableToolReason(name, config))
|
|
1327
|
+
.map(([, need]) => need.why)
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/** 这台机器上真正可用的工具表。快路径派发也要过同一道判据(见 `unavailableToolReason`)。 */
|
|
1331
|
+
export function availableToolDefs(config?: (key: any) => any): ToolDef[] {
|
|
1332
|
+
return TOOL_DEFS.filter(def => !unavailableToolReason(def.function.name, config))
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/**
|
|
1336
|
+
* 判断模型给的键翻成人话。
|
|
1337
|
+
*
|
|
1338
|
+
* 为什么要这一层:那些键是**英文判据的键名**(`casual_chat` / `give_commitment`),
|
|
1339
|
+
* 原样交给助手,它就会照着念给用户听。映射不上就退回原键——如实展示,不硬编一个。
|
|
1340
|
+
*/
|
|
1341
|
+
const INTENT_LABELS: Record<string, string> = {
|
|
1342
|
+
confirm_you_care: '想要你一个态度(在试探你还在不在意)',
|
|
1343
|
+
vent_anger: '在发火,想被听见而不是被解决',
|
|
1344
|
+
request_action: '有事要你办(发东西、付钱、约时间这类)',
|
|
1345
|
+
seek_explanation: '想要个解释',
|
|
1346
|
+
casual_chat: '在闲聊,没有事要你办',
|
|
1347
|
+
close_topic: '在收尾,不想再展开',
|
|
1348
|
+
}
|
|
1349
|
+
const ACTION_LABELS: Record<string, string> = {
|
|
1350
|
+
check_history: '先翻一下更早的聊天记录再说',
|
|
1351
|
+
apologize: '具体地认错',
|
|
1352
|
+
give_commitment: '给一个做得到的时间或交付',
|
|
1353
|
+
explain: '说明经过,不辩解',
|
|
1354
|
+
acknowledge: '表示收到了,先不承诺什么',
|
|
1355
|
+
say_less: '少说',
|
|
1356
|
+
make_plan: '一起定个时间',
|
|
1357
|
+
}
|
|
1358
|
+
const NEED_LABELS: Record<string, string> = {
|
|
1359
|
+
apology: '一个道歉', action: '实际的动作', explanation: '解释', care: '被在意',
|
|
1360
|
+
nothing: '什么都不用',
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function zhLabel(map: Record<string, string>, key: unknown): string {
|
|
1364
|
+
const text = String(key ?? '')
|
|
1365
|
+
return map[text] || text || '说不准'
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function riskLabel(score: unknown): string {
|
|
1369
|
+
const value = Number(score ?? 0)
|
|
1370
|
+
if (value >= 8) return '很危险'
|
|
1371
|
+
if (value >= 6) return '偏危险'
|
|
1372
|
+
if (value >= 3) return '留神'
|
|
1373
|
+
return '安全'
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* 外部机器(MCP)调用到"会把用户数据送出去"的工具时,**统一只给预览**的那段文案。
|
|
1378
|
+
*
|
|
1379
|
+
* 四个出境工具(draft_reply / who_owes_reply / search_chats / search_semantic)共用这一份形状,
|
|
1380
|
+
* 各自把"要发什么、发给谁"填进 `what`。预览阶段一律不出境:能用脚本自己的 `--dry-run`
|
|
1381
|
+
* 报出具体数字的就报数字(draft_reply、who_owes_reply),报不出的就按入参说清
|
|
1382
|
+
* (search_chats 的 --dry-run 打的是纯文本、不认 --json;semantic_search 根本没有 --dry-run)。
|
|
1383
|
+
*/
|
|
1384
|
+
function confirmPreview(what: string): string {
|
|
1385
|
+
return [
|
|
1386
|
+
'还没执行——这只是一次**预览**(没有把任何内容发出去)。',
|
|
1387
|
+
what,
|
|
1388
|
+
'用户明确同意之后再带 `confirm: true` 调一次,才会真的执行。',
|
|
1389
|
+
].join(String.fromCharCode(10))
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
export async function executeTool(name: string, args: Record<string, any>, ctx: ToolContext): Promise<string> {
|
|
1393
|
+
try {
|
|
1394
|
+
switch (name) {
|
|
1395
|
+
case 'list_sessions': {
|
|
1396
|
+
await ensureDb()
|
|
1397
|
+
const limit = boundedToolInteger(args.limit, 15, 30)
|
|
1398
|
+
const sessions = await chatService.listSessions(undefined, limit)
|
|
1399
|
+
if (!sessions.length) return '(未查到会话, 数据库可能未连接)'
|
|
1400
|
+
return sessions.map(s =>
|
|
1401
|
+
`· ${s.displayName || s.username}: ${(s.summary || '').replace(/\n/g, ' ').slice(0, 30)}`).join('\n')
|
|
1402
|
+
}
|
|
1403
|
+
case 'get_messages': {
|
|
1404
|
+
const contact = String(args.contact || '')
|
|
1405
|
+
if (!contact) return '(缺少 contact 参数)'
|
|
1406
|
+
const limit = boundedToolInteger(args.limit, 20, 50)
|
|
1407
|
+
// 时间窗:不给就是"最近 N 条"(老行为一个字没改)。给了就用范围读——
|
|
1408
|
+
// **这正是"上周三他说了什么"以前够不着的原因**:只按条数取,越久远的日子越取不到,
|
|
1409
|
+
// 模型要么说查不到,要么把最近的消息当成那天的。
|
|
1410
|
+
let from: number | undefined
|
|
1411
|
+
let to: number | undefined
|
|
1412
|
+
try {
|
|
1413
|
+
from = resolveSince(args.since)
|
|
1414
|
+
to = resolveUntil(args.until)
|
|
1415
|
+
} catch {
|
|
1416
|
+
return '(时间看不懂:since/until 可以写 2026-09-16 或 3d / 2w / 12h;收到的是'
|
|
1417
|
+
+ ` since=${JSON.stringify(args.since)} until=${JSON.stringify(args.until)})`
|
|
1418
|
+
}
|
|
1419
|
+
const windowed = from !== undefined || to !== undefined
|
|
1420
|
+
const windowLabel = windowed
|
|
1421
|
+
? `${from ? new Date(from * 1000).toLocaleDateString('zh-CN') : '最早'}`
|
|
1422
|
+
+ ` 至 ${to ? new Date(to * 1000).toLocaleDateString('zh-CN') : '现在'}`
|
|
1423
|
+
: ''
|
|
1424
|
+
const talker = await resolveTalker(contact)
|
|
1425
|
+
const msgs = windowed
|
|
1426
|
+
? await chatService.getMessagesInRange(talker, limit, from, to)
|
|
1427
|
+
: await chatService.getMessages(talker, limit)
|
|
1428
|
+
if (!msgs.length) {
|
|
1429
|
+
return windowed
|
|
1430
|
+
? `(${windowLabel} 这段时间里没有「${contact}」的消息)`
|
|
1431
|
+
: `(没找到「${contact}」的消息)`
|
|
1432
|
+
}
|
|
1433
|
+
// 图片消息带一个句柄:`[图片 #1234]` 里的 1234 就是 `look_at_image` 的 `image` 参数。
|
|
1434
|
+
// strict 模式下图片不允许出境,那时不带句柄——不摆出一个工具必定会拒绝的东西。
|
|
1435
|
+
const canLook = privacyGate.mode() !== 'strict'
|
|
1436
|
+
const rendered = msgs.map(m => {
|
|
1437
|
+
// 非文本消息优先用 `parsedContent`:它是读取器给的**显示形态**(`[图片]`、
|
|
1438
|
+
// `[文件] Base.csv`、`某人 撤回了一条消息`…),而 `content` 对这类消息可能是
|
|
1439
|
+
// 原始 XML(含 md5 与 cdn 链接)——那是给机器看的,不该塞进模型上下文。
|
|
1440
|
+
const raw = m.localType === 1
|
|
1441
|
+
? (m.content || m.parsedContent || '')
|
|
1442
|
+
: (m.parsedContent || m.content || '')
|
|
1443
|
+
// 每条 160 字。此前是 80,引用消息(`[引用] 回复 | 引:原文`)在 80 字里
|
|
1444
|
+
// 引文只剩七八个字,等于白带;长文本消息也被从中间切掉。上限仍是有界的:
|
|
1445
|
+
// limit 最多 50 条 × 约 180 字 ≈ 9k 字符。截断留省略号——切了却看起来像
|
|
1446
|
+
// 说完了,模型会把半句当整句。
|
|
1447
|
+
const flat = raw.replace(/\n/g, ' ')
|
|
1448
|
+
const body = canLook && m.localType === 3
|
|
1449
|
+
? `[图片 #${m.localId}]`
|
|
1450
|
+
: privacyGate.maskMessageBody(clipWithMarker(flat, MSG_BODY_CHARS),
|
|
1451
|
+
{ isText: m.localType === 1 })
|
|
1452
|
+
// 这一行原来用 `senderUsername`(**wxid 原列**),等于把账号标识交给云端模型。
|
|
1453
|
+
// 曾经以为"改成「对方」就分不清群聊里谁说的,是个真损失"——实测之后不成立:
|
|
1454
|
+
// `senderDisplay` 本来就有名字(见 `speakerLabel`),换成它两边都占,
|
|
1455
|
+
// 名字对模型还比 wxid 有用。自己发的仍是「用户」(这是对人说"你发了什么",
|
|
1456
|
+
// 与转录里的「我」是两种口吻,各自有测试钉着)。
|
|
1457
|
+
return `[${fmtTime(m.createTime)}] ${m.isSend ? '用户' : speakerLabel(m)}: ${body}`
|
|
1458
|
+
}).join('\n')
|
|
1459
|
+
// 有时间窗时先说清窗口:模型据此判断"这些是不是那天的",也免得它把窗口内的最后
|
|
1460
|
+
// 一条当成"最新的"。
|
|
1461
|
+
return windowed ? `(${windowLabel},共 ${msgs.length} 条)\n${rendered}` : rendered
|
|
1462
|
+
}
|
|
1463
|
+
case 'look_at_image': {
|
|
1464
|
+
const contact = String(args.contact || '')
|
|
1465
|
+
// 句柄在 get_messages 里长这样:`[图片 #1234]`。允许带不带 `#` 都接受——模型从
|
|
1466
|
+
// 上下文里抄一个编号过来,"抄得像不像"不该决定它能不能看图。
|
|
1467
|
+
const image = String(args.image ?? '').replace(/^#/, '').trim()
|
|
1468
|
+
if (!contact || !image) return '(缺少 contact 或 image 参数)'
|
|
1469
|
+
if (!/^\d+$/.test(image)) {
|
|
1470
|
+
return `(image 要填 get_messages 里 [图片 #N] 的编号,收到的是「${image.slice(0, 20)}」)`
|
|
1471
|
+
}
|
|
1472
|
+
// 第一层门:strict 模式(第三方聊天正文不出境)下图片也不出去。第二层在
|
|
1473
|
+
// `toApiMessages` 里——"工具肯给"与"这道门肯放"是两件事。
|
|
1474
|
+
if (privacyGate.mode() === 'strict' && !privacyGate.isLocalInference()) {
|
|
1475
|
+
return '(当前隐私模式是 strict:聊天图片不发出本机,用不了这个工具。'
|
|
1476
|
+
+ '要让我看图,得把 assistantPrivacy 改成 balanced)'
|
|
1477
|
+
}
|
|
1478
|
+
const budget = ctx.pendingImages ?? []
|
|
1479
|
+
if (budget.length >= MAX_IMAGES_PER_TURN) {
|
|
1480
|
+
return `(这一轮已经看过 ${MAX_IMAGES_PER_TURN} 张图了,先把已经看到的说完,别再取)`
|
|
1481
|
+
}
|
|
1482
|
+
const talker = await resolveTalker(contact)
|
|
1483
|
+
// 90 秒。建媒体索引是这条路唯一的成本,且随会话大小走:实测单聊 ~1 秒、
|
|
1484
|
+
// 一个 3 万条消息的大群 18.9 秒(而那个群本机只有缩略图)。取到的图会落在
|
|
1485
|
+
// `output/.cache/read-image/`,同一张再看是 0.1 秒。超时是必要的兜底——助手是
|
|
1486
|
+
// **串行**处理消息的,一次卡住会挡住后面所有人的话,所以宁可说"取图失败"。
|
|
1487
|
+
const r = await runPythonJson<any>('read_image.py',
|
|
1488
|
+
['--talker', talker, '--local-id', image, '--json'], { timeoutMs: 90_000 })
|
|
1489
|
+
if (!r.ok) return fail('取图失败', r)
|
|
1490
|
+
if (!r.data?.b64) {
|
|
1491
|
+
// 文字说明而不是空:本机没有这张图的副本(缓存清过、或只有缩略图)是常态
|
|
1492
|
+
return `(没取到 #${image} 的图片:${r.data?.reason || '本机没有这张图的副本'})`
|
|
1493
|
+
}
|
|
1494
|
+
// 拿到图才建数组:`pendingImages` 为 undefined 与"空数组"在下游是同一个意思,
|
|
1495
|
+
// 但只有前者能一眼看出"这一轮没图"。
|
|
1496
|
+
;(ctx.pendingImages ??= []).push({
|
|
1497
|
+
b64: r.data.b64, mime: r.data.mime || 'image/jpeg', localId: Number(image) })
|
|
1498
|
+
const size = r.data.width && r.data.height ? `${r.data.width}×${r.data.height}` : '尺寸未知'
|
|
1499
|
+
return `(已附上图片 #${image}(${size})。你能看到它了,直接据此回答。)`
|
|
1500
|
+
}
|
|
1501
|
+
case 'search_favorites': {
|
|
1502
|
+
await ensureDb()
|
|
1503
|
+
// 不给关键词就是"最近收藏了什么"——这一问在聊天里很自然,没理由逼调用方编一个词
|
|
1504
|
+
const keyword = String(args.keyword || '')
|
|
1505
|
+
const limit = boundedToolInteger(args.limit, keyword ? 8 : 15, keyword ? 15 : 30)
|
|
1506
|
+
const r = keyword ? await chatService.getFavorites({ keyword, limit })
|
|
1507
|
+
: await chatService.getFavorites({ limit })
|
|
1508
|
+
if (!r.success || !r.favorites?.length) {
|
|
1509
|
+
if (r.error) return `(查询失败: ${r.error})`
|
|
1510
|
+
return keyword ? `(收藏中未搜到「${keyword}」)` : '(收藏是空的,或收藏库还没连上)'
|
|
1511
|
+
}
|
|
1512
|
+
return `共${r.total}条, 前${r.favorites.length}条:\n` +
|
|
1513
|
+
r.favorites.map(f => {
|
|
1514
|
+
const desc = (f.desc || '').replace(/\s+/g, ' ').slice(0, 60)
|
|
1515
|
+
return `· ${f.title || '(无标题)'} (${f.source_name || '未知来源'})${desc ? ` — ${desc}` : ''}`
|
|
1516
|
+
}).join('\n')
|
|
1517
|
+
}
|
|
1518
|
+
case 'read_favorite': {
|
|
1519
|
+
await ensureDb()
|
|
1520
|
+
const keyword = String(args.keyword || '')
|
|
1521
|
+
if (!keyword) return '(缺少 keyword 参数)'
|
|
1522
|
+
const maxChars = boundedToolInteger(args.max_chars, 3000, 6000, 'max_chars')
|
|
1523
|
+
const r = await chatService.getFavorites({ keyword, limit: 5 })
|
|
1524
|
+
if (!r.success || !r.favorites?.length) {
|
|
1525
|
+
return `(收藏中未找到「${keyword}」)`
|
|
1526
|
+
}
|
|
1527
|
+
const art = r.favorites.find(f => f.link) || r.favorites[0]
|
|
1528
|
+
if (!art.link) {
|
|
1529
|
+
const txt = (art.desc || art.content || '').trim()
|
|
1530
|
+
return txt ? `「${art.title || '无标题'}」内容:\n${txt.slice(0, maxChars)}` : `(「${art.title}」没有可读的链接和内容)`
|
|
1531
|
+
}
|
|
1532
|
+
if (!isSafeUrl(art.link)) return `(链接不安全, 拒绝抓取: ${art.link.slice(0, 60)})`
|
|
1533
|
+
// 微信内置浏览器 UA + Referer 绕 WAF(与 biz_daily.py 同策略);
|
|
1534
|
+
// 这段与 fetch_article 是同一份实现——两处各写一份必然会漂
|
|
1535
|
+
const { status, html } = await fetchArticleHtml(art.link)
|
|
1536
|
+
if (!html) return `(抓取失败 HTTP ${status})`
|
|
1537
|
+
let text = articleTextFromHtml(html, maxChars)
|
|
1538
|
+
if (html.includes('已被发布者删除')) return `(「${art.title}」已被发布者删除)`
|
|
1539
|
+
if (!text.trim() || (text.match(/[\u4e00-\u9fff]/g) || []).length < 20) {
|
|
1540
|
+
return `(「${art.title}」正文提取失败, 可能是纯图片文章或已被删除)`
|
|
1541
|
+
}
|
|
1542
|
+
return `「${art.title || '无标题'}」(${art.source_name || '未知来源'}) 正文:\n${text}`
|
|
1543
|
+
}
|
|
1544
|
+
case 'get_daily_report': {
|
|
1545
|
+
const dates = existsSync(bizDailyDir())
|
|
1546
|
+
? readdirSync(bizDailyDir()).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse()
|
|
1547
|
+
: []
|
|
1548
|
+
if (!dates.length) return '(还没有日报数据, 先运行 biz_daily 生成)'
|
|
1549
|
+
const readArticles = (date: string): any[] | null => {
|
|
1550
|
+
const file = join(bizDailyDir(), date, '.articles.json')
|
|
1551
|
+
if (!existsSync(file)) return null
|
|
1552
|
+
try {
|
|
1553
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'))
|
|
1554
|
+
return Array.isArray(raw) ? raw : (raw.articles || Object.values(raw))
|
|
1555
|
+
} catch { return null }
|
|
1556
|
+
}
|
|
1557
|
+
const fmt = (a: any) => {
|
|
1558
|
+
const summary = (a.summary || '').replace(/\s+/g, ' ').slice(0, 80)
|
|
1559
|
+
const where = a.date ? `${a.date} ` : ''
|
|
1560
|
+
return `· ${where}[${a.topic}] ${a.title} (${a.source})${summary ? `\n ${summary}` : ''}`
|
|
1561
|
+
}
|
|
1562
|
+
const limit = boundedToolInteger(args.limit, 15, 30)
|
|
1563
|
+
|
|
1564
|
+
// 不给 date 但给了关键词 → 跨日期搜(MCP 那边原本叫 search_articles,两个工具搜的
|
|
1565
|
+
// 是同一批文章、只是范围不同,模型无从判断该用哪个,所以合并成一个)。
|
|
1566
|
+
const wantDate = args.date ? safeDate(args.date) : null
|
|
1567
|
+
if (args.date && !wantDate) return `(日期格式不对: ${args.date},要 YYYY-MM-DD)`
|
|
1568
|
+
if (!wantDate && (args.keyword || args.topic)) {
|
|
1569
|
+
const days = boundedToolInteger(args.days, 7, 60)
|
|
1570
|
+
const window = dates.slice(0, days)
|
|
1571
|
+
const hits: any[] = []
|
|
1572
|
+
let scanned = 0
|
|
1573
|
+
for (const d of window) {
|
|
1574
|
+
const arts = readArticles(d)
|
|
1575
|
+
if (!arts) continue
|
|
1576
|
+
scanned += arts.length
|
|
1577
|
+
for (const a of arts) {
|
|
1578
|
+
if (args.topic && a.topic !== args.topic) continue
|
|
1579
|
+
if (args.keyword) {
|
|
1580
|
+
const kw = String(args.keyword).toLowerCase()
|
|
1581
|
+
const hay = `${a.title || ''} ${a.summary || ''} ${a.source || ''}`.toLowerCase()
|
|
1582
|
+
if (!hay.includes(kw)) continue
|
|
1583
|
+
}
|
|
1584
|
+
hits.push({ ...a, date: d })
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
if (!hits.length) {
|
|
1588
|
+
return `(最近 ${window.length} 天的日报里(共 ${scanned} 篇)没有匹配`
|
|
1589
|
+
+ `${args.keyword ? `「${args.keyword}」` : `主题 ${args.topic}`} 的文章)`
|
|
1590
|
+
}
|
|
1591
|
+
return `最近 ${window.length} 天里匹配 ${hits.length} 篇(共扫 ${scanned} 篇):\n`
|
|
1592
|
+
+ hits.slice(0, limit).map(fmt).join('\n')
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
const date = wantDate || dates[0]
|
|
1596
|
+
const all = readArticles(date)
|
|
1597
|
+
if (!all) return `(${date} 没有日报。可用日期: ${dates.slice(0, 5).join(', ')})`
|
|
1598
|
+
// `full`:给的是当天那份人读的日报正文(README.md),不是条目清单。
|
|
1599
|
+
// 不加这个参数,模型想"把那天的日报念一段"就只能拿到标题列表。
|
|
1600
|
+
if (args.full) {
|
|
1601
|
+
const readme = join(bizDailyDir(), date, 'README.md')
|
|
1602
|
+
if (!existsSync(readme)) return `(${date} 没有日报正文文件,只有条目)`
|
|
1603
|
+
const maxChars = boundedToolInteger(args.max_chars, 3000, 6000)
|
|
1604
|
+
return `${date} 日报正文(${all.length} 篇):\n${readFileSync(readme, 'utf8').slice(0, maxChars)}`
|
|
1605
|
+
}
|
|
1606
|
+
let list = all
|
|
1607
|
+
if (args.topic) list = list.filter(a => a.topic === args.topic)
|
|
1608
|
+
if (args.keyword) {
|
|
1609
|
+
const kw = String(args.keyword).toLowerCase()
|
|
1610
|
+
list = list.filter(a =>
|
|
1611
|
+
(a.title || '').toLowerCase().includes(kw) || (a.summary || '').toLowerCase().includes(kw))
|
|
1612
|
+
}
|
|
1613
|
+
if (!list.length) return `(${date} 日报共 ${all.length} 篇, 过滤后无匹配)`
|
|
1614
|
+
const byTopic: Record<string, number> = {}
|
|
1615
|
+
for (const a of all) byTopic[a.topic] = (byTopic[a.topic] || 0) + 1
|
|
1616
|
+
const topicStat = Object.entries(byTopic).map(([t, n]) => `${t}${n}篇`).join(' ')
|
|
1617
|
+
return `${date} 日报共 ${all.length} 篇 (${topicStat}), 匹配 ${list.length} 篇:\n` +
|
|
1618
|
+
list.slice(0, limit).map(fmt).join('\n')
|
|
1619
|
+
}
|
|
1620
|
+
case 'get_sns': {
|
|
1621
|
+
await ensureDb()
|
|
1622
|
+
if (String(args.mode || 'timeline') === 'users') {
|
|
1623
|
+
// 谁常发朋友圈:本地把最近的时间线按发帖人聚合。**不新增出境**——数据本来就在这一条工具里。
|
|
1624
|
+
const r = await chatService.getSnsTimeline({ limit: 200 })
|
|
1625
|
+
if (!r.success || !r.timeline?.length) {
|
|
1626
|
+
return r.error ? `(朋友圈查询失败: ${r.error})` : '(朋友圈暂无缓存数据)'
|
|
1627
|
+
}
|
|
1628
|
+
const byAuthor = new Map<string, number>()
|
|
1629
|
+
for (const post of r.timeline) {
|
|
1630
|
+
const who = String(post.nickname || post.username || '未知')
|
|
1631
|
+
byAuthor.set(who, (byAuthor.get(who) ?? 0) + 1)
|
|
1632
|
+
}
|
|
1633
|
+
const ranked = [...byAuthor.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)
|
|
1634
|
+
const nl = String.fromCharCode(10)
|
|
1635
|
+
return `最近 ${r.timeline.length} 条朋友圈里,发得最多的:`
|
|
1636
|
+
+ ranked.map(([who, n]) => `${nl}· ${who}:${n} 条`).join('')
|
|
1637
|
+
}
|
|
1638
|
+
if (String(args.mode || 'timeline') === 'stats') {
|
|
1639
|
+
const r = await chatService.getSnsExportStats()
|
|
1640
|
+
if (!r.success) return `(朋友圈统计失败: ${r.error})`
|
|
1641
|
+
const d = r.data!
|
|
1642
|
+
return `朋友圈统计: 总动态 ${d.totalPosts} 条, 发布过动态的好友 ${d.totalFriends} 人${d.myPosts != null ? `, 用户自己发过 ${d.myPosts} 条` : ''}`
|
|
1643
|
+
}
|
|
1644
|
+
const limit = boundedToolInteger(args.limit, 10, 20)
|
|
1645
|
+
const r = await chatService.getSnsTimeline({ limit })
|
|
1646
|
+
if (!r.success || !r.timeline?.length) return r.error ? `(朋友圈查询失败: ${r.error})` : '(朋友圈暂无缓存数据)'
|
|
1647
|
+
return `最新 ${r.timeline.length} 条朋友圈:\n` + r.timeline.slice(0, limit).map((p: any) => {
|
|
1648
|
+
const content = (p.content || '').replace(/\s+/g, ' ').slice(0, 100)
|
|
1649
|
+
return `· [${fmtTime(p.create_time)}] ${p.nickname || p.username}: ${content}${p.media_count ? ` [${p.media_count}图]` : ''}`
|
|
1650
|
+
}).join('\n')
|
|
1651
|
+
}
|
|
1652
|
+
case 'get_weread': {
|
|
1653
|
+
const { wereadService } = await import('./wereadService.js')
|
|
1654
|
+
const mode = String(args.mode || 'shelf')
|
|
1655
|
+
const apiKey = (await import('./configService.js')).configService.get('wereadApiKey')
|
|
1656
|
+
if (!apiKey) return '(微信读书未配置: config set wereadApiKey <key>)'
|
|
1657
|
+
try {
|
|
1658
|
+
if (mode === 'notebooks') {
|
|
1659
|
+
const r = await wereadService.notebooks(20)
|
|
1660
|
+
if (!r.ok || !r.data?.books?.length) return `(暂无读书笔记: ${r.error || ''})`
|
|
1661
|
+
// `/user/notebooks` 把书名与作者**嵌在 `book` 里**,条目顶层没有这两个字段 ——
|
|
1662
|
+
// 实测(2026-09-28):直接读 `b.title` 会得到 `undefined`,而输出照样成立,
|
|
1663
|
+
// 只是每本书都没名字。顶层那两个是兜底,网关换版本时用得上。
|
|
1664
|
+
const titleOf = (b: any) => b?.book?.title || b?.title || '(未给书名)'
|
|
1665
|
+
const authorOf = (b: any) => b?.book?.author || b?.author || ''
|
|
1666
|
+
return `有笔记的书 ${r.data.books.length} 本:\n` +
|
|
1667
|
+
r.data.books.slice(0, 15).map((b: any) =>
|
|
1668
|
+
`· ${titleOf(b)} (${authorOf(b)}) — ${b.noteCount || 0} 条笔记`).join('\n')
|
|
1669
|
+
}
|
|
1670
|
+
if (mode === 'profile') {
|
|
1671
|
+
const r = await wereadService.profile()
|
|
1672
|
+
if (!r.ok || !r.data) return `(读不到阅读画像: ${r.error || ''})`
|
|
1673
|
+
const p = r.data
|
|
1674
|
+
return `阅读画像: ${p.name || '(未给昵称)'}\n`
|
|
1675
|
+
+ `累计阅读 ${Math.round((p.totalReadTime || 0) / 60)} 分钟、${p.totalReadDays || 0} 天\n`
|
|
1676
|
+
+ `书架 ${p.totalBooks || 0} 本,读完 ${p.totalFinished || 0} 本`
|
|
1677
|
+
}
|
|
1678
|
+
if (mode === 'stats') {
|
|
1679
|
+
const r = await wereadService.readData('monthly')
|
|
1680
|
+
if (!r.ok || !r.data) return `(读不到阅读统计: ${r.error || ''})`
|
|
1681
|
+
const d = r.data
|
|
1682
|
+
const top = (d.readLongest || []).slice(0, 5).map((x: any) =>
|
|
1683
|
+
`· ${x?.book?.title || x?.albumInfo?.title || '(未知)'} ${Math.round((x?.readTime || 0) / 60)} 分钟`)
|
|
1684
|
+
return `近期阅读 ${Math.round((d.totalReadTime || 0) / 60)} 分钟、读了 ${d.readDays || 0} 天`
|
|
1685
|
+
+ `,日均 ${Math.round((d.dayAverageReadTime || 0) / 60)} 分钟`
|
|
1686
|
+
+ (top.length ? `\n读得最久的:\n${top.join('\n')}` : '')
|
|
1687
|
+
}
|
|
1688
|
+
if (mode === 'book') {
|
|
1689
|
+
const bookId = String(args.book_id || '').trim()
|
|
1690
|
+
if (!bookId) return '(book 模式需要 book_id —— 先用 search 模式按书名找到它)'
|
|
1691
|
+
const info = await wereadService.bookInfo(bookId)
|
|
1692
|
+
if (!info.ok || !info.data) return `(读不到这本书: ${info.error || ''})`
|
|
1693
|
+
const b: any = info.data
|
|
1694
|
+
const prog = await wereadService.getProgress(bookId)
|
|
1695
|
+
const pct = prog.ok && prog.data
|
|
1696
|
+
? `\n我的进度: ${prog.data.progress}%${prog.data.chapterTitle ? `(${prog.data.chapterTitle})` : ''}`
|
|
1697
|
+
: ''
|
|
1698
|
+
return `《${b.title}》${b.author || ''}\n评分 ${b.rating ?? '未知'}(${b.ratingCount || 0} 人)`
|
|
1699
|
+
+ `| ${b.category || '未分类'} | ${b.wordCount || '未知'}字${pct}`
|
|
1700
|
+
}
|
|
1701
|
+
if (mode === 'review') {
|
|
1702
|
+
const bookId = String(args.book_id || '').trim()
|
|
1703
|
+
if (!bookId) return '(review 模式需要 book_id —— 先用 search 模式按书名找到它)'
|
|
1704
|
+
const r = await wereadService.reviews(bookId, 5)
|
|
1705
|
+
if (!r.ok || !r.data?.reviews?.length) return `(这本书还没有热门书评: ${r.error || ''})`
|
|
1706
|
+
return `热门书评 ${r.data.reviews.length} 条:\n`
|
|
1707
|
+
+ r.data.reviews.map((v: any) =>
|
|
1708
|
+
`· ${String(v.content || '').replace(/\s+/g, ' ').slice(0, 120)}`
|
|
1709
|
+
+ `(${v.user?.name || '匿名'},${v.rating ? `评 ${v.rating} 分` : '未评分'},${v.likeCount || 0} 赞)`).join('\n')
|
|
1710
|
+
}
|
|
1711
|
+
if (mode === 'discover') {
|
|
1712
|
+
const bookId = String(args.book_id || '').trim()
|
|
1713
|
+
const r = bookId ? await wereadService.similar(bookId, 6) : await wereadService.recommend(8)
|
|
1714
|
+
if (!r.ok || !r.data?.books?.length) return `(读不到推荐: ${r.error || ''})`
|
|
1715
|
+
return (bookId ? '与这本书相似的' : '为你推荐的') + `书 ${r.data.books.length} 本:\n`
|
|
1716
|
+
+ r.data.books.map((b: any) =>
|
|
1717
|
+
`· 《${b.title}》${b.author || ''}${b.rating ? ` 评分 ${b.rating}` : ''}`
|
|
1718
|
+
+ (b.intro ? `\n ${String(b.intro).replace(/\s+/g, ' ').slice(0, 80)}` : '')).join('\n')
|
|
1719
|
+
}
|
|
1720
|
+
if (mode === 'search') {
|
|
1721
|
+
const kw = String(args.keyword || '')
|
|
1722
|
+
if (!kw) return '(search 模式需要 keyword)'
|
|
1723
|
+
const r = await wereadService.search(kw, 8)
|
|
1724
|
+
if (!r.ok || !r.data?.books?.length) return `(没搜到「${kw}」: ${r.error || ''})`
|
|
1725
|
+
return `搜索「${kw}」结果:\n` +
|
|
1726
|
+
r.data.books.map((b: any) => `· ${b.title} (${b.author})`).join('\n')
|
|
1727
|
+
}
|
|
1728
|
+
const r = await wereadService.shelf()
|
|
1729
|
+
if (!r.ok || !r.data?.books?.length) return `(书架为空或读取失败: ${r.error || ''})`
|
|
1730
|
+
const books = r.data.books
|
|
1731
|
+
const reading = books.filter((b: any) => b.progress && b.progress > 0 && b.progress < 100)
|
|
1732
|
+
return `书架共 ${books.length} 本, 在读 ${reading.length} 本:\n` +
|
|
1733
|
+
(reading.length ? reading.slice(0, 10).map((b: any) =>
|
|
1734
|
+
`· ${b.title} (${b.author}) — 已读 ${b.progress || 0}%`).join('\n') : books.slice(0, 10).map((b: any) => `· ${b.title}`).join('\n'))
|
|
1735
|
+
} catch {
|
|
1736
|
+
return '(微信读书接口失败,请检查网络和本地配置)'
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
case 'get_todos': {
|
|
1740
|
+
const status = String(args.status || 'pending')
|
|
1741
|
+
// 走 pythonBridge(唯一的脚本调用入口):失败原因分三类报出来,不解析人类可读文本。
|
|
1742
|
+
// `--meta` 是为了分清"没有待办"与"从没提取过"——两者在 `--json` 下都是空数组,
|
|
1743
|
+
// 而把后者说成前者,用户会以为自己真的没事要做。MCP 侧一直分得开(见 mcp_bridge.py),
|
|
1744
|
+
// 这条工具此前没分。`--meta` 是新增开关,老形状(裸数组)原样保留给别的调用方。
|
|
1745
|
+
const result = await runPythonJson<any>('extract_todos.py', ['list', '--status', status, '--json', '--meta'])
|
|
1746
|
+
if (!result.ok) return fail('待办查询失败', result)
|
|
1747
|
+
// 兼容老形状:万一是没有 --meta 的实现,拿到的是数组,就退回原来的说法。
|
|
1748
|
+
const meta = result.data && !Array.isArray(result.data) ? result.data : null
|
|
1749
|
+
const todos: any[] = meta ? (Array.isArray(meta.items) ? meta.items : []) : (Array.isArray(result.data) ? result.data : [])
|
|
1750
|
+
if (!todos.length) {
|
|
1751
|
+
if (meta && meta.extracted === false) {
|
|
1752
|
+
return '(还没提取过待办——待办是从聊天记录里提取的,要用 `weflow-cli todos extract --days 7 --yes` 先跑一次;'
|
|
1753
|
+
+ '在那之前这份清单一直是空的,不代表你没有事要做)'
|
|
1754
|
+
}
|
|
1755
|
+
return `(没有${status === 'done' ? '已完成' : '待办'}任务)`
|
|
1756
|
+
}
|
|
1757
|
+
// 分组:`todos remind` 其实就是同一个文件按紧急度重排,所以不另开一个工具,
|
|
1758
|
+
// 加个参数就够(多一个近义工具只会让模型在两者之间瞎挑)
|
|
1759
|
+
if (String(args.group_by || '') === 'urgency') {
|
|
1760
|
+
const order: Record<string, number> = { 高: 0, 中: 1, 低: 2 }
|
|
1761
|
+
const sorted = [...todos].sort((a: any, b: any) =>
|
|
1762
|
+
(order[a.urgency] ?? 3) - (order[b.urgency] ?? 3))
|
|
1763
|
+
const groups: Record<string, any[]> = {}
|
|
1764
|
+
for (const t of sorted) (groups[t.urgency || '未标'] = groups[t.urgency || '未标'] || []).push(t)
|
|
1765
|
+
return `${status === 'done' ? '已完成' : '待办'} ${todos.length} 项(按紧急度分组):\n` +
|
|
1766
|
+
Object.entries(groups).map(([u, list]) =>
|
|
1767
|
+
`【${u}】${list.length} 项\n` + list.slice(0, 10).map((t: any) =>
|
|
1768
|
+
`· ${t.task || t.content || t.text || t.title}${t.deadline && t.deadline !== '未提及' ? ` (截止 ${t.deadline})` : ''}${idTag(t)}`).join('\n')
|
|
1769
|
+
).join('\n')
|
|
1770
|
+
}
|
|
1771
|
+
return `${status === 'done' ? '已完成' : '待办'} ${todos.length} 项:\n` +
|
|
1772
|
+
todos.slice(0, 15).map((t: any) =>
|
|
1773
|
+
`· [${t.urgency || '中'}] ${t.task || t.content || t.text || t.title}${t.deadline && t.deadline !== '未提及' ? ` (截止 ${t.deadline})` : ''}${idTag(t)}`).join('\n')
|
|
1774
|
+
}
|
|
1775
|
+
case 'search_chats': {
|
|
1776
|
+
// 跨会话检索:本机 message_fts 索引 + 判断模型挑查询词(**只发候选词与问题,不发聊天内容**)
|
|
1777
|
+
const question = String(args.question || '').trim()
|
|
1778
|
+
if (!question) return '(缺少 question 参数)'
|
|
1779
|
+
const perCard = boundedToolInteger(args.per_card, 3, 10, 'per_card')
|
|
1780
|
+
// 外部机器默认只给预览,**按入参说清**。这里要说清的恰好是最要紧的那件事:
|
|
1781
|
+
// 这条路上出去的是"你的问题 + 从聊天里提取的候选词",**不含消息正文**(排序在本地做)。
|
|
1782
|
+
if (ctx.requiresConfirm && args.confirm !== true) {
|
|
1783
|
+
return confirmPreview(`会把你的问题「${question.slice(0, 40)}」与从聊天里提取的候选词`
|
|
1784
|
+
+ `(最多 ${perCard} 个会话各若干词,**不含消息正文**)发给判断模型 Jev,`
|
|
1785
|
+
+ '让它挑出你真正在找的词;排序在本地做。')
|
|
1786
|
+
}
|
|
1787
|
+
const result = await runPythonJson<any>('route_cards.py',
|
|
1788
|
+
['ask', question, '--yes', '--json', '--per-card', String(perCard)], { timeoutMs: 90_000 })
|
|
1789
|
+
if (!result.ok) return fail('会话检索失败', result)
|
|
1790
|
+
|
|
1791
|
+
const ranked = Array.isArray(result.data?.ranked) ? result.data.ranked : []
|
|
1792
|
+
if (!ranked.length) {
|
|
1793
|
+
return `(没有会话字面命中「${question}」——检索只匹配字面词,换几个词再试)`
|
|
1794
|
+
}
|
|
1795
|
+
const lines = [`命中 ${ranked.length} 个会话(查询词:${(result.data.terms || []).join('、')}):`]
|
|
1796
|
+
for (const card of ranked.slice(0, 6)) {
|
|
1797
|
+
lines.push(`· #${card.id} [${card.kind}] ${card.label}(该会话 ${card.messages} 条消息,最后活动 ${card.lastDaysAgo} 天前)`)
|
|
1798
|
+
const rows = result.data?.messages?.[String(card.id)] || []
|
|
1799
|
+
for (const row of rows.slice(0, perCard)) {
|
|
1800
|
+
// 第三方聊天正文与 get_messages **同一条纪律**:出境前按隐私档位处理
|
|
1801
|
+
const body = privacyGate.maskMessageBody(String(row?.text || '').replace(/\s+/g, ' ').slice(0, 60))
|
|
1802
|
+
lines.push(` ${body}`)
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
return lines.join('\n')
|
|
1806
|
+
}
|
|
1807
|
+
case 'who_owes_reply': {
|
|
1808
|
+
// 谁在等我回话:逐会话问一次判断模型(较慢)。**不给正文**——真要看他写了什么,
|
|
1809
|
+
// 用 get_messages 单独查,那条路有完整的隐私处理。
|
|
1810
|
+
const days = boundedToolInteger(args.days, 14, 60, 'days')
|
|
1811
|
+
// 外部机器(MCP)默认只给预览:这个工具**每个会话一次判断调用**,每一次都带着那段聊天正文,
|
|
1812
|
+
// 是这条路上最贵也最"出境"的一个。预览走脚本自己的 --dry-run(只读本地、零出境)。
|
|
1813
|
+
if (ctx.requiresConfirm && args.confirm !== true) {
|
|
1814
|
+
const preview = await runPythonJson<any>('reply_debt.py',
|
|
1815
|
+
['--days', String(days), '--dry-run', '--json'], { timeoutMs: 60_000 })
|
|
1816
|
+
if (!preview.ok || !preview.data?.success) {
|
|
1817
|
+
return `(预览失败:${String(preview.data?.error || preview.error || '未知').slice(0, 120)})`
|
|
1818
|
+
}
|
|
1819
|
+
const p = preview.data
|
|
1820
|
+
return confirmPreview(`会把最近 ${days} 天里 ${p.conversations} 个有动静的会话、`
|
|
1821
|
+
+ `约 ${p.stateChars} 字符聊天正文,**逐个**发给判断模型(api.typesafe.ai),每会话一次请求。`)
|
|
1822
|
+
}
|
|
1823
|
+
const result = await runPythonJson<any>('reply_debt.py', ['--days', String(days), '--json'],
|
|
1824
|
+
{ timeoutMs: 180_000 })
|
|
1825
|
+
if (!result.ok) return fail('欠账查询失败', result)
|
|
1826
|
+
|
|
1827
|
+
const debts = Array.isArray(result.data?.debts) ? result.data.debts : []
|
|
1828
|
+
if (!debts.length) return `(最近 ${days} 天没有明显在等你回话的会话)`
|
|
1829
|
+
const lines = [`在等你回话的 ${debts.length} 个会话(最近 ${days} 天):`]
|
|
1830
|
+
for (const row of debts.slice(0, 8)) {
|
|
1831
|
+
const prob = Number(row.waiting ?? 0)
|
|
1832
|
+
const urgency = row.urgencyScore === null || row.urgencyScore === undefined ? '' : ` · 紧急度 ${row.urgencyScore}`
|
|
1833
|
+
lines.push(`· ${row.name}(等了 ${row.days} 天 · 概率 ${prob.toFixed(2)}${urgency}${row.kind ? ' · ' + row.kind : ''})`)
|
|
1834
|
+
}
|
|
1835
|
+
lines.push('(想看某人具体说了什么,用 get_messages 单独查;这里只报谁在等。)')
|
|
1836
|
+
return lines.join('\n')
|
|
1837
|
+
}
|
|
1838
|
+
case 'draft_reply': {
|
|
1839
|
+
// 起草:判断(Jev)→ 起草(DeepSeek)→ 排序(Jev)。**只产出文本,绝不发送**——
|
|
1840
|
+
// 仓库里"发送"本来就不可达(TOOL_DEFS 没有任何 send 类工具),这里也不新增。
|
|
1841
|
+
const contact = String(args.contact || '')
|
|
1842
|
+
if (!contact) return '(缺少 contact 参数)'
|
|
1843
|
+
const count = boundedToolInteger(args.count, 3, 5, 'count')
|
|
1844
|
+
// strict 下**直接拒绝**,而不是把遮成"内容 N 字"的正文拿去起草——那会得到一段
|
|
1845
|
+
// 基于空白的、看起来正常的回复,比拒绝更糟(同 `look_at_image` 的处置)。
|
|
1846
|
+
//
|
|
1847
|
+
// 这里**不看 `isLocalInference()`**,与 `look_at_image` 刻意不同:那张图确实送到
|
|
1848
|
+
// 本机模型,所以"本机推理"在那里是成立的旁路;而起草出境的是**脚本自己的**两条远程
|
|
1849
|
+
// 调用(`jev_client` 走 HTTP、`_utils.call_deepseek` 写死 DeepSeek),跟 `aiEngine`
|
|
1850
|
+
// 配成什么毫无关系。照抄那个旁路=用"我配了本地模型"当理由,把正文发到远端去。
|
|
1851
|
+
// 这条是实测出来的:aiEngine=ollama + strict 下,原实现真的把对话交给了脚本。
|
|
1852
|
+
if (privacyGate.mode() === 'strict') {
|
|
1853
|
+
return '(当前隐私模式是 strict:聊天正文不出境,而起草必须把正文发给远端模型'
|
|
1854
|
+
+ '(判断一步、生成一步),这一步用本机推理也绕不开。'
|
|
1855
|
+
+ '在电脑上 `weflow-cli config set assistantPrivacy balanced`,或用本机命令 '
|
|
1856
|
+
+ '`weflow-cli draft <会话>`。)'
|
|
1857
|
+
}
|
|
1858
|
+
const talker = await resolveTalker(contact)
|
|
1859
|
+
const msgs = await chatService.getMessages(talker, TRANSCRIPT_MESSAGES)
|
|
1860
|
+
if (!msgs.length) return `(没找到「${contact}」的消息)`
|
|
1861
|
+
|
|
1862
|
+
// **外部机器(MCP)默认只给预览,不做出境。** 与 CLI 的 `--dry-run` / `--yes` 同一套纪律:
|
|
1863
|
+
// 预览只读本地库、报清要发多少字符给哪两个模型,零出境。
|
|
1864
|
+
// 面板/微信那条路(`requiresConfirm` 不置位)不受影响——它那边的边界是白名单 + 用户
|
|
1865
|
+
// 在一个只有自己看得见的会话里显式开口问。
|
|
1866
|
+
if (ctx.requiresConfirm && args.confirm !== true) {
|
|
1867
|
+
const preview = await runPythonJson<any>('draft_reply.py',
|
|
1868
|
+
['--talker', talker, '--dry-run', '--json', '--count', String(count)],
|
|
1869
|
+
{ timeoutMs: 60_000 })
|
|
1870
|
+
if (!preview.ok || !preview.data?.success) {
|
|
1871
|
+
return `(起草预览失败:${String(preview.data?.error || preview.error || '未知').slice(0, 120)})`
|
|
1872
|
+
}
|
|
1873
|
+
const d = preview.data
|
|
1874
|
+
return confirmPreview('会把「' + d.name + '」最近的 ' + d.messages + ' 条消息、共 '
|
|
1875
|
+
+ d.stateChars + ' 字符,发给 ' + (d.models?.judge || '判断模型') + ' 与 '
|
|
1876
|
+
+ (d.models?.draft || '生成模型') + '(' + (d.calls || '3 次调用') + '),产出 '
|
|
1877
|
+
+ d.count + ' 条候选。'
|
|
1878
|
+
// 前提说明也在出境的那一段里(它会进两段提示词),所以预览要把它算进去
|
|
1879
|
+
+ (d.lastFromMe ? '另外会附一句「最后一条是我自己发的」的前提说明。' : ''))
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// 逐条遮罩**再**交给脚本:Python 侧没有任何脱敏实现(全仓只有两处无关的位掩码),
|
|
1883
|
+
// 而脱敏这一层在 TS。**不在 Python 里再写一份**——同一件事两处实现早晚有一处会漏。
|
|
1884
|
+
//
|
|
1885
|
+
// 说明白:上面的门**现在**已经把 strict 全挡了,所以这里的 `maskMessageBody`
|
|
1886
|
+
// 在当下打不到(balanced/open 不改正文)。留着它是因为它守的是另一条不变式:
|
|
1887
|
+
// "交给脚本的正文一定先过一道遮罩",谁将来松开了上面那道门,这道还在。
|
|
1888
|
+
const transcript = msgs.slice().reverse().map(m => transcriptLine(m))
|
|
1889
|
+
const result = await runPythonJson<any>('draft_reply.py',
|
|
1890
|
+
['--stdin', '--yes', '--json', '--count', String(count)],
|
|
1891
|
+
// `lastFromMe`:`transcript` 是 `msgs` **反过来**渲染的,所以最后一条 = `msgs[0]`。
|
|
1892
|
+
// 用结构化字段 `isSend`,不去解析那行文本(群聊里别人的标签是人名,不是「对方」)。
|
|
1893
|
+
// 这段是说给脚本听的**前提**:最后一条是我发的时,"回答对方最后一句"这件事根本不存在。
|
|
1894
|
+
{ stdin: JSON.stringify({ name: contact, lines: transcript, lastFromMe: !!msgs[0]?.isSend }),
|
|
1895
|
+
timeoutMs: 240_000 })
|
|
1896
|
+
if (!result.ok) return fail('起草失败', result)
|
|
1897
|
+
|
|
1898
|
+
const data = result.data ?? {}
|
|
1899
|
+
if (!data.success) return `(起草没跑通: ${String(data.error || '未知').slice(0, 120)})`
|
|
1900
|
+
const picked = Array.isArray(data.drafts) ? data.drafts : []
|
|
1901
|
+
// 脚本给的前提说明(仅当最后一条是我发的)。**用户也要看见**:否则候选看起来像在回
|
|
1902
|
+
// 一句根本不存在的话。三条出口(降级/被闸门拦下/正常)都要带上它。
|
|
1903
|
+
const premise = typeof data.premise === 'string' ? data.premise.trim() : ''
|
|
1904
|
+
// **降级那次(Jev 没调通)必须说清两件事**:这次没有判断,以及那道闸门没生效。
|
|
1905
|
+
// 闸门(涉钱/高风险不给草稿)正是靠判断结果才存在的——不标出来,降级看起来就跟
|
|
1906
|
+
// "判断过了"一模一样。**不以 `(` 开头**:那个前缀在本仓库表示"工具没产出内容"。
|
|
1907
|
+
if (data.judged === false) {
|
|
1908
|
+
if (!picked.length) return `(起草没给出候选:${String(data.judgeNote || '判断与生成都没跑通').slice(0, 120)})`
|
|
1909
|
+
const lines = [...(premise ? [premise, ''] : []),
|
|
1910
|
+
`这次**没有判断**——${String(data.judgeNote || '判断那一步没调通')}。`,
|
|
1911
|
+
`所以"涉钱/风险高就不给草稿"那道闸门**没生效**,下面 ${picked.length} 条是生成模型`
|
|
1912
|
+
+ `按上下文给的,你自己看一眼:`, '']
|
|
1913
|
+
picked.forEach((draft: any, index: number) => { lines.push(`${index + 1}. ${draft.text}`) })
|
|
1914
|
+
lines.push('')
|
|
1915
|
+
lines.push('(这些只是文本,没有发送任何东西——回不回、怎么回由你决定。)')
|
|
1916
|
+
return lines.join('\n')
|
|
1917
|
+
}
|
|
1918
|
+
const judgment = data.judgment ?? {}
|
|
1919
|
+
const why = `对方${zhLabel(INTENT_LABELS, judgment.intent)}`
|
|
1920
|
+
+ `;建议${zhLabel(ACTION_LABELS, judgment.action)}`
|
|
1921
|
+
+ `;风险 ${Number(judgment.risk ?? 0).toFixed(1)}/9 ${riskLabel(judgment.risk)}`
|
|
1922
|
+
// 被闸门拦下:**不是失败**,是这条最该给的回答。所以不以 `(` 起头
|
|
1923
|
+
// (那个前缀在本仓库里表示"工具没产出内容",见 `producedContent`)。
|
|
1924
|
+
if (data.gate === 'refused') {
|
|
1925
|
+
return [...(premise ? [premise, ''] : []),
|
|
1926
|
+
'不起草。', String(data.reason || ''), '',
|
|
1927
|
+
'建议先:', ...(data.advice ?? []).map((line: string) => `· ${line}`),
|
|
1928
|
+
'', `(判断:${why}。这些只是文本,没有发送任何东西。)`].join('\n')
|
|
1929
|
+
}
|
|
1930
|
+
const drafts: any[] = Array.isArray(data.drafts) ? data.drafts : []
|
|
1931
|
+
if (!drafts.length) return '(起草没给出候选)'
|
|
1932
|
+
const lines = [...(premise ? [premise, ''] : []),
|
|
1933
|
+
`建议这样回(${drafts.length} 条,第一条是判断最合适的):`, '']
|
|
1934
|
+
drafts.forEach((draft, index) => { lines.push(`${index + 1}. ${draft.text}`) })
|
|
1935
|
+
lines.push('')
|
|
1936
|
+
lines.push(`(判断:${why}。这些只是文本,没有发送任何东西——回不回、怎么回由你决定。)`)
|
|
1937
|
+
return lines.join('\n')
|
|
1938
|
+
}
|
|
1939
|
+
case 'search_semantic': {
|
|
1940
|
+
// 语义/同义检索(向量相似度 + 决策模型重排)。字面词搜不到时用这条。
|
|
1941
|
+
// **出境说明**:查询词要发阿里云百炼做嵌入,候选片段要发判断模型重排——
|
|
1942
|
+
// 两者都在仓库既有的云端路径上(不是新类别),但这里如实写出来。
|
|
1943
|
+
const query = String(args.query || '').trim()
|
|
1944
|
+
if (!query) return '(缺少 query 参数)'
|
|
1945
|
+
const line = toolLine(args.line)
|
|
1946
|
+
if (!line) return `(line 只能是 wiki / chat / all,收到「${String(args.line)}」)`
|
|
1947
|
+
const topK = boundedToolInteger(args.top_k, 8, 20, 'top_k')
|
|
1948
|
+
// 外部机器默认只给预览(`semantic_search.py` 没有 --dry-run,按入参说清)
|
|
1949
|
+
if (ctx.requiresConfirm && args.confirm !== true) {
|
|
1950
|
+
return confirmPreview(`会把查询词「${query.slice(0, 40)}」发给阿里云百炼做嵌入,`
|
|
1951
|
+
+ `再把前 ${topK} 条命中的本地片段(可能含聊天或文章原文)发给判断模型 Jev 重排。`)
|
|
1952
|
+
}
|
|
1953
|
+
// **查询词走环境变量、不进 argv**:仓库写进测试的隐私纪律(进程列表里看不到正文)
|
|
1954
|
+
const result = await runPythonJson<any>('semantic_search.py',
|
|
1955
|
+
['search', '--top-k', String(topK), '--line', line],
|
|
1956
|
+
{ env: { WEFLOW_SEARCH_QUERY: query }, timeoutMs: 90_000 })
|
|
1957
|
+
if (!result.ok) return fail('语义检索失败(索引可能还没建:weflow-cli search-index)', result)
|
|
1958
|
+
if (result.data && typeof result.data.error === 'string') {
|
|
1959
|
+
return `(语义检索没跑成:${String(result.data.error).slice(0, 120)})`
|
|
1960
|
+
}
|
|
1961
|
+
if (!result.data || !Array.isArray(result.data.results)) {
|
|
1962
|
+
// **形状不对要响,不能降级成"没有结果"**:脚本那边 `search` 的输出是一个带
|
|
1963
|
+
// `line` / `results` 的对象,而"读不到"和"搜不到"在界面上长得一模一样
|
|
1964
|
+
// (`(语义检索没有结果)`)—— 那正是这个仓库一直在防的那种静默。
|
|
1965
|
+
return `(语义检索返回了看不懂的形状:${JSON.stringify(result.data).slice(0, 120)})`
|
|
1966
|
+
}
|
|
1967
|
+
const rows = result.data.results
|
|
1968
|
+
const note = String(result.data.note || '')
|
|
1969
|
+
const scope = String(result.data.lineLabel || lineLabelOf(line))
|
|
1970
|
+
if (!rows.length) {
|
|
1971
|
+
return `(语义检索在${scope}里没有结果「${query}」${note ? ':' + note : ''}`
|
|
1972
|
+
+ `${line === 'all' ? ')' : ';`line: \'all\'` 两条线一起搜)'}`
|
|
1973
|
+
}
|
|
1974
|
+
return `语义检索「${query}」(范围:${scope})前 ${rows.length} 条:` + String.fromCharCode(10) + rows.map((row: any) => {
|
|
1975
|
+
const title = String(row.title || row.source || '(无标题)').slice(0, 40)
|
|
1976
|
+
const score = typeof row.score === 'number' ? `(${row.score.toFixed(2)})` : ''
|
|
1977
|
+
const text = privacyGate.maskMessageBody(
|
|
1978
|
+
String(row.text || row.content || '').replace(/\s+/g, ' ').slice(0, 80))
|
|
1979
|
+
const nl = String.fromCharCode(10)
|
|
1980
|
+
return `· ${title}${score}${text ? nl + ' ' + text : ''}`
|
|
1981
|
+
}).join(String.fromCharCode(10))
|
|
1982
|
+
}
|
|
1983
|
+
case 'export_chat': {
|
|
1984
|
+
// **写操作**,边界写死:只往 output/exports/ 下**新建**目录(名字带时间戳),绝不覆盖;
|
|
1985
|
+
// 路径由这里拼,模型给不了任意路径。用户是在对话里明确要求的,这就是那次确认。
|
|
1986
|
+
const contact = String(args.contact || '').trim()
|
|
1987
|
+
if (!contact) return '(缺少 contact 参数)'
|
|
1988
|
+
const limit = boundedToolInteger(args.limit, 500, 5000, 'limit')
|
|
1989
|
+
const format = String(args.format || 'html').toLowerCase()
|
|
1990
|
+
if (!['html', 'txt', 'json', 'excel'].includes(format)) {
|
|
1991
|
+
return `(参数错误: format 只能是 html/txt/json/excel,收到 ${format.slice(0, 12)})`
|
|
1992
|
+
}
|
|
1993
|
+
const talker = await resolveTalker(contact)
|
|
1994
|
+
|
|
1995
|
+
const safeName = contact.replace(/[\/:*?"<>|]/g, '_').slice(0, 20) || 'chat'
|
|
1996
|
+
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 12)
|
|
1997
|
+
// 目录名到秒,同一秒里导两次会撞进同一个目录——那就不是"绝不覆盖"了。撞了就往后加序号。
|
|
1998
|
+
// 导出根目录可用环境变量改(测试用临时目录;也方便自定义)。默认在仓库的 output/exports 下。
|
|
1999
|
+
const exportRoot = process.env.WEFLOW_ASSISTANT_EXPORT_ROOT || join(PKG_ROOT, 'output', 'exports')
|
|
2000
|
+
let outDir = join(exportRoot, `${safeName}-${stamp}`)
|
|
2001
|
+
for (let n = 2; existsSync(outDir) && n < 100; n++) {
|
|
2002
|
+
outDir = join(exportRoot, `${safeName}-${stamp}-${n}`)
|
|
2003
|
+
}
|
|
2004
|
+
const result = format === 'txt' ? await exportService.exportTxt(talker, outDir, limit)
|
|
2005
|
+
: format === 'json' ? await exportService.exportJson(talker, outDir, limit)
|
|
2006
|
+
: format === 'excel' ? await exportService.exportExcel(talker, outDir, limit)
|
|
2007
|
+
: await exportService.exportHtml(talker, outDir, limit, '', undefined, undefined, true)
|
|
2008
|
+
if (!result.success) return `(导出失败: ${String(result.error || '未知').slice(0, 100)})`
|
|
2009
|
+
const suffix = format === 'html' ? ',含图片' : ''
|
|
2010
|
+
// 报**真的写到了哪**:`outDir` 才是(撞名之后会带 `-2`),而事前拼的 `safeName-stamp`
|
|
2011
|
+
// 两次导出会报成同一个名字——实测第二次明明是 `…-2`,消息里却是基础名(2026-09-23 验收)。
|
|
2012
|
+
// 根目录是默认值时按仓库相对路径报(用户照着找得到);自定义根时只报目录名——
|
|
2013
|
+
// **不把绝对路径发进聊天**,那是本机路径,没必要出境。
|
|
2014
|
+
const shown = exportRoot === join(PKG_ROOT, 'output', 'exports')
|
|
2015
|
+
? `output/exports/${basename(outDir)}`
|
|
2016
|
+
: basename(outDir)
|
|
2017
|
+
return `已导出 ${result.count ?? 0} 条消息到 ${shown}/(${format}${suffix})`
|
|
2018
|
+
}
|
|
2019
|
+
case 'search_knowledge': {
|
|
2020
|
+
const kw = String(args.keyword || '')
|
|
2021
|
+
if (!kw) return '(缺少 keyword 参数)'
|
|
2022
|
+
const line = toolLine(args.line)
|
|
2023
|
+
if (!line) return `(line 只能是 wiki / chat / all,收到「${String(args.line)}」)`
|
|
2024
|
+
const all = vaultDirs().filter(dir => existsSync(dir))
|
|
2025
|
+
if (!all.length) return '(知识库尚未生成, 先运行 weflow-cli wiki compile)'
|
|
2026
|
+
// **默认只搜文章线**(两条线分开是用户要求的);`line: 'all'` 才两条一起搜。
|
|
2027
|
+
// 范围一定要写在输出里:不说的话,"这一条线里没有"会被读成"整个知识库里没有"。
|
|
2028
|
+
const liveDirs = line === 'all' ? all : all.filter(dir => vaultLineId(dir) === line)
|
|
2029
|
+
if (!liveDirs.length) {
|
|
2030
|
+
return `(${lineLabelOf(line)}还没有概念页——知识库里现在只有${all.map(vaultLineLabel).join('、')};`
|
|
2031
|
+
+ `要看那些用 line: 'all')`
|
|
2032
|
+
}
|
|
2033
|
+
const entries: Array<{ dir: string; file: string; where: string }> = []
|
|
2034
|
+
let total = 0
|
|
2035
|
+
for (const dir of liveDirs) {
|
|
2036
|
+
// 标注来源,好让模型(和读日志的人)知道这条来自哪个知识库
|
|
2037
|
+
const where = vaultLineId(dir) === 'chat' ? '聊天' : '文章'
|
|
2038
|
+
for (const file of readdirSync(dir).filter(f => f.endsWith('.md'))) {
|
|
2039
|
+
entries.push({ dir, file, where })
|
|
2040
|
+
total += 1
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
const hits = entries.filter(e => e.file.replace('.md', '').toLowerCase().includes(kw.toLowerCase()))
|
|
2044
|
+
if (!hits.length) {
|
|
2045
|
+
// **只搜正文,不搜「来源」段。** 页面的来源段可能列 300+ 条文章标题(2026-09-27 那批
|
|
2046
|
+
// 改动之后就是这样),拿整个文件做包含判断,会把「某篇来源的**标题**里含这个词」报成
|
|
2047
|
+
// 「**正文**提及」—— 实测 `Grok-4` 命中 4 张页、其中 3 张的正文里根本没有它。
|
|
2048
|
+
// `assistant-eval` 的 `knowledge-fallback-not-body` 钉住这件事。
|
|
2049
|
+
//
|
|
2050
|
+
// 顺带把**那一句**给出去:只给页名的话模型会自己补内容(实测它补出过「Grok-4 是作为
|
|
2051
|
+
// 被测试的模型之一出现的」,那是它从别处知道的,不是这一页里写的)。
|
|
2052
|
+
const contentHits = entries.map(e => {
|
|
2053
|
+
const text = pageBody(readFileSync(join(e.dir, e.file), 'utf8'))
|
|
2054
|
+
const at = text.toLowerCase().indexOf(kw.toLowerCase())
|
|
2055
|
+
if (at === -1) return null
|
|
2056
|
+
// (局部变量别叫 `line`:外层那个 `line` 是"搜哪条知识库",同名会把它遮住)
|
|
2057
|
+
const snippetLine = text.slice(Math.max(0, at - 80), at + 160)
|
|
2058
|
+
.split('\n').find(l => l.includes(kw)) || ''
|
|
2059
|
+
return { ...e, snippet: snippetLine.trim().slice(0, 180) }
|
|
2060
|
+
}).filter((e): e is { dir: string; file: string; where: string; snippet: string } => e !== null)
|
|
2061
|
+
.slice(0, 5)
|
|
2062
|
+
if (!contentHits.length) {
|
|
2063
|
+
// **说清是哪个范围里没收录**:只写"知识库未收录"会被读成整个知识库都没有
|
|
2064
|
+
return `(${lineLabelOf(line)}没收录「${kw}」, 这一趟看了 ${total} 个概念页`
|
|
2065
|
+
+ (line === 'all' ? ')' : ';`line: \'all\'` 可以连另一条线一起搜)')
|
|
2066
|
+
}
|
|
2067
|
+
return `正文提及「${kw}」的概念页(${lineLabelOf(line)}):` + '\n'
|
|
2068
|
+
+ contentHits.map(e => `· ${e.file.replace('.md', '')}(${e.where}): ${e.snippet}`).join('\n')
|
|
2069
|
+
}
|
|
2070
|
+
const hit = hits[0]
|
|
2071
|
+
const name = hit.file.replace('.md', '')
|
|
2072
|
+
const page = readFileSync(join(hit.dir, hit.file), 'utf8')
|
|
2073
|
+
// 两条线各有一张同名页是**设计**(两个命名空间分开)。只给第一条会让模型以为另一条
|
|
2074
|
+
// 没有这个概念 —— 而它可能正是用户问的那一条("我们聊过的"vs"公众号里读到的")。
|
|
2075
|
+
const alsoIn = hits.filter(h => h.where !== hit.where).map(h => h.where)
|
|
2076
|
+
const alsoNote = alsoIn.length ? `(另一个同名页在${alsoIn.join('、')}知识库:line: 'all' 两条都看)` : ''
|
|
2077
|
+
// **顺带把邻居给出去**(见 `conceptNeighbors`):一次调用拿到"这个概念 + 连着它的一小块子图"。
|
|
2078
|
+
// 不点明"可以再用本工具查其中任意一个"的话,模型会把它当装饰性文字读过去。
|
|
2079
|
+
const depth = boundedToolInteger(args.depth, 1, 3, 'depth')
|
|
2080
|
+
// **从命中的那一条线开始**(`hit.dir`):同名概念两条线各一张,不指定起点的话
|
|
2081
|
+
// 永远走文章线那张 —— 而上面刚刚才告诉模型"另一条线也有这个概念"
|
|
2082
|
+
const { levels, truncated, missing } = conceptSubgraph(name, depth, 30, vaultDirs(), hit.dir)
|
|
2083
|
+
const out = [`「${name}」概念页(${hit.where}知识库)${alsoNote}:`, page.slice(0, 2000)]
|
|
2084
|
+
if (levels.length) {
|
|
2085
|
+
out.push('', depth > 1
|
|
2086
|
+
? `—— 它周围的概念(${depth} 跳;「←」标出是从哪个概念连过来的)——`
|
|
2087
|
+
: '—— 它连到的概念(可以再用本工具查其中任意一个,或者加 depth 参数一次多看几跳)——')
|
|
2088
|
+
levels.forEach((level, index) => {
|
|
2089
|
+
if (levels.length > 1) out.push(`第 ${index + 1} 跳:`)
|
|
2090
|
+
for (const nb of level) {
|
|
2091
|
+
out.push(depth > 1 ? `· ${nb.name} ← ${nb.via} — ${nb.brief}` : `· ${nb.name} — ${nb.brief}`)
|
|
2092
|
+
}
|
|
2093
|
+
})
|
|
2094
|
+
// 封顶了要**说出来**:默默少给的话,模型会以为这就是全部
|
|
2095
|
+
if (truncated) out.push(`(图太大,只给了前 ${levels.flat().length} 个概念;
|
|
2096
|
+
想看某个分支可以单独查它)`)
|
|
2097
|
+
}
|
|
2098
|
+
if (missing) out.push(`(另有 ${missing} 个相关概念尚未建页,查不到)`)
|
|
2099
|
+
return out.join('\n')
|
|
2100
|
+
}
|
|
2101
|
+
case 'get_stats': {
|
|
2102
|
+
await ensureDb()
|
|
2103
|
+
const sessions = await chatService.listSessions(undefined, 1000)
|
|
2104
|
+
const fav = await chatService.getFavorites({ limit: 1 })
|
|
2105
|
+
const lines = [`会话数: ${sessions.length}`, `收藏总数: ${fav.success ? fav.total : '未知'}`]
|
|
2106
|
+
// 知识库那一半:MCP 的手写 get_stats 一直有、助手这边缺。同一个工具名在两个界面
|
|
2107
|
+
// 报的东西不一样,是最容易让人误判成数据丢了的那种不一致。
|
|
2108
|
+
const dates = existsSync(bizDailyDir())
|
|
2109
|
+
? readdirSync(bizDailyDir()).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort()
|
|
2110
|
+
: []
|
|
2111
|
+
let articleCount = 0
|
|
2112
|
+
const topics: Record<string, number> = {}
|
|
2113
|
+
for (const d of dates) {
|
|
2114
|
+
const file = join(bizDailyDir(), d, '.articles.json')
|
|
2115
|
+
if (!existsSync(file)) continue
|
|
2116
|
+
try {
|
|
2117
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'))
|
|
2118
|
+
const arts: any[] = Array.isArray(raw) ? raw : (raw.articles || [])
|
|
2119
|
+
articleCount += arts.length
|
|
2120
|
+
for (const a of arts) topics[a.topic || '未知'] = (topics[a.topic || '未知'] || 0) + 1
|
|
2121
|
+
} catch { /* 某一天的文件坏了不该让整个统计失败 */ }
|
|
2122
|
+
}
|
|
2123
|
+
if (dates.length) {
|
|
2124
|
+
lines.push(`日报文章: ${articleCount} 篇,覆盖 ${dates.length} 天(${dates[0]} ~ ${dates[dates.length - 1]})`)
|
|
2125
|
+
const top = Object.entries(topics).sort((a, b) => b[1] - a[1]).slice(0, 6)
|
|
2126
|
+
if (top.length) lines.push(`主题分布: ${top.map(([t, n]) => `${t}${n}`).join('、')}`)
|
|
2127
|
+
} else {
|
|
2128
|
+
lines.push('日报文章: 还没有日报数据')
|
|
2129
|
+
}
|
|
2130
|
+
const conceptCounts: string[] = []
|
|
2131
|
+
for (const dir of vaultDirs()) {
|
|
2132
|
+
const idx = wikiIndexPath(dir)
|
|
2133
|
+
if (!existsSync(idx)) continue
|
|
2134
|
+
const m = readFileSync(idx, 'utf8').match(/共\s*(\d+)\s*个概念/)
|
|
2135
|
+
if (m) conceptCounts.push(`${vaultLineLabel(dir)} ${m[1]}`)
|
|
2136
|
+
}
|
|
2137
|
+
if (conceptCounts.length) lines.push(`概念页: ${conceptCounts.join('、')}`)
|
|
2138
|
+
return lines.join('\n')
|
|
2139
|
+
}
|
|
2140
|
+
case 'get_reading_stats': {
|
|
2141
|
+
const days = boundedToolInteger(args.days, 7, 90, 'days')
|
|
2142
|
+
const r = await runPythonJson<any>('daily_stats.py',
|
|
2143
|
+
['--days', String(days), '--json'], { timeoutMs: 60_000 })
|
|
2144
|
+
if (!r.ok) return fail('读取公众号统计失败', r)
|
|
2145
|
+
const rows: any[] = r.data?.sources ?? []
|
|
2146
|
+
if (!rows.length) return `(最近 ${days} 天没有公众号推送记录)`
|
|
2147
|
+
const period = r.data?.period ?? {}
|
|
2148
|
+
const byPushed = [...rows].sort((a, b) => (b.pushed ?? 0) - (a.pushed ?? 0)).slice(0, 5)
|
|
2149
|
+
const lines = [`最近 ${days} 天(${period.start} 至 ${period.end}):${rows.length} 个公众号有推送`]
|
|
2150
|
+
lines.push('发得最多:' + byPushed.map(x => `${x.name}(${x.pushed})`).join('、'))
|
|
2151
|
+
const processed = rows.filter(x => (x.processed ?? 0) > 0)
|
|
2152
|
+
.sort((a, b) => (b.processed ?? 0) - (a.processed ?? 0)).slice(0, 5)
|
|
2153
|
+
if (processed.length) {
|
|
2154
|
+
lines.push('日报处理得最多:' + processed.map(x => `${x.name}(${x.processed})`).join('、'))
|
|
2155
|
+
} else {
|
|
2156
|
+
// **直说日报没在跑**:这时"处理数 0"不是"这些号没内容",而是日报压根没运行。
|
|
2157
|
+
// 把它混成"没读到东西",用户会去查号,而该查的是日报任务。
|
|
2158
|
+
lines.push(`这 ${days} 天日报没有任何处理记录` + lastDailyNote())
|
|
2159
|
+
}
|
|
2160
|
+
return lines.join('\n')
|
|
2161
|
+
}
|
|
2162
|
+
case 'search_memory': {
|
|
2163
|
+
const hits = ctx.memory.searchFacts(ctx.userId, String(args.keyword || ''))
|
|
2164
|
+
if (!hits.length) return '(长期记忆中无相关内容)'
|
|
2165
|
+
return hits.map(f => `· ${f.content}`).join('\n')
|
|
2166
|
+
}
|
|
2167
|
+
case 'save_memory': {
|
|
2168
|
+
const content = String(args.content || '').trim().slice(0, 60)
|
|
2169
|
+
if (!content) return '(内容为空, 未保存)'
|
|
2170
|
+
const added = ctx.memory.addFact(ctx.userId, content)
|
|
2171
|
+
return added ? '(已存入长期记忆)' : '(与已有记忆重复, 未保存)'
|
|
2172
|
+
}
|
|
2173
|
+
case 'list_skills': {
|
|
2174
|
+
const scan = scanSkills()
|
|
2175
|
+
const q = String(args.query || '').trim().toLowerCase()
|
|
2176
|
+
const hit = (s: { id: string; name: string; description: string; triggers: string[] }) =>
|
|
2177
|
+
!q || [s.id, s.name, s.description, ...s.triggers].some(x => String(x).toLowerCase().includes(q))
|
|
2178
|
+
const rows = scan.skills.filter(hit)
|
|
2179
|
+
if (!rows.length) {
|
|
2180
|
+
// 一个技能都没有时**说清是"没装"还是"目录配错了"**:后者会把用户引到错的地方找
|
|
2181
|
+
if (!scan.skills.length) {
|
|
2182
|
+
return `本机没有扫描到技能。技能根目录:${skillRoots().join('、')}`
|
|
2183
|
+
+ (scan.missingRoots.length ? `(其中这些目录不存在:${scan.missingRoots.join('、')})` : '')
|
|
2184
|
+
}
|
|
2185
|
+
return `没有匹配「${args.query}」的技能(本机共有 ${scan.skills.length} 个)`
|
|
2186
|
+
}
|
|
2187
|
+
const disabled = disabledIds()
|
|
2188
|
+
const lines = rows.map(s => {
|
|
2189
|
+
const state = skillState(s, disabled)
|
|
2190
|
+
const tag = state === 'disabled' ? '[已禁用] ' : ''
|
|
2191
|
+
const ver = s.version ? ` v${s.version}` : ''
|
|
2192
|
+
return `· ${tag}${s.id}${ver}:${s.name} —— ${s.description || '(无描述)'}`
|
|
2193
|
+
})
|
|
2194
|
+
lines.push(`(共 ${rows.length} 个,正文用 read_skill 读)`)
|
|
2195
|
+
return lines.join('\n')
|
|
2196
|
+
}
|
|
2197
|
+
case 'read_skill': {
|
|
2198
|
+
const id = String(args.id || '').trim()
|
|
2199
|
+
if (!id) return '(参数错误: 缺少技能 id)'
|
|
2200
|
+
const body = readSkillBody(id)
|
|
2201
|
+
if (!body.ok) return body.text
|
|
2202
|
+
// 技能正文是**磁盘上的内容**,可能含 `</weflow-local-data>` —— 必须包起来再进上下文
|
|
2203
|
+
return frameLocalData(`skill.${id}`, body.text)
|
|
2204
|
+
}
|
|
2205
|
+
case 'list_contacts': {
|
|
2206
|
+
await ensureDb()
|
|
2207
|
+
const keyword = String(args.keyword || '').trim()
|
|
2208
|
+
const limit = boundedToolInteger(args.limit, 50, 200)
|
|
2209
|
+
const contacts = await chatService.listContacts(keyword || undefined, limit)
|
|
2210
|
+
if (!contacts.length) {
|
|
2211
|
+
return keyword
|
|
2212
|
+
? `(通讯录里没有匹配「${keyword}」的名字)`
|
|
2213
|
+
: '(通讯录是空的或读不到——可以用 check 看本机数据源的状态)'
|
|
2214
|
+
}
|
|
2215
|
+
// **只给名字**:`username` 是 wxid,`avatarUrl` 里也可能带本机标识,
|
|
2216
|
+
// 本仓的纪律是本机标识不进模型上下文(同 speakerLabel 那边的处理)
|
|
2217
|
+
const names: string[] = []
|
|
2218
|
+
for (const c of contacts) {
|
|
2219
|
+
const n = String(c.remark || c.displayName || c.nickname || '').trim()
|
|
2220
|
+
if (n && !names.includes(n)) names.push(n)
|
|
2221
|
+
}
|
|
2222
|
+
if (!names.length) return '(通讯录读到了, 但都没有可显示的名字)'
|
|
2223
|
+
return `通讯录里 ${names.length} 个名字:\n` + names.slice(0, limit).map(n => `· ${n}`).join('\n')
|
|
2224
|
+
}
|
|
2225
|
+
case 'get_review': {
|
|
2226
|
+
const dir = reviewsDir()
|
|
2227
|
+
if (!existsSync(dir)) {
|
|
2228
|
+
// 说清"这个目录本来就还没生成",而不是让模型以为"今天没有回顾"
|
|
2229
|
+
return '(还没有学习回顾——它不是日报自动产的,要单独跑 `weflow-cli review`)'
|
|
2230
|
+
}
|
|
2231
|
+
const files = readdirSync(dir).filter(f => /^Daily-\d{4}-\d{2}-\d{2}\.md$/.test(f)).sort().reverse()
|
|
2232
|
+
if (!files.length) return '(学习回顾目录是空的)'
|
|
2233
|
+
const want = args.date ? safeDate(args.date) : null
|
|
2234
|
+
if (args.date && !want) return `(日期格式不对: ${args.date},要 YYYY-MM-DD)`
|
|
2235
|
+
const pick = want ? files.find(f => f.includes(want)) : files[0]
|
|
2236
|
+
if (!pick) {
|
|
2237
|
+
return `(${want} 没有学习回顾。最近几份: ${files.slice(0, 3).map(f => f.slice(6, 16)).join(', ')})`
|
|
2238
|
+
}
|
|
2239
|
+
const maxChars = boundedToolInteger(args.max_chars, 3000, 6000)
|
|
2240
|
+
const body = readFileSync(join(dir, pick), 'utf8')
|
|
2241
|
+
const date = pick.slice(6, 16)
|
|
2242
|
+
return `${date} 学习回顾:\n${body.slice(0, maxChars)}`
|
|
2243
|
+
}
|
|
2244
|
+
case 'get_concepts': {
|
|
2245
|
+
const limit = boundedToolInteger(args.limit, 40, 120)
|
|
2246
|
+
const out: string[] = []
|
|
2247
|
+
for (const dir of vaultDirs()) {
|
|
2248
|
+
const line = dir.split(/[\\/]/).includes('Chat') ? '聊天线' : '文章线'
|
|
2249
|
+
const index = wikiIndexPath(dir)
|
|
2250
|
+
if (!existsSync(index)) { out.push(`【${line}】还没有总览索引(还没编译过这一线的知识库)`); continue }
|
|
2251
|
+
const text = readFileSync(index, 'utf8')
|
|
2252
|
+
const rows = text.split(/\r?\n/).filter(l => l.trim()).slice(0, limit)
|
|
2253
|
+
out.push(`【${line}】${rows.length} 行:\n${rows.join('\n')}`)
|
|
2254
|
+
}
|
|
2255
|
+
return out.join('\n\n')
|
|
2256
|
+
}
|
|
2257
|
+
case 'format_article': {
|
|
2258
|
+
const content = String(args.content || '')
|
|
2259
|
+
if (!content.trim()) return '(参数错误: content 不能为空)'
|
|
2260
|
+
const { formatWeChatArticle } = await import('./wechat-formatter.js')
|
|
2261
|
+
const theme = String(args.theme || 'default')
|
|
2262
|
+
const html = formatWeChatArticle(content, { theme: theme as any })
|
|
2263
|
+
const preview = html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').slice(0, 200)
|
|
2264
|
+
return `主题: ${theme}\n总长度: ${html.length} 字符\n正文预览: ${preview}…\n---\n${html}`
|
|
2265
|
+
}
|
|
2266
|
+
case 'list_themes': {
|
|
2267
|
+
const { listThemes } = await import('./wechat-formatter.js')
|
|
2268
|
+
const themes = listThemes()
|
|
2269
|
+
if (!themes.length) return '(没有可用主题)'
|
|
2270
|
+
return `可用主题 ${themes.length} 个:\n`
|
|
2271
|
+
+ themes.map(t => `· ${t.id} — ${t.name}: ${t.description}`).join('\n')
|
|
2272
|
+
}
|
|
2273
|
+
case 'lint_wiki': {
|
|
2274
|
+
// 两个知识库都要查(本仓的分线纪律:只查一个,另一半的断链永远看不见)
|
|
2275
|
+
const parts: string[] = []
|
|
2276
|
+
let totalBad = 0
|
|
2277
|
+
// **真正体检过几条线。"跳过"不算"没问题"**:CI 上(检出里没有 `output/wechat-vault`)
|
|
2278
|
+
// 两条线都只打「目录不存在,跳过」,而下面那一支原来照样会加一句"两个知识库都没有断链…"
|
|
2279
|
+
// —— 没看过却说干净。用户没编译过知识库时也会撞上同一句话。
|
|
2280
|
+
let checked = 0
|
|
2281
|
+
for (const dir of vaultDirs()) {
|
|
2282
|
+
// **线名优先由 Python 那边给**(`report.lineLabel`,源头是 `_utils.LINE_LABELS`):
|
|
2283
|
+
// 两条线各自一个根之后聊天线很可能不再叫 `Chat`,按路径猜会把聊天线报成
|
|
2284
|
+
// **文章线**,还不报错。`guess` 只在目录不存在(来不及问 Python)与自定义
|
|
2285
|
+
// `--dir` 认不出线时兜底。
|
|
2286
|
+
const guess = vaultLineLabel(dir)
|
|
2287
|
+
if (!existsSync(dir)) { parts.push(`【${guess}】目录不存在,跳过`); continue }
|
|
2288
|
+
// 超时给 60 秒:本机最大的一条线(3,623 页)实测 **3.4 秒**(脚本里的 O(n²) 修掉之后,
|
|
2289
|
+
// 见 wiki_lint.py 那条注释),17 倍余量够用。默认的 30 秒曾经也超——那是因为脚本本身慢,
|
|
2290
|
+
// 不是超时给得小。
|
|
2291
|
+
const r = await runPythonJson<any>('wiki_lint.py', ['--dir', dir, '--json'], { timeoutMs: 60_000 })
|
|
2292
|
+
if (!r.ok || !r.data) {
|
|
2293
|
+
// 失败要说出来:静默跳过会让"体检通过"变成一句假话
|
|
2294
|
+
parts.push(`【${guess}】体检失败: ${r.error || '读不到结果'}`)
|
|
2295
|
+
totalBad += 1
|
|
2296
|
+
continue
|
|
2297
|
+
}
|
|
2298
|
+
const d = r.data
|
|
2299
|
+
const line = d.lineLabel || guess
|
|
2300
|
+
const broken = (d.broken || []).length
|
|
2301
|
+
const orphans = (d.orphans || []).length
|
|
2302
|
+
const empty = (d.empty || []).length
|
|
2303
|
+
const dup = Object.keys(d.duplicateTitles || {}).length
|
|
2304
|
+
// 跨线同名**不计入问题数**:两条线各留一张同名页是设计(两个命名空间分开),
|
|
2305
|
+
// 把它算成"要修的"会让人去修一个设计如此的东西。
|
|
2306
|
+
const cross = (d.crossLineSameName || []).length
|
|
2307
|
+
checked += 1
|
|
2308
|
+
totalBad += broken + orphans + empty + dup
|
|
2309
|
+
parts.push(`【${line}】${d.pages || 0} 张页:断链 ${broken}、孤儿 ${orphans}、空页 ${empty}、同名 ${dup} 组`
|
|
2310
|
+
+ (cross ? `(另有 ${cross} 个概念两条线都有,设计如此)` : ''))
|
|
2311
|
+
}
|
|
2312
|
+
return (checked && !totalBad ? '两个知识库都没有断链/孤儿/空页/同名。\n' : '') + parts.join('\n')
|
|
2313
|
+
}
|
|
2314
|
+
case 'check_skills': {
|
|
2315
|
+
const scan = scanSkills()
|
|
2316
|
+
const disabled = disabledIds()
|
|
2317
|
+
const lines: string[] = [`技能目录: ${skillRoots().join('、')}`, `扫描到 ${scan.skills.length} 个技能`]
|
|
2318
|
+
for (const dir of scan.missingRoots) lines.push(`· 目录不存在: ${dir}`)
|
|
2319
|
+
if (scan.unreadable.length) {
|
|
2320
|
+
lines.push(`读不出来的 ${scan.unreadable.length} 个(frontmatter 缺失或不闭合):`)
|
|
2321
|
+
for (const u of scan.unreadable) lines.push(`· ${u.file} —— ${u.reason}`)
|
|
2322
|
+
}
|
|
2323
|
+
if (scan.conflicts.length) {
|
|
2324
|
+
lines.push(`跨目录同名的 ${scan.conflicts.length} 个(靠前的目录胜出): ${scan.conflicts.map(c => c.id).join('、')}`)
|
|
2325
|
+
}
|
|
2326
|
+
const warned = scan.skills.filter(s => s.notes.length)
|
|
2327
|
+
if (warned.length) {
|
|
2328
|
+
lines.push(`有提醒的 ${warned.length} 个:`)
|
|
2329
|
+
for (const w of warned) for (const n of w.notes) lines.push(`· ${w.id}: ${n}`)
|
|
2330
|
+
}
|
|
2331
|
+
const off = scan.skills.filter(s => skillState(s, disabled) === 'disabled').map(s => s.id)
|
|
2332
|
+
if (off.length) lines.push(`已禁用: ${off.join('、')}`)
|
|
2333
|
+
if (!scan.unreadable.length && !scan.conflicts.length && !warned.length) {
|
|
2334
|
+
lines.push('没有读不出来或撞名的技能。')
|
|
2335
|
+
}
|
|
2336
|
+
return lines.join('\n')
|
|
2337
|
+
}
|
|
2338
|
+
case 'fetch_article': {
|
|
2339
|
+
const url = String(args.url || '').trim()
|
|
2340
|
+
// **严格白名单,不是 denylist**:只认 https 的 mp.weixin.qq.com。
|
|
2341
|
+
// 这条比 read_favorite 那条严,因为那里的链接是用户自己收藏的,这里的 URL 是别人给的。
|
|
2342
|
+
if (!isAllowedWeChatArticleUrl(url)) {
|
|
2343
|
+
return `(只接受 https 的 mp.weixin.qq.com 文章链接,拒绝抓取: ${url.slice(0, 60)})`
|
|
2344
|
+
}
|
|
2345
|
+
const maxChars = boundedToolInteger(args.max_chars, 3000, 6000)
|
|
2346
|
+
// 逐跳复检:一次 302 就能把入口白名单绕过去
|
|
2347
|
+
const { status, html } = await fetchArticleHtml(url,
|
|
2348
|
+
{ maxRedirects: 3, allowHop: isAllowedWeChatArticleUrl })
|
|
2349
|
+
if (!html) return `(抓取失败 HTTP ${status} —— 链接可能不是公众号文章,或者跳转到了别处)`
|
|
2350
|
+
if (html.includes('已被发布者删除')) return '(这篇文章已被发布者删除)'
|
|
2351
|
+
const body = articleTextFromHtml(html, maxChars)
|
|
2352
|
+
if (!body.trim() || (body.match(/[一-鿿]/g) || []).length < 20) {
|
|
2353
|
+
return '(抓到了页面但取不出正文——可能是验证页,或者这篇文章要登录/已失效)'
|
|
2354
|
+
}
|
|
2355
|
+
const title = html.match(/<meta[^>]*property="og:title"[^>]*content="([^"]+)"/i)?.[1]
|
|
2356
|
+
|| html.match(/<title>([^<]+)<\/title>/i)?.[1]?.trim() || ''
|
|
2357
|
+
const author = html.match(/var\s+nickname\s*=\s*"([^"]+)"/i)?.[1]
|
|
2358
|
+
|| html.match(/<meta[^>]*property="og:article:author"[^>]*content="([^"]+)"/i)?.[1] || ''
|
|
2359
|
+
// 配图只报数量与前几个链接:正文是纯文本,但图往往就是这篇文章的要点
|
|
2360
|
+
const imgs = [...html.matchAll(/data-src="(https?:\/\/[^"]+)"/g)].map(m => m[1]).slice(0, 5)
|
|
2361
|
+
const head = [title && `标题: ${title}`, author && `公众号: ${author}`].filter(Boolean).join(' | ')
|
|
2362
|
+
const tail = imgs.length ? `\n\n配图 ${imgs.length} 张(前几张):\n${imgs.map(u => `· ${u}`).join('\n')}` : ''
|
|
2363
|
+
return (head ? `${head}\n---\n` : '') + body + tail
|
|
2364
|
+
}
|
|
2365
|
+
case 'search_public': {
|
|
2366
|
+
const keyword = String(args.keyword || '').trim()
|
|
2367
|
+
if (!keyword) return '(参数错误: 缺少 keyword)'
|
|
2368
|
+
const limit = boundedToolInteger(args.limit, 8, 20)
|
|
2369
|
+
let html = ''
|
|
2370
|
+
try {
|
|
2371
|
+
const resp = await fetch(
|
|
2372
|
+
`https://weixin.sogou.com/weixin?type=2&query=${encodeURIComponent(keyword)}`,
|
|
2373
|
+
{
|
|
2374
|
+
headers: {
|
|
2375
|
+
'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',
|
|
2376
|
+
'Referer': 'https://weixin.sogou.com/',
|
|
2377
|
+
'Accept': 'text/html',
|
|
2378
|
+
'Accept-Language': 'zh-CN,zh;q=0.9',
|
|
2379
|
+
},
|
|
2380
|
+
signal: AbortSignal.timeout(20_000),
|
|
2381
|
+
},
|
|
2382
|
+
)
|
|
2383
|
+
html = await readTextLimited(resp, 2 * 1024 * 1024)
|
|
2384
|
+
} catch {
|
|
2385
|
+
return '(搜索失败——检查网络,或者稍后再试(第三方搜索页会限流))'
|
|
2386
|
+
}
|
|
2387
|
+
const grab = (re: RegExp, clean: (s: string) => string): string[] => {
|
|
2388
|
+
const out: string[] = []
|
|
2389
|
+
for (const m of html.matchAll(re)) {
|
|
2390
|
+
const v = clean(m[1])
|
|
2391
|
+
if (v) out.push(v)
|
|
2392
|
+
}
|
|
2393
|
+
return out
|
|
2394
|
+
}
|
|
2395
|
+
const titles = grab(/<h3[^>]*>\s*<a[^>]*>(.*?)<\/a>\s*<\/h3>/gs,
|
|
2396
|
+
s => s.replace(/<em[^>]*>/g, '').replace(/<\/em>/g, '').replace(/<!--[^>]*-->/g, '').trim())
|
|
2397
|
+
const accounts = grab(/<span[^>]*class="[^"]*all-time-y2[^"]*"[^>]*>(.*?)<\/span>/gs,
|
|
2398
|
+
s => s.replace(/<[^>]+>/g, '').trim())
|
|
2399
|
+
const descs = grab(/<p[^>]*class="[^"]*txt-info[^"]*"[^>]*>(.*?)<\/p>/gs,
|
|
2400
|
+
s => s.replace(/<[^>]+>/g, '').replace(/…/g, '…').replace(/→/g, '→').replace(/—/g, '—').trim())
|
|
2401
|
+
.filter(d => d.length > 10)
|
|
2402
|
+
const count = Math.max(titles.length, accounts.length, descs.length)
|
|
2403
|
+
if (!count) {
|
|
2404
|
+
return `(没搜到与「${keyword}」相关的公开文章。换个说法、或加上公众号名试试;`
|
|
2405
|
+
+ '找到链接后可以用 fetch_article 抓全文)'
|
|
2406
|
+
}
|
|
2407
|
+
const rows: string[] = []
|
|
2408
|
+
for (let i = 0; i < Math.min(count, limit); i++) {
|
|
2409
|
+
rows.push(`${i + 1}. ${titles[i] || '(未知标题)'}\n 公众号: ${accounts[i] || '(未知)'}`
|
|
2410
|
+
+ (descs[i] ? `\n ${descs[i]}` : ''))
|
|
2411
|
+
}
|
|
2412
|
+
return `公开文章里搜「${keyword}」,命中 ${Math.min(count, limit)} 条:\n`
|
|
2413
|
+
+ rows.join('\n')
|
|
2414
|
+
+ '\n(这是第三方搜索页的标题与摘要,不是全文;要正文用 fetch_article 抓)'
|
|
2415
|
+
}
|
|
2416
|
+
case 'set_todo_status': {
|
|
2417
|
+
const status = String(args.status || '')
|
|
2418
|
+
if (status !== 'done' && status !== 'pending') return '(参数错误: status 只能是 done(已完成)或 pending(取消完成))'
|
|
2419
|
+
const id = String(args.id || '').trim()
|
|
2420
|
+
const task = String(args.task || '').trim()
|
|
2421
|
+
if (!id && !task) return '(参数错误: 要给 id 或 task 指到哪一条待办)'
|
|
2422
|
+
let target = id
|
|
2423
|
+
if (!target) {
|
|
2424
|
+
// 按任务文字找:**只认唯一**。命中 0 条或 ≥2 条都拒绝——
|
|
2425
|
+
// 猜错条目比拒绝更糟(用户会以为自己的待办被改了,而改的是另一条)
|
|
2426
|
+
const listed = await runPythonJson<any>('extract_todos.py', ['list', '--json'])
|
|
2427
|
+
if (!listed.ok) return fail('待办读取失败', listed)
|
|
2428
|
+
const items: any[] = Array.isArray(listed.data) ? listed.data : (listed.data?.items || [])
|
|
2429
|
+
const norm = (s: unknown) => String(s || '').replace(/\s+/g, '')
|
|
2430
|
+
const hit = items.filter(t => norm(t.task).includes(norm(task)))
|
|
2431
|
+
if (!hit.length) return `(没有任务文字包含「${task}」的待办)`
|
|
2432
|
+
if (hit.length > 1) {
|
|
2433
|
+
return `(「${task}」匹配到 ${hit.length} 条待办,不能确定是哪一条,请用 id 指定:\n`
|
|
2434
|
+
+ hit.slice(0, 5).map(t => `· ${t.task}`).join('\n') + ')'
|
|
2435
|
+
}
|
|
2436
|
+
if (!hit[0].id) return '(这条待办没有 id——本机的待办文件格式比预期旧,请在电脑上用 `todos done` 处理)'
|
|
2437
|
+
target = String(hit[0].id)
|
|
2438
|
+
}
|
|
2439
|
+
const r = await runPythonJson<any>('extract_todos.py',
|
|
2440
|
+
[status === 'done' ? 'done' : 'undone', target, '--json'])
|
|
2441
|
+
if (!r.ok) return fail(status === 'done' ? '标记完成失败' : '取消完成失败', r)
|
|
2442
|
+
// 括号开头 = "没有产出内容"(本仓的约定,见 producedContent):这条只报告动作做成了
|
|
2443
|
+
return status === 'done' ? `(已标记完成: ${target})` : `(已取消完成标记: ${target})`
|
|
2444
|
+
}
|
|
2445
|
+
default:
|
|
2446
|
+
return `(未知工具: ${name})`
|
|
2447
|
+
}
|
|
2448
|
+
} catch (error) {
|
|
2449
|
+
if (error instanceof ToolInputError) return `(参数错误: ${error.message})`
|
|
2450
|
+
return '(工具执行失败,请检查本地配置或运行状态)'
|
|
2451
|
+
}
|
|
2452
|
+
}
|