weflow-cli 1.6.4 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +1966 -0
  3. package/OPERATIONS.md +808 -0
  4. package/README.en.md +93 -7
  5. package/README.md +31 -6
  6. package/bin/weflow-cli.ts +1245 -26
  7. package/dist/bin/weflow-cli.js +1349 -72
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.js +39 -13
  10. package/dist/mcp-server/index.js.map +1 -1
  11. package/dist/src/core/appMsgFormat.d.ts +37 -0
  12. package/dist/src/core/appMsgFormat.d.ts.map +1 -0
  13. package/dist/src/core/appMsgFormat.js +106 -0
  14. package/dist/src/core/appMsgFormat.js.map +1 -0
  15. package/dist/src/core/ntCore.d.ts +43 -0
  16. package/dist/src/core/ntCore.d.ts.map +1 -1
  17. package/dist/src/core/ntCore.js +31 -0
  18. package/dist/src/core/ntCore.js.map +1 -1
  19. package/dist/src/core/sqlcipherCore.d.ts +0 -12
  20. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  21. package/dist/src/core/sqlcipherCore.js +5 -64
  22. package/dist/src/core/sqlcipherCore.js.map +1 -1
  23. package/dist/src/core/wcdbCore.d.ts +1 -1
  24. package/dist/src/core/wcdbCore.d.ts.map +1 -1
  25. package/dist/src/core/wcdbCore.js +5 -1
  26. package/dist/src/core/wcdbCore.js.map +1 -1
  27. package/dist/src/panel/client.d.ts +25 -0
  28. package/dist/src/panel/client.d.ts.map +1 -0
  29. package/dist/src/panel/client.js +34 -0
  30. package/dist/src/panel/client.js.map +1 -0
  31. package/dist/src/panel/endpoint.d.ts +45 -0
  32. package/dist/src/panel/endpoint.d.ts.map +1 -0
  33. package/dist/src/panel/endpoint.js +132 -0
  34. package/dist/src/panel/endpoint.js.map +1 -0
  35. package/dist/src/panel/launch.d.ts +50 -0
  36. package/dist/src/panel/launch.d.ts.map +1 -0
  37. package/dist/src/panel/launch.js +74 -0
  38. package/dist/src/panel/launch.js.map +1 -0
  39. package/dist/src/panel/server.d.ts +23 -0
  40. package/dist/src/panel/server.d.ts.map +1 -0
  41. package/dist/src/panel/server.js +413 -0
  42. package/dist/src/panel/server.js.map +1 -0
  43. package/dist/src/panel/userId.d.ts +37 -0
  44. package/dist/src/panel/userId.d.ts.map +1 -0
  45. package/dist/src/panel/userId.js +56 -0
  46. package/dist/src/panel/userId.js.map +1 -0
  47. package/dist/src/services/assistantDaemon.d.ts +20 -3
  48. package/dist/src/services/assistantDaemon.d.ts.map +1 -1
  49. package/dist/src/services/assistantDaemon.js +46 -9
  50. package/dist/src/services/assistantDaemon.js.map +1 -1
  51. package/dist/src/services/assistantEval.d.ts +141 -0
  52. package/dist/src/services/assistantEval.d.ts.map +1 -0
  53. package/dist/src/services/assistantEval.js +497 -0
  54. package/dist/src/services/assistantEval.js.map +1 -0
  55. package/dist/src/services/assistantMemory.d.ts +106 -4
  56. package/dist/src/services/assistantMemory.d.ts.map +1 -1
  57. package/dist/src/services/assistantMemory.js +396 -61
  58. package/dist/src/services/assistantMemory.js.map +1 -1
  59. package/dist/src/services/assistantPrivacy.d.ts +15 -2
  60. package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
  61. package/dist/src/services/assistantPrivacy.js +18 -4
  62. package/dist/src/services/assistantPrivacy.js.map +1 -1
  63. package/dist/src/services/assistantRouter.d.ts +41 -0
  64. package/dist/src/services/assistantRouter.d.ts.map +1 -0
  65. package/dist/src/services/assistantRouter.js +151 -0
  66. package/dist/src/services/assistantRouter.js.map +1 -0
  67. package/dist/src/services/assistantService.d.ts +209 -3
  68. package/dist/src/services/assistantService.d.ts.map +1 -1
  69. package/dist/src/services/assistantService.js +590 -75
  70. package/dist/src/services/assistantService.js.map +1 -1
  71. package/dist/src/services/assistantTools.d.ts +205 -1
  72. package/dist/src/services/assistantTools.d.ts.map +1 -1
  73. package/dist/src/services/assistantTools.js +1089 -58
  74. package/dist/src/services/assistantTools.js.map +1 -1
  75. package/dist/src/services/assistantTrace.d.ts +47 -0
  76. package/dist/src/services/assistantTrace.d.ts.map +1 -0
  77. package/dist/src/services/assistantTrace.js +150 -0
  78. package/dist/src/services/assistantTrace.js.map +1 -0
  79. package/dist/src/services/chatService.d.ts +13 -0
  80. package/dist/src/services/chatService.d.ts.map +1 -1
  81. package/dist/src/services/chatService.js +23 -0
  82. package/dist/src/services/chatService.js.map +1 -1
  83. package/dist/src/services/configService.d.ts +9 -0
  84. package/dist/src/services/configService.d.ts.map +1 -1
  85. package/dist/src/services/configService.js +15 -8
  86. package/dist/src/services/configService.js.map +1 -1
  87. package/dist/src/services/messageQuery.d.ts +35 -0
  88. package/dist/src/services/messageQuery.d.ts.map +1 -1
  89. package/dist/src/services/messageQuery.js +61 -0
  90. package/dist/src/services/messageQuery.js.map +1 -1
  91. package/dist/src/services/pythonBridge.d.ts +41 -0
  92. package/dist/src/services/pythonBridge.d.ts.map +1 -0
  93. package/dist/src/services/pythonBridge.js +119 -0
  94. package/dist/src/services/pythonBridge.js.map +1 -0
  95. package/dist/src/services/syncService.d.ts +127 -0
  96. package/dist/src/services/syncService.d.ts.map +1 -0
  97. package/dist/src/services/syncService.js +263 -0
  98. package/dist/src/services/syncService.js.map +1 -0
  99. package/dist/src/services/syncState.d.ts +113 -0
  100. package/dist/src/services/syncState.d.ts.map +1 -0
  101. package/dist/src/services/syncState.js +214 -0
  102. package/dist/src/services/syncState.js.map +1 -0
  103. package/dist/src/services/wechatMessageService.d.ts +25 -0
  104. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  105. package/dist/src/services/wechatMessageService.js +69 -7
  106. package/dist/src/services/wechatMessageService.js.map +1 -1
  107. package/dist/src/utils/atomicWrite.d.ts +4 -0
  108. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  109. package/dist/src/utils/atomicWrite.js +60 -0
  110. package/dist/src/utils/atomicWrite.js.map +1 -0
  111. package/dist/src/utils/dateRange.d.ts +15 -0
  112. package/dist/src/utils/dateRange.d.ts.map +1 -1
  113. package/dist/src/utils/dateRange.js +47 -0
  114. package/dist/src/utils/dateRange.js.map +1 -1
  115. package/dist/src/utils/text.d.ts +18 -0
  116. package/dist/src/utils/text.d.ts.map +1 -0
  117. package/dist/src/utils/text.js +20 -0
  118. package/dist/src/utils/text.js.map +1 -0
  119. package/docs/AI_INTERFACE.md +2 -0
  120. package/docs/DECISIONS.md +1484 -20
  121. package/docs/EXTENDING.md +187 -0
  122. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +338 -0
  123. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  124. package/docs/MCP.md +23 -4
  125. package/docs/PROJECT_STATE.md +299 -96
  126. package/docs/SETUP.md +22 -0
  127. package/docs/SYNC_CONTRACT.md +180 -0
  128. package/mcp-server/index.ts +681 -657
  129. package/package.json +84 -83
  130. package/resources/panel/ball-position.cjs +153 -0
  131. package/resources/panel/index.html +46 -0
  132. package/resources/panel/main.cjs +447 -0
  133. package/resources/panel/mascot-focus.png +0 -0
  134. package/resources/panel/mascot-happy.png +0 -0
  135. package/resources/panel/mascot-sorry.png +0 -0
  136. package/resources/panel/mascot-tired.png +0 -0
  137. package/resources/panel/mascot.png +0 -0
  138. package/resources/panel/package.json +7 -0
  139. package/resources/panel/panel.css +407 -0
  140. package/resources/panel/preload.cjs +72 -0
  141. package/resources/panel/quick-menu.cjs +197 -0
  142. package/resources/panel/renderer.js +441 -0
  143. package/resources/panel/tray-menu.cjs +40 -0
  144. package/resources/panel/tray.png +0 -0
  145. package/scripts/_utils.py +403 -54
  146. package/scripts/annual_report.py +4 -2
  147. package/scripts/article_notes.py +423 -0
  148. package/scripts/backfill_articles.py +438 -0
  149. package/scripts/biz_daily.py +590 -105
  150. package/scripts/chat_notes.py +470 -0
  151. package/scripts/classify_daily.py +10 -18
  152. package/scripts/compile_wiki.py +1059 -46
  153. package/scripts/create_reading_notes.py +199 -14
  154. package/scripts/decide.py +253 -0
  155. package/scripts/draft_reply.py +671 -0
  156. package/scripts/export_chat_html.py +212 -46
  157. package/scripts/extract_todos.py +31 -5
  158. package/scripts/fav_notes.py +318 -0
  159. package/scripts/fix_topics.py +2 -1
  160. package/scripts/generate_ai_report.py +214 -9
  161. package/scripts/generate_html.py +18 -3
  162. package/scripts/generate_review.py +2 -2
  163. package/scripts/jev_client.py +294 -0
  164. package/scripts/jev_probe.py +440 -0
  165. package/scripts/nt_common.py +82 -0
  166. package/scripts/nt_decrypt.py +1755 -1352
  167. package/scripts/pipeline.py +27 -2
  168. package/scripts/promote_ideas.py +19 -4
  169. package/scripts/quality_eval.py +707 -0
  170. package/scripts/rag_chat.py +4 -4
  171. package/scripts/read_image.py +187 -0
  172. package/scripts/reply_debt.py +488 -0
  173. package/scripts/route_cards.py +453 -0
  174. package/scripts/semantic_search.py +208 -49
  175. package/scripts/user_notes.py +315 -0
  176. package/scripts/vault_rag.py +13 -7
  177. package/scripts/vault_search.py +21 -4
  178. package/scripts/wiki_lint.py +397 -0
  179. package/src/core/appMsgFormat.ts +117 -0
  180. package/src/core/ntCore.ts +67 -0
  181. package/src/core/sqlcipherCore.ts +5 -61
  182. package/src/core/wcdbCore.ts +6 -2
  183. package/src/panel/client.ts +49 -0
  184. package/src/panel/endpoint.ts +144 -0
  185. package/src/panel/launch.ts +101 -0
  186. package/src/panel/server.ts +424 -0
  187. package/src/panel/userId.ts +72 -0
  188. package/src/services/assistantDaemon.ts +57 -9
  189. package/src/services/assistantEval.ts +611 -0
  190. package/src/services/assistantMemory.ts +413 -57
  191. package/src/services/assistantPrivacy.ts +18 -4
  192. package/src/services/assistantRouter.ts +191 -0
  193. package/src/services/assistantService.ts +671 -74
  194. package/src/services/assistantTools.ts +1118 -57
  195. package/src/services/assistantTrace.ts +175 -0
  196. package/src/services/chatService.ts +30 -0
  197. package/src/services/configService.ts +403 -385
  198. package/src/services/messageQuery.ts +97 -0
  199. package/src/services/pythonBridge.ts +139 -0
  200. package/src/services/syncService.ts +363 -0
  201. package/src/services/syncState.ts +275 -0
  202. package/src/services/wechatMessageService.ts +73 -7
  203. package/src/utils/atomicWrite.ts +56 -0
  204. package/src/utils/dateRange.ts +46 -0
  205. package/src/utils/text.ts +20 -0
@@ -3,35 +3,306 @@
3
3
  * 所有工具在本机执行; 结果经 PrivacyGate 脱敏后才进入 LLM 上下文。
4
4
  */
5
5
  import { chatService } from './chatService.js';
6
+ import { configService } from './configService.js';
7
+ import { runPythonJson } from './pythonBridge.js';
8
+ import { exportService } from './exportService.js';
6
9
  import { privacyGate } from './assistantPrivacy.js';
7
10
  import { existsSync, readFileSync, readdirSync } from 'fs';
8
- import { join } from 'path';
9
- import { execFile } from 'child_process';
10
- import { promisify } from 'util';
11
- import { createPythonProcessEnv, safeSubprocessError } from '../utils/pythonProcessEnv.js';
11
+ import { basename, join } from 'path';
12
+ // 直接调进程的写法已收敛进 pythonBridge:这里不再 import child_process
12
13
  import { resolvePackageRoot } from '../utils/packageRoot.js';
13
- const execFileAsync = promisify(execFile);
14
+ import { clipWithMarker } from '../utils/text.js';
15
+ import { resolveSince, resolveUntil } from '../utils/dateRange.js';
14
16
  const PKG_ROOT = resolvePackageRoot(import.meta.url);
15
17
  const BIZ_DAILY_DIR = join(PKG_ROOT, 'output', 'biz-daily');
16
- const VAULT_WIKI_DIR = join(PKG_ROOT, 'output', 'wechat-vault', 'Wiki', 'Concepts');
18
+ /** 单条聊天消息进上下文的字数上限(见 get_messages:引用消息要放得下正文+被引原文) */
19
+ const MSG_BODY_CHARS = 160;
20
+ // 知识页住在哪几个目录。**两个是有意的**:文章知识库与聊天知识库分开(用户 2026-09-27
21
+ // 要求),所以读的一方要把**两个都读**——只读一个,分出去的那一半就静默地搜不到。
22
+ // 与 Python 的 `_utils.CONCEPT_DIRS` 是同一份清单,有测试钉住两者一致(跨语言没法共用常量)。
23
+ const VAULT_WIKI_DIRS = [
24
+ join(PKG_ROOT, 'output', 'wechat-vault', 'Wiki', 'Concepts'),
25
+ join(PKG_ROOT, 'output', 'wechat-vault', 'Chat', 'Concepts'),
26
+ ];
27
+ /**
28
+ * 概念名 → 文件名。**与 `compile_wiki` 是同一套规则**(非法字符换下划线、截到 60 字)。
29
+ *
30
+ * 这里必须消毒:邻居名是从**文件内容**里读出来的,直接拼进路径就能走出目录
31
+ * (`../../` 那类)。文件名规则两边不一致也会让"查得到"变成"查不到"。
32
+ */
33
+ export function safeConceptFile(name) {
34
+ return `${name.replace(/[\\/:*?"<>|]/g, '_').slice(0, 60)}.md`;
35
+ }
36
+ /** 概念页正文的第一句(跳过 frontmatter、标题、引用、列表)——给"这个邻居是什么"。 */
37
+ /**
38
+ * 页面里「正文」的那一部分:`## 来源` 之前。
39
+ *
40
+ * **来源段不算正文** —— 它列的是文章标题(可能 300+ 条),拿它做「这个词在这页里吗」的判断,
41
+ * 会把「某篇来源的标题里含这个词」报成「正文提及」。
42
+ */
43
+ export function pageBody(text) {
44
+ const at = text.indexOf('## 来源');
45
+ return at === -1 ? text : text.slice(0, at);
46
+ }
47
+ /** 概念页里「相关概念」那一节指向的名字(按出现顺序)。 */
48
+ function relatedNames(pagePath) {
49
+ const text = readFileSync(pagePath, 'utf8');
50
+ const start = text.indexOf('## 相关概念');
51
+ if (start < 0)
52
+ return [];
53
+ const rest = text.slice(start + 1);
54
+ const end = rest.indexOf('\n## ');
55
+ const section = end > 0 ? rest.slice(0, end) : rest;
56
+ return [...section.matchAll(/\[\[([^\]|]+)\]\]/g)]
57
+ .map(match => match[1].replace(/\.md$/, '').trim())
58
+ .filter(Boolean);
59
+ }
60
+ /**
61
+ * 从一页出发,**沿着图走若干跳**,逐层返回 `{名字, 一句定义, 从谁连过来}`。
62
+ *
63
+ * 与 `conceptNeighbors` 的分工:那个给"一跳"(本页 + 直接邻居),这个给"一片"。助手此前只能
64
+ * 一跳一跳地查(工具说明里写着"可以顺着它们再用本工具逐跳查下去"),但那要**模型自己决定**
65
+ * 下一跳查谁。一次给两跳,它就能直接看到"这个概念周围的生态"。
66
+ *
67
+ * 三条规矩:
68
+ * - **去重**:一个概念在图上有多条路径时只留**最先到达**的那条(BFS 保证它跳数最少),
69
+ * 否则同一个名字会在好几层里重复出现,读的人分不清;
70
+ * - **封顶**(`maxNodes`):4 个邻居乘 4 乘 4 就是 64 —— 不封顶第三跳能把输出撑爆,而模型
71
+ * 读不完的图等于没给。截断了要**说出来**(`truncated`),不能默默少给;
72
+ * - **没建页的邻居照旧报数**(`missing`),不静默吞掉。
73
+ */
74
+ export function conceptSubgraph(pageName, depth = 2, maxNodes = 30, wikiDirs = VAULT_WIKI_DIRS) {
75
+ // 同 `conceptNeighbors`:传字符串会静默地按字符遍历,所以这里响,不猜。
76
+ if (typeof wikiDirs === 'string') {
77
+ throw new TypeError('conceptSubgraph 的 wikiDirs 要的是目录数组,不是单个字符串');
78
+ }
79
+ // 邻居可能住在另一个目录(文章概念指向聊天概念、反之亦然),所以查找跨目录
80
+ const find = (name) => {
81
+ for (const dir of wikiDirs) {
82
+ const candidate = join(dir, safeConceptFile(name));
83
+ if (existsSync(candidate))
84
+ return candidate;
85
+ }
86
+ return null;
87
+ };
88
+ if (!find(pageName))
89
+ return { levels: [], truncated: false, missing: 0 };
90
+ const seen = new Set([pageName]);
91
+ const levels = [];
92
+ let frontier = [pageName];
93
+ let truncated = false;
94
+ let missing = 0;
95
+ for (let hop = 0; hop < depth && frontier.length; hop += 1) {
96
+ const level = [];
97
+ const next = [];
98
+ for (const from of frontier) {
99
+ const fromPath = find(from);
100
+ if (!fromPath)
101
+ continue;
102
+ for (const name of relatedNames(fromPath)) {
103
+ if (seen.has(name))
104
+ continue;
105
+ const target = find(name);
106
+ if (!target) {
107
+ missing += 1;
108
+ continue;
109
+ }
110
+ if (seen.size >= maxNodes) {
111
+ truncated = true;
112
+ continue;
113
+ }
114
+ seen.add(name);
115
+ level.push({ name, brief: conceptBrief(readFileSync(target, 'utf8')), via: from });
116
+ next.push(name);
117
+ }
118
+ }
119
+ if (level.length)
120
+ levels.push(level);
121
+ frontier = next;
122
+ }
123
+ return { levels, truncated, missing };
124
+ }
125
+ export function conceptBrief(text) {
126
+ const body = text.startsWith('---') ? text.slice(text.indexOf('---', 3) + 3) : text;
127
+ for (const raw of body.split('\n')) {
128
+ const line = raw.trim();
129
+ if (!line || /^[#>\-|`\[]/.test(line))
130
+ continue;
131
+ return line.length > 60 ? `${line.slice(0, 60)}…` : line;
132
+ }
133
+ return '(这页没写定义)';
134
+ }
135
+ /**
136
+ * 一个概念页在 `## 相关概念` 里连到的邻居——**图谱在 Agent 侧的落地**。
137
+ *
138
+ * 为什么要有:Obsidian 的关系图谱是人眼视图,助手那条路一直读不到"图"。但概念页里
139
+ * 本来就写着它的邻居,把它们一并给出去,"查一个概念"就变成"拿到一小块子图"。
140
+ * 实测:全部概念页都短于这个工具返回的 2,000 字上限,所以那段一直在返回内容里
141
+ * ——但**没人点明它可以顺着走**,也没人把邻居的一句话定义取出来。
142
+ *
143
+ * 有页的邻居连定义一起给;没有页的只报个数(`missing`),免得模型去追一个取不到的名字。
144
+ */
145
+ export function conceptNeighbors(pageName, wikiDirs = VAULT_WIKI_DIRS) {
146
+ // **传字符串会静默地按字符遍历**(`for (const dir of 'D:\vault')` 挨个字符看过去),
147
+ // 于是 `join('D', '某概念.md')` 不存在,函数安静地回一句「没有邻居」。2026-09-27 把参数
148
+ // 从单个目录改成目录数组时,测试里正好踩到这个——类型拦得住 `src/`(`tsc` 覆盖它),
149
+ // 拦不住测试与将来的 JS 调用方。所以这里响,不猜。
150
+ if (typeof wikiDirs === 'string') {
151
+ throw new TypeError('conceptNeighbors 的 wikiDirs 要的是目录数组,不是单个字符串');
152
+ }
153
+ // **邻居可能住在另一个目录**(文章概念指向聊天概念、反之亦然),所以查找也是跨目录的
154
+ const find = (name) => {
155
+ for (const dir of wikiDirs) {
156
+ const candidate = join(dir, safeConceptFile(name));
157
+ if (existsSync(candidate))
158
+ return candidate;
159
+ }
160
+ return null;
161
+ };
162
+ const pagePath = find(pageName);
163
+ if (!pagePath)
164
+ return { linked: [], missing: 0 };
165
+ const text = readFileSync(pagePath, 'utf8');
166
+ const start = text.indexOf('## 相关概念');
167
+ if (start < 0)
168
+ return { linked: [], missing: 0 };
169
+ const rest = text.slice(start + 1);
170
+ const end = rest.indexOf('\n## ');
171
+ const section = end > 0 ? rest.slice(0, end) : rest;
172
+ const linked = [];
173
+ let missing = 0;
174
+ for (const match of section.matchAll(/\[\[([^\]|]+)\]\]/g)) {
175
+ const name = match[1].replace(/\.md$/, '').trim();
176
+ if (!name)
177
+ continue;
178
+ const target = find(name);
179
+ if (!target) {
180
+ missing += 1;
181
+ continue;
182
+ }
183
+ linked.push({ name, brief: conceptBrief(readFileSync(target, 'utf8')) });
184
+ }
185
+ return { linked, missing };
186
+ }
187
+ /** 一轮对话里最多看几张图。每次调用一张,这是上限——图片按体积算钱,也按体积算隐私。 */
188
+ const MAX_IMAGES_PER_TURN = 2;
17
189
  function fmtTime(ts) {
18
190
  const n = Number(ts);
19
191
  const d = n > 1e12 ? new Date(n) : new Date(n * 1000);
20
192
  return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
21
193
  }
22
- /** SSRF 防护: 仅 http(s), 拒绝内网/环回地址 */
23
- function isSafeUrl(raw) {
194
+ /**
195
+ * 转录里的时刻:`09-23 12:20`。
196
+ *
197
+ * 与 `fmtTime`(给用户看的工具输出用 `9/23 12:20`)**故意不同**:转录这一行的形状要和
198
+ * Python 侧逐字一致,见 `transcriptLine`。改 `fmtTime` 会连带改用户看到的东西,所以这里单独一个。
199
+ */
200
+ function transcriptTime(ts) {
201
+ const n = Number(ts);
202
+ const d = n > 1e12 ? new Date(n) : new Date(n * 1000);
203
+ const pad = (value) => String(value).padStart(2, '0');
204
+ return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
205
+ }
206
+ /** 单条消息进转录的截断长度。**必须等于** `scripts/reply_debt.py` 的 `DRAFT_MSG_CHARS`。 */
207
+ export const TRANSCRIPT_MSG_CHARS = 160;
208
+ /**
209
+ * 转录取**多少条**消息。**必须等于** `scripts/reply_debt.py` 的 `TRANSCRIPT_SIZE`。
210
+ *
211
+ * 与上面那个字数上限是一对:一个决定"多长的一段对话",一个决定"每条多长"。两条入口
212
+ * (面板/微信走 `--stdin`,命令行走 `--talker`)必须给出同一段对话——否则同一件事在两边
213
+ * 判断出的结果会不一样,而**不会报任何错**。两边今天都是 30,但在这之前谁都没盯着它。
214
+ */
215
+ export const TRANSCRIPT_MESSAGES = 30;
216
+ /**
217
+ * 消息是**谁**说的(对方那一侧):**有解析出来的名字就用名字,没有就写「对方」**。
218
+ *
219
+ * **`senderUsername` 不参与**:它是数据库原列的 wxid(`wcdbCore` 的 `sender_username`、
220
+ * `sqlcipherCore` 的 `StrTalker`),不是名字。拿它当标签有两个坏处——把**账号标识**交给云端
221
+ * 模型,而且对模型没用(wxid 不告诉它这是谁)。
222
+ *
223
+ * 实测(本机 8 个会话、179 条别人发的消息):`senderDisplay` 是有中文的名字 174 条,
224
+ * `senderUsername` 是 wxid 形状 176 条,两者**从不相同**。所以这个替换不丢信息:
225
+ * 群聊里照样分得清谁说的(名字比 wxid 还更有用),而标识不再出境。
226
+ */
227
+ export function speakerLabel(message) {
228
+ return message.senderDisplay || '对方';
229
+ }
230
+ /**
231
+ * 对话转录里的一行:`[09-23 12:20] 老王:那个文件你什么时候发我`。
232
+ *
233
+ * **这段形状在 Python 侧还有一份**:`reply_debt.format_line`(`draft_reply.py --talker`
234
+ * 那条路自己读库时用它渲染)。两处必须逐字一致,否则同一段对话走不同入口会得到不同的转录——
235
+ * 而它们**已经漂过**,实测三处不同:
236
+ *
237
+ * | | 这边(`--stdin`,面板/微信那条路) | Python `format_line`(`--talker` 命令行) |
238
+ * | --- | --- | --- |
239
+ * | 时刻 | `9/23 12:20` | `09-23 12:20` |
240
+ * | 对方那句 | 写死「对方」 | `senderDisplay` 优先(单聊里就是人名) |
241
+ * | 截断 | 160 字 | 120 字(留个 `…` vs 直接切掉) |
242
+ *
243
+ * 漂移里最危险的是「我」那个标记:脚本靠 `'] 我:'` 挑语气样本、靠 `lastFromMe` 判最后一条,
244
+ * 改错一个字全都**静默失效**(不报错,只是判断与语气样本都变差)。
245
+ * 所以有 `test/transcript-format-contract.test.ts`:同一批消息(含一条超长、一条非文本)
246
+ * 把两边逐字比一遍。谁改了这一行的形状而不改另一边,那条会红。
247
+ *
248
+ * **`senderUsername` 永远不参与**(实测过):它是**数据库原列的 wxid**
249
+ * (`wcdbCore` 的 `sender_username`、`sqlcipherCore` 的 `StrTalker`),不是名字。
250
+ * 拿它当发言人的标签会把账号标识喂给两个云端模型——而这一行的目的只是让模型分得清谁说的。
251
+ * 所以规则是:**有解析出来的名字就用名字,没有就写「对方」**,绝不退回原列。
252
+ * `senderDisplay` 在真实路径上是有值的(实测一条会话:文本与图片消息都带),所以这边
253
+ * 正常会写成人名,「对方」只是兜底。
254
+ */
255
+ export function transcriptLine(message) {
256
+ const raw = message.localType === 1
257
+ ? (message.content || message.parsedContent || '')
258
+ : (message.parsedContent || message.content || '');
259
+ const body = privacyGate.maskMessageBody(clipWithMarker(String(raw).replace(/\n/g, ' '), TRANSCRIPT_MSG_CHARS), { isText: message.localType === 1 });
260
+ const speaker = message.isSend ? '我' : speakerLabel(message);
261
+ return `[${transcriptTime(message.createTime)}] ${speaker}:${body}`;
262
+ }
263
+ /** SSRF 防护: 仅 http(s), 拒绝内网/环回地址
264
+ *
265
+ * 导出是为了能测它:这是**抓取前的安全边界**,而它守着的是"别让助手被一条收藏里的链接
266
+ * 带去访问内网"。纯函数,没有副作用。
267
+ */
268
+ export function isSafeUrl(raw) {
24
269
  try {
25
270
  const u = new URL(raw);
26
271
  if (u.protocol !== 'http:' && u.protocol !== 'https:')
27
272
  return false;
28
- const h = u.hostname;
29
- if (h === 'localhost' || h === '[::1]')
273
+ const h = u.hostname.toLowerCase();
274
+ if (!h)
30
275
  return false;
31
- if (/^(127\.|10\.|192\.168\.|169\.254\.|0\.)/.test(h))
276
+ // IPv6 字面量(URL 里带方括号)。Node 会把 [0:0:0:0:0:0:0:1] 归一成 [::1],
277
+ // 但不碰 [fd00::1] 这类——所以下面按前缀逐个挡。
278
+ if (h.startsWith('[')) {
279
+ const inner = h.slice(1, -1);
280
+ if (inner === '::' || inner === '::1')
281
+ return false;
282
+ if (inner.startsWith('::ffff:'))
283
+ return false; // IPv4 映射(可能是 127.0.0.1)
284
+ if (/^f[cd]/.test(inner))
285
+ return false; // fc00::/7 唯一本地
286
+ if (/^fe[89ab]/.test(inner))
287
+ return false; // fe80::/10 链路本地
288
+ return true;
289
+ }
290
+ if (h === 'localhost')
32
291
  return false;
33
- if (/^172\.(1[6-9]|2\d|3[01])\./.test(h))
292
+ // 纯数字或 0x 形式的整型主机名:内核会把它当 IPv4 用,而域名不可能是这种形状。
293
+ // (实测 Node 对 2130706433 / 0x7f000001 会归一成 127.0.0.1,这道是双保险。)
294
+ if (/^(0x[0-9a-f]+|[0-9]+)$/.test(h))
34
295
  return false;
296
+ // 私网前缀**只在真的是点分四段时**才判:否则 `10.example.com` 这种公网域名会被
297
+ // `^10\.` 误杀,工具会回一句「链接不安全,拒绝抓取」,而链接其实是好的。
298
+ if (/^[0-9]{1,3}(\.[0-9]{1,3}){3}$/.test(h)) {
299
+ if (/^(127\.|10\.|192\.168\.|169\.254\.|0\.)/.test(h))
300
+ return false;
301
+ if (/^172\.(1[6-9]|2[0-9]|3[01])\./.test(h))
302
+ return false;
303
+ }
304
+ // 这是一份已知形态的黑名单,不是"这地址一定可公网路由"的证明:
305
+ // NAT64(64:ff9b::/96)等映射形式不在覆盖范围内。
35
306
  return true;
36
307
  }
37
308
  catch {
@@ -39,7 +310,7 @@ function isSafeUrl(raw) {
39
310
  }
40
311
  }
41
312
  /** 标签剥离 → 纯文本 */
42
- function stripTags(seg) {
313
+ export function stripTags(seg) {
43
314
  return seg.replace(/<script[\s\S]*?<\/script>/gi, ' ')
44
315
  .replace(/<style[\s\S]*?<\/style>/gi, ' ')
45
316
  .replace(/<[^>]+>/g, '\n')
@@ -48,7 +319,7 @@ function stripTags(seg) {
48
319
  .split('\n').map(s => s.trim()).filter(Boolean).join('\n');
49
320
  }
50
321
  /** WAF 挑战页兜底: 正文藏在 cgiDataNew.content_noencode (JS 转义字符串) */
51
- function extractFromChallengePage(html) {
322
+ export function extractFromChallengePage(html) {
52
323
  const start = html.indexOf("content_noencode: '");
53
324
  if (start < 0)
54
325
  return null;
@@ -92,7 +363,7 @@ function extractFromChallengePage(html) {
92
363
  return out.length > 100 ? out : null;
93
364
  }
94
365
  /** 从 HTML 提取正文文本 (微信公众号 js_content 优先, 配平 div 边界; 兜底 body) */
95
- function extractText(html) {
366
+ export function extractText(html) {
96
367
  let seg = html;
97
368
  const jc = html.indexOf('id="js_content"');
98
369
  if (jc >= 0) {
@@ -125,6 +396,25 @@ function extractText(html) {
125
396
  async function ensureDb() {
126
397
  await chatService.connect();
127
398
  }
399
+ /** 最近一份**有内容**的日报是哪天,以及那是几天前。算不出来就回空串——不猜。 */
400
+ function lastDailyNote() {
401
+ try {
402
+ const days = readdirSync(BIZ_DAILY_DIR)
403
+ .filter(name => /^\d{4}-\d{2}-\d{2}$/.test(name)).sort().reverse().slice(0, 30);
404
+ for (const day of days) {
405
+ const file = join(BIZ_DAILY_DIR, day, '.articles.json');
406
+ if (!existsSync(file))
407
+ continue;
408
+ const payload = JSON.parse(readFileSync(file, 'utf8'));
409
+ if ((payload?.articles ?? []).length) {
410
+ const ago = Math.round((Date.now() - new Date(`${day}T00:00:00`).getTime()) / 86_400_000);
411
+ return `;最近一次有内容的日报是 ${day}(${ago} 天前)`;
412
+ }
413
+ }
414
+ }
415
+ catch { /* 扫不出来就什么都不说,而不是猜一个日期 */ }
416
+ return '';
417
+ }
128
418
  class ToolInputError extends Error {
129
419
  }
130
420
  export function boundedToolInteger(value, fallback, maximum, field = 'limit') {
@@ -158,10 +448,45 @@ export function resolveUniqueTalker(name, sessions) {
158
448
  }
159
449
  return query;
160
450
  }
161
- /** 显示名 → username 解析 (会话表里两者都有) */
451
+ /**
452
+ * 通讯录里按 **备注 / 显示名 / 昵称 / 别名 / username** 找唯一的一个;找不到或有歧义回 null。
453
+ *
454
+ * 与 `resolveUniqueTalker` 同一套纪律:**只认唯一**,不猜。
455
+ */
456
+ export async function lookupContactUsername(name) {
457
+ const query = name.trim();
458
+ if (!query)
459
+ return null;
460
+ // 窗口给宽一点:关键字命中的是**包含**它的所有名字,一个字的查询能匹配到几十个人,
461
+ // 而精确那一个可能排在 20 名之后。实测(378 个真实联系人,都不在最近会话里):
462
+ // 20 名时漏 6 个、60 名时漏 5 个,**认错都是 0 个**。剩下那 5 个都是单字名——它匹配到
463
+ // 几十个人,精确的那个仍在窗口之外。这个残余是安全的:回 null,助手说"没找到",
464
+ // 而不会读到别人的聊天。
465
+ const contacts = await chatService.listContacts(query, 60);
466
+ const fields = (contact) => [contact.remark, contact.displayName, contact.nickname, contact.alias]
467
+ .filter(Boolean);
468
+ const exact = contacts.filter(c => c.username === query || fields(c).some(f => f === query));
469
+ const pool = exact.length ? exact : contacts.filter(c => fields(c).some(f => f.includes(query)));
470
+ const usernames = [...new Set(pool.map(c => c.username))];
471
+ return usernames.length === 1 ? usernames[0] : null;
472
+ }
473
+ /**
474
+ * 显示名 → username 解析。
475
+ *
476
+ * 会话列表**只取最近 300 个**,而通讯录是完整的:名字不在那 300 个里时(对话很多的人,或
477
+ * 很久没聊过的人),旧行为是把查询词原样当 talker 返回——于是下游读到空、助手说"没找到
478
+ * 消息",而那个人其实在通讯录里。所以**匹配不上时再查一次通讯录**。
479
+ *
480
+ * 兜底只做"名字 → username",不去验证这个会话有没有消息:没有消息时下游会如实说
481
+ * "没找到消息",那是对的答案,不该在这里变成"查无此人"。
482
+ */
162
483
  async function resolveTalker(name) {
163
484
  const sessions = await chatService.listSessions(undefined, 300);
164
- return resolveUniqueTalker(name, sessions);
485
+ const talker = resolveUniqueTalker(name, sessions); // 歧义会抛,让它抛
486
+ if (sessions.some(session => session.username === talker))
487
+ return talker;
488
+ const fromBook = await lookupContactUsername(name);
489
+ return fromBook ?? talker;
165
490
  }
166
491
  export const TOOL_DEFS = [
167
492
  {
@@ -181,29 +506,49 @@ export const TOOL_DEFS = [
181
506
  type: 'function',
182
507
  function: {
183
508
  name: 'get_messages',
184
- description: '读取用户与某位联系人的最近聊天记录。联系人名用会话里出现的显示名。',
509
+ description: '读取用户与某位联系人的聊天记录。默认给最近若干条;'
510
+ + '问**某一天或某段时间**时给 since/until —— 不给的话久远的日子按条数是取不到的。'
511
+ + '联系人名用会话里出现的显示名。',
185
512
  parameters: {
186
513
  type: 'object',
187
514
  properties: {
188
515
  contact: { type: 'string', description: '联系人显示名或备注名' },
189
- limit: { type: 'number', description: '消息条数, 默认20' },
516
+ limit: { type: 'number', description: '消息条数, 默认20, 上限50' },
517
+ since: { type: 'string', description: '起点:2026-09-16 或相对写法 3d / 2w / 12h' },
518
+ until: { type: 'string', description: '终点(含当天):2026-09-16' },
190
519
  },
191
520
  required: ['contact'],
192
521
  },
193
522
  },
194
523
  },
524
+ {
525
+ type: 'function',
526
+ function: {
527
+ name: 'look_at_image',
528
+ description: '看聊天里的一张图片(把图片本身交给模型看,不只是"这里有一张图")。'
529
+ + '`image` 填 get_messages 输出里 `[图片 #1234]` 的编号。'
530
+ + '一轮最多看两张;隐私模式为 strict 时不可用(聊天图片不出发本机)。',
531
+ parameters: {
532
+ type: 'object',
533
+ properties: {
534
+ contact: { type: 'string', description: '这张图所在会话的联系人显示名' },
535
+ image: { type: 'string', description: 'get_messages 里 [图片 #N] 的编号 N' },
536
+ },
537
+ required: ['contact', 'image'],
538
+ },
539
+ },
540
+ },
195
541
  {
196
542
  type: 'function',
197
543
  function: {
198
544
  name: 'search_favorites',
199
- description: '搜索用户的微信收藏(主要是收藏的公众号文章)。',
545
+ description: '搜索用户的微信收藏(主要是收藏的公众号文章);**不给关键词就是最近收藏了什么**。',
200
546
  parameters: {
201
547
  type: 'object',
202
548
  properties: {
203
549
  keyword: { type: 'string', description: '搜索关键词' },
204
550
  limit: { type: 'number', description: '返回条数, 默认8' },
205
551
  },
206
- required: ['keyword'],
207
552
  },
208
553
  },
209
554
  },
@@ -246,7 +591,7 @@ export const TOOL_DEFS = [
246
591
  parameters: {
247
592
  type: 'object',
248
593
  properties: {
249
- mode: { type: 'string', description: 'timeline (最新动态) 或 stats (统计), 默认 timeline' },
594
+ mode: { type: 'string', description: 'timeline (最新动态) / stats (统计) / users (谁发得最多), 默认 timeline' },
250
595
  limit: { type: 'number', description: 'timeline 模式条数, 默认10' },
251
596
  },
252
597
  },
@@ -279,15 +624,125 @@ export const TOOL_DEFS = [
279
624
  },
280
625
  },
281
626
  },
627
+ {
628
+ type: 'function',
629
+ function: {
630
+ name: 'search_chats',
631
+ description: '在**聊天记录**里检索「在哪聊过某件事」,按字面词匹配。适合「上次说的那个部署方案是在哪聊的」。'
632
+ + '(知识库用 search_knowledge;助手记得的关于你的事用 search_memory)'
633
+ + '「谁提过这个客户」这类问题。它只匹配字面词(同义改写要靠别的路子),所以问题描述得具体些。'
634
+ + '代价:会把你的问题与候选词发给判断模型(不发聊天正文),约 1-2 秒。',
635
+ parameters: {
636
+ type: 'object',
637
+ properties: {
638
+ question: { type: 'string', description: '要找的事,用自然语言描述' },
639
+ per_card: { type: 'number', description: '每个会话最多回几条消息,默认 3' },
640
+ confirm: {
641
+ type: 'boolean',
642
+ description: '仅当用户**明确同意**这个工具会把内容发出去时才传 true。不传或传 false 时只返回预览(要发什么、发给谁),零出境。',
643
+ },
644
+ },
645
+ required: ['question'],
646
+ },
647
+ },
648
+ },
649
+ {
650
+ type: 'function',
651
+ function: {
652
+ name: 'draft_reply',
653
+ description: '帮用户**起草**一条回复(只产出候选文本,绝不替他发送)。适合「帮我回老王那句话」'
654
+ + '「这条怎么回」。会先判断对方要什么、该不该给实质内容、风险多高,再给几条候选。'
655
+ + '涉及钱或风险很高时**不给草稿**,只说明风险与该先确认什么——别把这种情况当失败。'
656
+ + '(想看对方写了什么用 get_messages;想知道谁在等用 who_owes_reply。)'
657
+ + '代价:要把这段对话发给判断模型与生成模型(两个云端模型),约几秒;'
658
+ + '**外部机器调用(MCP)不带 confirm 时只返回预览**(发多少字符给谁),不实际出境。',
659
+ parameters: {
660
+ type: 'object',
661
+ properties: {
662
+ contact: { type: 'string', description: '给谁起草(联系人名,和 get_messages 一样解析)' },
663
+ count: { type: 'number', description: '要几条候选,默认 3(最多 5)' },
664
+ confirm: {
665
+ type: 'boolean',
666
+ description: '仅当用户**明确同意**把这段对话发给两个云端模型时才传 true。'
667
+ + '不传或传 false 时只返回预览(多少条消息、多少字符、发给哪两个模型),零出境。',
668
+ },
669
+ },
670
+ required: ['contact'],
671
+ },
672
+ },
673
+ },
674
+ {
675
+ type: 'function',
676
+ function: {
677
+ name: 'who_owes_reply',
678
+ description: '看看谁在等你回话(对方说完就没下文的那种)。适合「我有没有漏回谁的消息」。'
679
+ + '代价:逐会话问一次判断模型,可能要几十秒;只报谁在等,不回正文。',
680
+ parameters: {
681
+ type: 'object',
682
+ properties: {
683
+ days: { type: 'number', description: '只看最近多少天有动静的会话,默认 14' },
684
+ confirm: {
685
+ type: 'boolean',
686
+ description: '仅当用户**明确同意**这个工具会把内容发出去时才传 true。不传或传 false 时只返回预览(要发什么、发给谁),零出境。',
687
+ },
688
+ },
689
+ },
690
+ },
691
+ },
692
+ {
693
+ type: 'function',
694
+ function: {
695
+ name: 'search_semantic',
696
+ description: '按**意思**找聊天记录(语义/同义检索),而 search_chats 只匹配字面词——'
697
+ + '想不起原话、只记得大意时用它。'
698
+ + '「上次说的那个部署方案是在哪聊的」用 search_chats;「和钱有关的讨论」这种同义改写用这条。'
699
+ + '代价:查询词会发给阿里云百炼做嵌入、候选片段会发给判断模型重排(都是仓库既有的云端路径),'
700
+ + '需要先建过索引(weflow-cli search-index)。',
701
+ parameters: {
702
+ type: 'object',
703
+ properties: {
704
+ query: { type: 'string', description: '要找的意思,用自然语言描述' },
705
+ top_k: { type: 'number', description: '要几条,默认 8,上限 20' },
706
+ confirm: {
707
+ type: 'boolean',
708
+ description: '仅当用户**明确同意**这个工具会把内容发出去时才传 true。不传或传 false 时只返回预览(要发什么、发给谁),零出境。',
709
+ },
710
+ },
711
+ required: ['query'],
712
+ },
713
+ },
714
+ },
715
+ {
716
+ type: 'function',
717
+ function: {
718
+ name: 'export_chat',
719
+ description: '把某个人的聊天记录导出成 HTML(含图片),落到 output/exports/ 下。'
720
+ + '适合「帮我把和某某的聊天导出备份一下」。**会写文件**:每次新建一个带时间戳的目录,绝不覆盖已有的,'
721
+ + '路径也不是你给而是固定的。',
722
+ parameters: {
723
+ type: 'object',
724
+ properties: {
725
+ contact: { type: 'string', description: '联系人显示名或备注名' },
726
+ limit: { type: 'number', description: '最多导出多少条,默认 500,上限 5000' },
727
+ format: { type: 'string', description: 'html(默认,含图片) / txt / json / excel' },
728
+ },
729
+ required: ['contact'],
730
+ },
731
+ },
732
+ },
282
733
  {
283
734
  type: 'function',
284
735
  function: {
285
736
  name: 'search_knowledge',
286
- description: '搜索用户的本地知识库(从公众号文章沉淀的 Wiki 概念页与学习日报)。适合查概念解释、找之前整理过的知识。',
737
+ description: '搜索**本地知识库**(从公众号文章沉淀的 Wiki 概念页与学习日报),适合查概念解释、找之前整理过的知识。'
738
+ + '**返回时会一并给出这一页连到的相关概念(每个一句定义)**——可以顺着它们再用本工具逐跳查下去,'
739
+ + '这样一次追问能走开一小块知识,而不是停在一个孤立页面上。'
740
+ + '(聊天里说过什么用 search_chats;助手记得的关于你的事用 search_memory)',
287
741
  parameters: {
288
742
  type: 'object',
289
743
  properties: {
290
744
  keyword: { type: 'string', description: '概念名或关键词, 如 RAG、Agent' },
745
+ depth: { type: 'integer', description: '沿「相关概念」走几跳:默认 1 只给直接邻居,2 能一次看到这个概念周围的生态(最多 3)' },
291
746
  },
292
747
  required: ['keyword'],
293
748
  },
@@ -301,11 +756,27 @@ export const TOOL_DEFS = [
301
756
  parameters: { type: 'object', properties: {} },
302
757
  },
303
758
  },
759
+ {
760
+ type: 'function',
761
+ function: {
762
+ name: 'get_reading_stats',
763
+ description: '公众号推送与日报处理的统计:哪些号发得多、哪些被日报处理得多。'
764
+ + '回答"我最近都在读什么/哪个号发得最多"这类问题时用它。'
765
+ + '**日报没在跑的时候它会直说**(那不是"没内容")。',
766
+ parameters: {
767
+ type: 'object',
768
+ properties: {
769
+ days: { type: 'number', description: '统计最近多少天,默认 7,上限 90' },
770
+ },
771
+ },
772
+ },
773
+ },
304
774
  {
305
775
  type: 'function',
306
776
  function: {
307
777
  name: 'search_memory',
308
- description: '搜索助手关于用户的长期记忆(此前对话中提取的持久事实)。',
778
+ description: '搜索**助手记得的关于你的事**(此前对话里提取的长期事实:偏好、项目、关系)。'
779
+ + '(聊天正文用 search_chats,整理过的知识用 search_knowledge)',
309
780
  parameters: {
310
781
  type: 'object',
311
782
  properties: {
@@ -330,7 +801,152 @@ export const TOOL_DEFS = [
330
801
  },
331
802
  },
332
803
  ];
333
- export const MCP_READ_ONLY_TOOL_DEFS = TOOL_DEFS.filter(tool => tool.function.name !== 'save_memory');
804
+ /**
805
+ * MCP 那条路看得见的工具表。
806
+ *
807
+ * **它不叫 read-only**(曾经叫,而那是假的):里面一直有写文件的 `export_chat`,也有几个会把
808
+ * 用户数据发给云端模型、需要 `confirm: true` 的工具。这里只排除**在那条路上根本做不了事**的,
809
+ * 而"为什么做不了"写在下面那张表里(值就是原因,不是 true):
810
+ *
811
+ * - `save_memory`:写记忆。MCP 没有微信用户身份,挂的记忆桶是单独一条;让它写没有意义。
812
+ * - `look_at_image`:它的做法是把图**挂进侧信道**(`ctx.pendingImages`)交给助手自己的模型看,
813
+ * 而 MCP 那条路只把工具返回的**文本**交给客户端(见 `mcp-server/index.ts` 的 default 分支)——
814
+ * 图没人接,回话里却写着"已附上图片…你能看到它了",那是**一句假话**。
815
+ * 按本仓"不摆一个跑不了的工具"那条立论(同 `unavailableToolReason`),它不该出现在这里。
816
+ *
817
+ * 做成表而不是写进 filter:这是**注册表的一部分**,`test/tool-registry.test.ts` 会盯着
818
+ * 表里的每个名字都真的存在于 `TOOL_DEFS`(写错一个名字 = 静默地没排除掉任何东西)。
819
+ */
820
+ export const MCP_EXCLUDED = {
821
+ save_memory: 'MCP 没有微信用户身份,挂的记忆桶是单独一条,让它写没有意义',
822
+ look_at_image: 'MCP 只把工具返回的文本交给客户端,它挂进侧信道的图没人接,回话会变成一句假话',
823
+ };
824
+ export const MCP_TOOL_DEFS = TOOL_DEFS.filter(tool => !(tool.function.name in MCP_EXCLUDED));
825
+ /** 脚本类工具的失败回话:桥接层已经分好类(超时/退出码/没有 JSON/脚本自己报错),
826
+ * 这里把它和 stderr 尾巴合起来**过一遍脱敏**再交给模型——stderr 里可能有密钥形状的东西,
827
+ * 而工具结果是要出境的。
828
+ */
829
+ function fail(what, result) {
830
+ const detail = [result.error, result.stderr].filter(Boolean).join(' · ');
831
+ const { safe } = privacyGate.redact(detail);
832
+ return `(${what}${safe ? ": " + safe : ""})`;
833
+ }
834
+ /**
835
+ * **括号起头 = 工具没能给出内容**。这是本仓库既有的写法:`fail()` 与各分支的
836
+ * "查不到 / 缺参数 / 拒绝抓取"一律写成 `(…)`,而正常内容不带括号。
837
+ *
838
+ * 这张白名单是**唯一的例外**:成功的句子也可以是括号起头(不然它就不叫例外了)。
839
+ * 有一条测试盯着它——白名单里每一条都必须在 `assistantTools.ts` 里真的以 `return` 出现,
840
+ * 改措辞忘了同步就会红。反过来(有新的成功消息带括号、但没进白名单)测试抓不到,
841
+ * 这是这个信号的真实边界:**它靠约定,不是一个机器可读的成败字段**。
842
+ *
843
+ * 想要真字段,得把 outcome 从 ~40 个 return 处一路带出来(`ToolContext.outcome`)。
844
+ * 那件事记在 D-043 的"已知边界"里,没有做——这里用它是因为轨迹只想知道"这一步有没有
845
+ * 产出东西",而这个判断错了的代价是报告里多一个/少一个「无内容」标记。
846
+ */
847
+ export const PAREN_SUCCESS_PREFIXES = ['(已附上图片'];
848
+ /** 工具这次**有没有给出内容**。括号起头(除白名单外)都算没有。 */
849
+ export function producedContent(text) {
850
+ const head = (text ?? '').trimStart();
851
+ if (!head.startsWith('('))
852
+ return true;
853
+ return PAREN_SUCCESS_PREFIXES.some(prefix => head.startsWith(prefix));
854
+ }
855
+ /**
856
+ * "这台机器上跑不了"的**声明**:缺哪个配置键、缺了之后怎么跟用户说(值就是那句话)。
857
+ *
858
+ * 做成一张表而不是一串 `if (name === '...')`:加一个带前置条件的工具,现在是在这里加**一行**,
859
+ * 而不是在 if 链里再插一段;`test/tool-registry.test.ts` 盯着表里的每个名字都真的存在于 `TOOL_DEFS`
860
+ * (写错名字 = 那条规则永远不生效,而且不会有任何报错)。
861
+ *
862
+ * **只写"缺了就跑不了"的前置条件**,不写"暂时没数据"——没数据时工具自己会说该运行什么
863
+ * (`先运行 weflow-cli wiki compile`),那是有用的回答。
864
+ */
865
+ export const TOOL_REQUIREMENTS = {
866
+ search_semantic: { key: 'dashscopeApiKey', why: '语义检索需要 dashscopeApiKey' },
867
+ get_weread: { key: 'wereadApiKey', why: '微信读书需要 wereadApiKey' },
868
+ draft_reply: {
869
+ key: 'deepseekApiKey',
870
+ // **只有 DeepSeek 是必须的**。判断那一步(Jev)现在可降级:调不通就只用生成模型起草,
871
+ // 输出里会写明"这次没有判断、闸门没生效"(2026-09-25 Jev 免费期结束那天改的)。
872
+ // 所以缺 typesafeApiKey **不**该把这个工具收起来——那会让"能跑的工具"被藏掉。
873
+ why: '起草回复需要 deepseekApiKey(生成那一步)',
874
+ },
875
+ };
876
+ /**
877
+ * 这台机器上**跑不了**的工具,不该出现在工具表里。规则本身在上面的 `TOOL_REQUIREMENTS` 里,这里只是执行它。
878
+ *
879
+ * 为什么按配置过滤:模型看见工具就会去试。这台机器没配 `dashscopeApiKey` 时 `search_semantic`
880
+ * 必然失败——实测(评测的 `ambiguous-contact` 那次)模型连试两个检索工具、两个都报错,最后答复
881
+ * 里带着"两个检索工具都跑不通"。那不是助手的问题,是**我们摆了一个跑不了的工具**。工具越少,
882
+ * 选择也越准。
883
+ */
884
+ export function unavailableToolReason(name, config = configService.get.bind(configService)) {
885
+ const need = TOOL_REQUIREMENTS[name];
886
+ if (!need)
887
+ return null;
888
+ return String(config(need.key) || '').trim() ? null : need.why;
889
+ }
890
+ /** 这台机器上真正可用的工具表。快路径派发也要过同一道判据(见 `unavailableToolReason`)。 */
891
+ export function availableToolDefs(config) {
892
+ return TOOL_DEFS.filter(def => !unavailableToolReason(def.function.name, config));
893
+ }
894
+ /**
895
+ * 判断模型给的键翻成人话。
896
+ *
897
+ * 为什么要这一层:那些键是**英文判据的键名**(`casual_chat` / `give_commitment`),
898
+ * 原样交给助手,它就会照着念给用户听。映射不上就退回原键——如实展示,不硬编一个。
899
+ */
900
+ const INTENT_LABELS = {
901
+ confirm_you_care: '想要你一个态度(在试探你还在不在意)',
902
+ vent_anger: '在发火,想被听见而不是被解决',
903
+ request_action: '有事要你办(发东西、付钱、约时间这类)',
904
+ seek_explanation: '想要个解释',
905
+ casual_chat: '在闲聊,没有事要你办',
906
+ close_topic: '在收尾,不想再展开',
907
+ };
908
+ const ACTION_LABELS = {
909
+ check_history: '先翻一下更早的聊天记录再说',
910
+ apologize: '具体地认错',
911
+ give_commitment: '给一个做得到的时间或交付',
912
+ explain: '说明经过,不辩解',
913
+ acknowledge: '表示收到了,先不承诺什么',
914
+ say_less: '少说',
915
+ make_plan: '一起定个时间',
916
+ };
917
+ const NEED_LABELS = {
918
+ apology: '一个道歉', action: '实际的动作', explanation: '解释', care: '被在意',
919
+ nothing: '什么都不用',
920
+ };
921
+ function zhLabel(map, key) {
922
+ const text = String(key ?? '');
923
+ return map[text] || text || '说不准';
924
+ }
925
+ function riskLabel(score) {
926
+ const value = Number(score ?? 0);
927
+ if (value >= 8)
928
+ return '很危险';
929
+ if (value >= 6)
930
+ return '偏危险';
931
+ if (value >= 3)
932
+ return '留神';
933
+ return '安全';
934
+ }
935
+ /**
936
+ * 外部机器(MCP)调用到"会把用户数据送出去"的工具时,**统一只给预览**的那段文案。
937
+ *
938
+ * 四个出境工具(draft_reply / who_owes_reply / search_chats / search_semantic)共用这一份形状,
939
+ * 各自把"要发什么、发给谁"填进 `what`。预览阶段一律不出境:能用脚本自己的 `--dry-run`
940
+ * 报出具体数字的就报数字(draft_reply、who_owes_reply),报不出的就按入参说清
941
+ * (search_chats 的 --dry-run 打的是纯文本、不认 --json;semantic_search 根本没有 --dry-run)。
942
+ */
943
+ function confirmPreview(what) {
944
+ return [
945
+ '还没执行——这只是一次**预览**(没有把任何内容发出去)。',
946
+ what,
947
+ '用户明确同意之后再带 `confirm: true` 调一次,才会真的执行。',
948
+ ].join(String.fromCharCode(10));
949
+ }
334
950
  export async function executeTool(name, args, ctx) {
335
951
  try {
336
952
  switch (name) {
@@ -347,24 +963,114 @@ export async function executeTool(name, args, ctx) {
347
963
  if (!contact)
348
964
  return '(缺少 contact 参数)';
349
965
  const limit = boundedToolInteger(args.limit, 20, 50);
966
+ // 时间窗:不给就是"最近 N 条"(老行为一个字没改)。给了就用范围读——
967
+ // **这正是"上周三他说了什么"以前够不着的原因**:只按条数取,越久远的日子越取不到,
968
+ // 模型要么说查不到,要么把最近的消息当成那天的。
969
+ let from;
970
+ let to;
971
+ try {
972
+ from = resolveSince(args.since);
973
+ to = resolveUntil(args.until);
974
+ }
975
+ catch {
976
+ return '(时间看不懂:since/until 可以写 2026-09-16 或 3d / 2w / 12h;收到的是'
977
+ + ` since=${JSON.stringify(args.since)} until=${JSON.stringify(args.until)})`;
978
+ }
979
+ const windowed = from !== undefined || to !== undefined;
980
+ const windowLabel = windowed
981
+ ? `${from ? new Date(from * 1000).toLocaleDateString('zh-CN') : '最早'}`
982
+ + ` 至 ${to ? new Date(to * 1000).toLocaleDateString('zh-CN') : '现在'}`
983
+ : '';
350
984
  const talker = await resolveTalker(contact);
351
- const msgs = await chatService.getMessages(talker, limit);
352
- if (!msgs.length)
353
- return `(没找到「${contact}」的消息)`;
354
- return msgs.map(m => {
355
- const body = privacyGate.maskMessageBody((m.content || m.parsedContent || '').replace(/\n/g, ' ').slice(0, 80));
356
- return `[${fmtTime(m.createTime)}] ${m.isSend ? '用户' : (m.senderUsername || '对方')}: ${body}`;
985
+ const msgs = windowed
986
+ ? await chatService.getMessagesInRange(talker, limit, from, to)
987
+ : await chatService.getMessages(talker, limit);
988
+ if (!msgs.length) {
989
+ return windowed
990
+ ? `(${windowLabel} 这段时间里没有「${contact}」的消息)`
991
+ : `(没找到「${contact}」的消息)`;
992
+ }
993
+ // 图片消息带一个句柄:`[图片 #1234]` 里的 1234 就是 `look_at_image` 的 `image` 参数。
994
+ // strict 模式下图片不允许出境,那时不带句柄——不摆出一个工具必定会拒绝的东西。
995
+ const canLook = privacyGate.mode() !== 'strict';
996
+ const rendered = msgs.map(m => {
997
+ // 非文本消息优先用 `parsedContent`:它是读取器给的**显示形态**(`[图片]`、
998
+ // `[文件] Base.csv`、`某人 撤回了一条消息`…),而 `content` 对这类消息可能是
999
+ // 原始 XML(含 md5 与 cdn 链接)——那是给机器看的,不该塞进模型上下文。
1000
+ const raw = m.localType === 1
1001
+ ? (m.content || m.parsedContent || '')
1002
+ : (m.parsedContent || m.content || '');
1003
+ // 每条 160 字。此前是 80,引用消息(`[引用] 回复 | 引:原文`)在 80 字里
1004
+ // 引文只剩七八个字,等于白带;长文本消息也被从中间切掉。上限仍是有界的:
1005
+ // limit 最多 50 条 × 约 180 字 ≈ 9k 字符。截断留省略号——切了却看起来像
1006
+ // 说完了,模型会把半句当整句。
1007
+ const flat = raw.replace(/\n/g, ' ');
1008
+ const body = canLook && m.localType === 3
1009
+ ? `[图片 #${m.localId}]`
1010
+ : privacyGate.maskMessageBody(clipWithMarker(flat, MSG_BODY_CHARS), { isText: m.localType === 1 });
1011
+ // 这一行原来用 `senderUsername`(**wxid 原列**),等于把账号标识交给云端模型。
1012
+ // 曾经以为"改成「对方」就分不清群聊里谁说的,是个真损失"——实测之后不成立:
1013
+ // `senderDisplay` 本来就有名字(见 `speakerLabel`),换成它两边都占,
1014
+ // 名字对模型还比 wxid 有用。自己发的仍是「用户」(这是对人说"你发了什么",
1015
+ // 与转录里的「我」是两种口吻,各自有测试钉着)。
1016
+ return `[${fmtTime(m.createTime)}] ${m.isSend ? '用户' : speakerLabel(m)}: ${body}`;
357
1017
  }).join('\n');
1018
+ // 有时间窗时先说清窗口:模型据此判断"这些是不是那天的",也免得它把窗口内的最后
1019
+ // 一条当成"最新的"。
1020
+ return windowed ? `(${windowLabel},共 ${msgs.length} 条)\n${rendered}` : rendered;
1021
+ }
1022
+ case 'look_at_image': {
1023
+ const contact = String(args.contact || '');
1024
+ // 句柄在 get_messages 里长这样:`[图片 #1234]`。允许带不带 `#` 都接受——模型从
1025
+ // 上下文里抄一个编号过来,"抄得像不像"不该决定它能不能看图。
1026
+ const image = String(args.image ?? '').replace(/^#/, '').trim();
1027
+ if (!contact || !image)
1028
+ return '(缺少 contact 或 image 参数)';
1029
+ if (!/^\d+$/.test(image)) {
1030
+ return `(image 要填 get_messages 里 [图片 #N] 的编号,收到的是「${image.slice(0, 20)}」)`;
1031
+ }
1032
+ // 第一层门:strict 模式(第三方聊天正文不出境)下图片也不出去。第二层在
1033
+ // `toApiMessages` 里——"工具肯给"与"这道门肯放"是两件事。
1034
+ if (privacyGate.mode() === 'strict' && !privacyGate.isLocalInference()) {
1035
+ return '(当前隐私模式是 strict:聊天图片不发出本机,用不了这个工具。'
1036
+ + '要让我看图,得把 assistantPrivacy 改成 balanced)';
1037
+ }
1038
+ const budget = ctx.pendingImages ?? [];
1039
+ if (budget.length >= MAX_IMAGES_PER_TURN) {
1040
+ return `(这一轮已经看过 ${MAX_IMAGES_PER_TURN} 张图了,先把已经看到的说完,别再取)`;
1041
+ }
1042
+ const talker = await resolveTalker(contact);
1043
+ // 90 秒。建媒体索引是这条路唯一的成本,且随会话大小走:实测单聊 ~1 秒、
1044
+ // 一个 3 万条消息的大群 18.9 秒(而那个群本机只有缩略图)。取到的图会落在
1045
+ // `output/.cache/read-image/`,同一张再看是 0.1 秒。超时是必要的兜底——助手是
1046
+ // **串行**处理消息的,一次卡住会挡住后面所有人的话,所以宁可说"取图失败"。
1047
+ const r = await runPythonJson('read_image.py', ['--talker', talker, '--local-id', image, '--json'], { timeoutMs: 90_000 });
1048
+ if (!r.ok)
1049
+ return fail('取图失败', r);
1050
+ if (!r.data?.b64) {
1051
+ // 文字说明而不是空:本机没有这张图的副本(缓存清过、或只有缩略图)是常态
1052
+ return `(没取到 #${image} 的图片:${r.data?.reason || '本机没有这张图的副本'})`;
1053
+ }
1054
+ // 拿到图才建数组:`pendingImages` 为 undefined 与"空数组"在下游是同一个意思,
1055
+ // 但只有前者能一眼看出"这一轮没图"。
1056
+ ;
1057
+ (ctx.pendingImages ??= []).push({
1058
+ b64: r.data.b64, mime: r.data.mime || 'image/jpeg', localId: Number(image)
1059
+ });
1060
+ const size = r.data.width && r.data.height ? `${r.data.width}×${r.data.height}` : '尺寸未知';
1061
+ return `(已附上图片 #${image}(${size})。你能看到它了,直接据此回答。)`;
358
1062
  }
359
1063
  case 'search_favorites': {
360
1064
  await ensureDb();
1065
+ // 不给关键词就是"最近收藏了什么"——这一问在聊天里很自然,没理由逼调用方编一个词
361
1066
  const keyword = String(args.keyword || '');
362
- if (!keyword)
363
- return '(缺少 keyword 参数)';
364
- const limit = boundedToolInteger(args.limit, 8, 15);
365
- const r = await chatService.getFavorites({ keyword, limit });
1067
+ const limit = boundedToolInteger(args.limit, keyword ? 8 : 15, keyword ? 15 : 30);
1068
+ const r = keyword ? await chatService.getFavorites({ keyword, limit })
1069
+ : await chatService.getFavorites({ limit });
366
1070
  if (!r.success || !r.favorites?.length) {
367
- return r.error ? `(查询失败: ${r.error})` : `(收藏中未搜到「${keyword}」)`;
1071
+ if (r.error)
1072
+ return `(查询失败: ${r.error})`;
1073
+ return keyword ? `(收藏中未搜到「${keyword}」)` : '(收藏是空的,或收藏库还没连上)';
368
1074
  }
369
1075
  return `共${r.total}条, 前${r.favorites.length}条:\n` +
370
1076
  r.favorites.map(f => {
@@ -462,6 +1168,22 @@ export async function executeTool(name, args, ctx) {
462
1168
  }
463
1169
  case 'get_sns': {
464
1170
  await ensureDb();
1171
+ if (String(args.mode || 'timeline') === 'users') {
1172
+ // 谁常发朋友圈:本地把最近的时间线按发帖人聚合。**不新增出境**——数据本来就在这一条工具里。
1173
+ const r = await chatService.getSnsTimeline({ limit: 200 });
1174
+ if (!r.success || !r.timeline?.length) {
1175
+ return r.error ? `(朋友圈查询失败: ${r.error})` : '(朋友圈暂无缓存数据)';
1176
+ }
1177
+ const byAuthor = new Map();
1178
+ for (const post of r.timeline) {
1179
+ const who = String(post.nickname || post.username || '未知');
1180
+ byAuthor.set(who, (byAuthor.get(who) ?? 0) + 1);
1181
+ }
1182
+ const ranked = [...byAuthor.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10);
1183
+ const nl = String.fromCharCode(10);
1184
+ return `最近 ${r.timeline.length} 条朋友圈里,发得最多的:`
1185
+ + ranked.map(([who, n]) => `${nl}· ${who}:${n} 条`).join('');
1186
+ }
465
1187
  if (String(args.mode || 'timeline') === 'stats') {
466
1188
  const r = await chatService.getSnsExportStats();
467
1189
  if (!r.success)
@@ -516,39 +1238,324 @@ export async function executeTool(name, args, ctx) {
516
1238
  }
517
1239
  case 'get_todos': {
518
1240
  const status = String(args.status || 'pending');
519
- try {
520
- const { getPythonCommand } = await import('../utils/python.js');
521
- const py = await getPythonCommand();
522
- const { stdout } = await execFileAsync(py, [join(PKG_ROOT, 'scripts', 'extract_todos.py'), 'list', '--status', status, '--json'], {
523
- timeout: 30_000,
524
- env: createPythonProcessEnv(),
525
- });
526
- const todos = JSON.parse(stdout.trim());
527
- if (!Array.isArray(todos) || !todos.length)
528
- return `(没有${status === 'done' ? '已完成' : '待办'}任务)`;
529
- return `${status === 'done' ? '已完成' : '待办'} ${todos.length} 项:\n` +
530
- todos.slice(0, 15).map((t) => `· [${t.urgency || '中'}] ${t.task || t.content || t.text || t.title}${t.deadline && t.deadline !== '未提及' ? ` (截止 ${t.deadline})` : ''}`).join('\n');
1241
+ // 走 pythonBridge(唯一的脚本调用入口):失败原因分三类报出来,不解析人类可读文本。
1242
+ // `--meta` 是为了分清"没有待办"与"从没提取过"——两者在 `--json` 下都是空数组,
1243
+ // 而把后者说成前者,用户会以为自己真的没事要做。MCP 侧一直分得开(见 mcp_bridge.py),
1244
+ // 这条工具此前没分。`--meta` 是新增开关,老形状(裸数组)原样保留给别的调用方。
1245
+ const result = await runPythonJson('extract_todos.py', ['list', '--status', status, '--json', '--meta']);
1246
+ if (!result.ok)
1247
+ return fail('待办查询失败', result);
1248
+ // 兼容老形状:万一是没有 --meta 的实现,拿到的是数组,就退回原来的说法。
1249
+ const meta = result.data && !Array.isArray(result.data) ? result.data : null;
1250
+ const todos = meta ? (Array.isArray(meta.items) ? meta.items : []) : (Array.isArray(result.data) ? result.data : []);
1251
+ if (!todos.length) {
1252
+ if (meta && meta.extracted === false) {
1253
+ return '(还没提取过待办——待办是从聊天记录里提取的,要用 `weflow-cli todos extract --days 7 --yes` 先跑一次;'
1254
+ + '在那之前这份清单一直是空的,不代表你没有事要做)';
1255
+ }
1256
+ return `(没有${status === 'done' ? '已完成' : '待办'}任务)`;
1257
+ }
1258
+ return `${status === 'done' ? '已完成' : '待办'} ${todos.length} 项:\n` +
1259
+ todos.slice(0, 15).map((t) => `· [${t.urgency || '中'}] ${t.task || t.content || t.text || t.title}${t.deadline && t.deadline !== '未提及' ? ` (截止 ${t.deadline})` : ''}`).join('\n');
1260
+ }
1261
+ case 'search_chats': {
1262
+ // 跨会话检索:本机 message_fts 索引 + 判断模型挑查询词(**只发候选词与问题,不发聊天内容**)
1263
+ const question = String(args.question || '').trim();
1264
+ if (!question)
1265
+ return '(缺少 question 参数)';
1266
+ const perCard = boundedToolInteger(args.per_card, 3, 10, 'per_card');
1267
+ // 外部机器默认只给预览,**按入参说清**。这里要说清的恰好是最要紧的那件事:
1268
+ // 这条路上出去的是"你的问题 + 从聊天里提取的候选词",**不含消息正文**(排序在本地做)。
1269
+ if (ctx.requiresConfirm && args.confirm !== true) {
1270
+ return confirmPreview(`会把你的问题「${question.slice(0, 40)}」与从聊天里提取的候选词`
1271
+ + `(最多 ${perCard} 个会话各若干词,**不含消息正文**)发给判断模型 Jev,`
1272
+ + '让它挑出你真正在找的词;排序在本地做。');
1273
+ }
1274
+ const result = await runPythonJson('route_cards.py', ['ask', question, '--yes', '--json', '--per-card', String(perCard)], { timeoutMs: 90_000 });
1275
+ if (!result.ok)
1276
+ return fail('会话检索失败', result);
1277
+ const ranked = Array.isArray(result.data?.ranked) ? result.data.ranked : [];
1278
+ if (!ranked.length) {
1279
+ return `(没有会话字面命中「${question}」——检索只匹配字面词,换几个词再试)`;
1280
+ }
1281
+ const lines = [`命中 ${ranked.length} 个会话(查询词:${(result.data.terms || []).join('、')}):`];
1282
+ for (const card of ranked.slice(0, 6)) {
1283
+ lines.push(`· #${card.id} [${card.kind}] ${card.label}(该会话 ${card.messages} 条消息,最后活动 ${card.lastDaysAgo} 天前)`);
1284
+ const rows = result.data?.messages?.[String(card.id)] || [];
1285
+ for (const row of rows.slice(0, perCard)) {
1286
+ // 第三方聊天正文与 get_messages **同一条纪律**:出境前按隐私档位处理
1287
+ const body = privacyGate.maskMessageBody(String(row?.text || '').replace(/\s+/g, ' ').slice(0, 60));
1288
+ lines.push(` ${body}`);
1289
+ }
1290
+ }
1291
+ return lines.join('\n');
1292
+ }
1293
+ case 'who_owes_reply': {
1294
+ // 谁在等我回话:逐会话问一次判断模型(较慢)。**不给正文**——真要看他写了什么,
1295
+ // 用 get_messages 单独查,那条路有完整的隐私处理。
1296
+ const days = boundedToolInteger(args.days, 14, 60, 'days');
1297
+ // 外部机器(MCP)默认只给预览:这个工具**每个会话一次判断调用**,每一次都带着那段聊天正文,
1298
+ // 是这条路上最贵也最"出境"的一个。预览走脚本自己的 --dry-run(只读本地、零出境)。
1299
+ if (ctx.requiresConfirm && args.confirm !== true) {
1300
+ const preview = await runPythonJson('reply_debt.py', ['--days', String(days), '--dry-run', '--json'], { timeoutMs: 60_000 });
1301
+ if (!preview.ok || !preview.data?.success) {
1302
+ return `(预览失败:${String(preview.data?.error || preview.error || '未知').slice(0, 120)})`;
1303
+ }
1304
+ const p = preview.data;
1305
+ return confirmPreview(`会把最近 ${days} 天里 ${p.conversations} 个有动静的会话、`
1306
+ + `约 ${p.stateChars} 字符聊天正文,**逐个**发给判断模型(api.typesafe.ai),每会话一次请求。`);
531
1307
  }
532
- catch (error) {
533
- return `(${safeSubprocessError(error, '待办查询失败')})`;
1308
+ const result = await runPythonJson('reply_debt.py', ['--days', String(days), '--json'], { timeoutMs: 180_000 });
1309
+ if (!result.ok)
1310
+ return fail('欠账查询失败', result);
1311
+ const debts = Array.isArray(result.data?.debts) ? result.data.debts : [];
1312
+ if (!debts.length)
1313
+ return `(最近 ${days} 天没有明显在等你回话的会话)`;
1314
+ const lines = [`在等你回话的 ${debts.length} 个会话(最近 ${days} 天):`];
1315
+ for (const row of debts.slice(0, 8)) {
1316
+ const prob = Number(row.waiting ?? 0);
1317
+ const urgency = row.urgencyScore === null || row.urgencyScore === undefined ? '' : ` · 紧急度 ${row.urgencyScore}`;
1318
+ lines.push(`· ${row.name}(等了 ${row.days} 天 · 概率 ${prob.toFixed(2)}${urgency}${row.kind ? ' · ' + row.kind : ''})`);
534
1319
  }
1320
+ lines.push('(想看某人具体说了什么,用 get_messages 单独查;这里只报谁在等。)');
1321
+ return lines.join('\n');
1322
+ }
1323
+ case 'draft_reply': {
1324
+ // 起草:判断(Jev)→ 起草(DeepSeek)→ 排序(Jev)。**只产出文本,绝不发送**——
1325
+ // 仓库里"发送"本来就不可达(TOOL_DEFS 没有任何 send 类工具),这里也不新增。
1326
+ const contact = String(args.contact || '');
1327
+ if (!contact)
1328
+ return '(缺少 contact 参数)';
1329
+ const count = boundedToolInteger(args.count, 3, 5, 'count');
1330
+ // strict 下**直接拒绝**,而不是把遮成"内容 N 字"的正文拿去起草——那会得到一段
1331
+ // 基于空白的、看起来正常的回复,比拒绝更糟(同 `look_at_image` 的处置)。
1332
+ //
1333
+ // 这里**不看 `isLocalInference()`**,与 `look_at_image` 刻意不同:那张图确实送到
1334
+ // 本机模型,所以"本机推理"在那里是成立的旁路;而起草出境的是**脚本自己的**两条远程
1335
+ // 调用(`jev_client` 走 HTTP、`_utils.call_deepseek` 写死 DeepSeek),跟 `aiEngine`
1336
+ // 配成什么毫无关系。照抄那个旁路=用"我配了本地模型"当理由,把正文发到远端去。
1337
+ // 这条是实测出来的:aiEngine=ollama + strict 下,原实现真的把对话交给了脚本。
1338
+ if (privacyGate.mode() === 'strict') {
1339
+ return '(当前隐私模式是 strict:聊天正文不出境,而起草必须把正文发给远端模型'
1340
+ + '(判断一步、生成一步),这一步用本机推理也绕不开。'
1341
+ + '在电脑上 `weflow-cli config set assistantPrivacy balanced`,或用本机命令 '
1342
+ + '`weflow-cli draft <会话>`。)';
1343
+ }
1344
+ const talker = await resolveTalker(contact);
1345
+ const msgs = await chatService.getMessages(talker, TRANSCRIPT_MESSAGES);
1346
+ if (!msgs.length)
1347
+ return `(没找到「${contact}」的消息)`;
1348
+ // **外部机器(MCP)默认只给预览,不做出境。** 与 CLI 的 `--dry-run` / `--yes` 同一套纪律:
1349
+ // 预览只读本地库、报清要发多少字符给哪两个模型,零出境。
1350
+ // 面板/微信那条路(`requiresConfirm` 不置位)不受影响——它那边的边界是白名单 + 用户
1351
+ // 在一个只有自己看得见的会话里显式开口问。
1352
+ if (ctx.requiresConfirm && args.confirm !== true) {
1353
+ const preview = await runPythonJson('draft_reply.py', ['--talker', talker, '--dry-run', '--json', '--count', String(count)], { timeoutMs: 60_000 });
1354
+ if (!preview.ok || !preview.data?.success) {
1355
+ return `(起草预览失败:${String(preview.data?.error || preview.error || '未知').slice(0, 120)})`;
1356
+ }
1357
+ const d = preview.data;
1358
+ return confirmPreview('会把「' + d.name + '」最近的 ' + d.messages + ' 条消息、共 '
1359
+ + d.stateChars + ' 字符,发给 ' + (d.models?.judge || '判断模型') + ' 与 '
1360
+ + (d.models?.draft || '生成模型') + '(' + (d.calls || '3 次调用') + '),产出 '
1361
+ + d.count + ' 条候选。'
1362
+ // 前提说明也在出境的那一段里(它会进两段提示词),所以预览要把它算进去
1363
+ + (d.lastFromMe ? '另外会附一句「最后一条是我自己发的」的前提说明。' : ''));
1364
+ }
1365
+ // 逐条遮罩**再**交给脚本:Python 侧没有任何脱敏实现(全仓只有两处无关的位掩码),
1366
+ // 而脱敏这一层在 TS。**不在 Python 里再写一份**——同一件事两处实现早晚有一处会漏。
1367
+ //
1368
+ // 说明白:上面的门**现在**已经把 strict 全挡了,所以这里的 `maskMessageBody`
1369
+ // 在当下打不到(balanced/open 不改正文)。留着它是因为它守的是另一条不变式:
1370
+ // "交给脚本的正文一定先过一道遮罩",谁将来松开了上面那道门,这道还在。
1371
+ const transcript = msgs.slice().reverse().map(m => transcriptLine(m));
1372
+ const result = await runPythonJson('draft_reply.py', ['--stdin', '--yes', '--json', '--count', String(count)],
1373
+ // `lastFromMe`:`transcript` 是 `msgs` **反过来**渲染的,所以最后一条 = `msgs[0]`。
1374
+ // 用结构化字段 `isSend`,不去解析那行文本(群聊里别人的标签是人名,不是「对方」)。
1375
+ // 这段是说给脚本听的**前提**:最后一条是我发的时,"回答对方最后一句"这件事根本不存在。
1376
+ { stdin: JSON.stringify({ name: contact, lines: transcript, lastFromMe: !!msgs[0]?.isSend }),
1377
+ timeoutMs: 240_000 });
1378
+ if (!result.ok)
1379
+ return fail('起草失败', result);
1380
+ const data = result.data ?? {};
1381
+ if (!data.success)
1382
+ return `(起草没跑通: ${String(data.error || '未知').slice(0, 120)})`;
1383
+ const picked = Array.isArray(data.drafts) ? data.drafts : [];
1384
+ // 脚本给的前提说明(仅当最后一条是我发的)。**用户也要看见**:否则候选看起来像在回
1385
+ // 一句根本不存在的话。三条出口(降级/被闸门拦下/正常)都要带上它。
1386
+ const premise = typeof data.premise === 'string' ? data.premise.trim() : '';
1387
+ // **降级那次(Jev 没调通)必须说清两件事**:这次没有判断,以及那道闸门没生效。
1388
+ // 闸门(涉钱/高风险不给草稿)正是靠判断结果才存在的——不标出来,降级看起来就跟
1389
+ // "判断过了"一模一样。**不以 `(` 开头**:那个前缀在本仓库表示"工具没产出内容"。
1390
+ if (data.judged === false) {
1391
+ if (!picked.length)
1392
+ return `(起草没给出候选:${String(data.judgeNote || '判断与生成都没跑通').slice(0, 120)})`;
1393
+ const lines = [...(premise ? [premise, ''] : []),
1394
+ `这次**没有判断**——${String(data.judgeNote || '判断那一步没调通')}。`,
1395
+ `所以"涉钱/风险高就不给草稿"那道闸门**没生效**,下面 ${picked.length} 条是生成模型`
1396
+ + `按上下文给的,你自己看一眼:`, ''];
1397
+ picked.forEach((draft, index) => { lines.push(`${index + 1}. ${draft.text}`); });
1398
+ lines.push('');
1399
+ lines.push('(这些只是文本,没有发送任何东西——回不回、怎么回由你决定。)');
1400
+ return lines.join('\n');
1401
+ }
1402
+ const judgment = data.judgment ?? {};
1403
+ const why = `对方${zhLabel(INTENT_LABELS, judgment.intent)}`
1404
+ + `;建议${zhLabel(ACTION_LABELS, judgment.action)}`
1405
+ + `;风险 ${Number(judgment.risk ?? 0).toFixed(1)}/9 ${riskLabel(judgment.risk)}`;
1406
+ // 被闸门拦下:**不是失败**,是这条最该给的回答。所以不以 `(` 起头
1407
+ // (那个前缀在本仓库里表示"工具没产出内容",见 `producedContent`)。
1408
+ if (data.gate === 'refused') {
1409
+ return [...(premise ? [premise, ''] : []),
1410
+ '不起草。', String(data.reason || ''), '',
1411
+ '建议先:', ...(data.advice ?? []).map((line) => `· ${line}`),
1412
+ '', `(判断:${why}。这些只是文本,没有发送任何东西。)`].join('\n');
1413
+ }
1414
+ const drafts = Array.isArray(data.drafts) ? data.drafts : [];
1415
+ if (!drafts.length)
1416
+ return '(起草没给出候选)';
1417
+ const lines = [...(premise ? [premise, ''] : []),
1418
+ `建议这样回(${drafts.length} 条,第一条是判断最合适的):`, ''];
1419
+ drafts.forEach((draft, index) => { lines.push(`${index + 1}. ${draft.text}`); });
1420
+ lines.push('');
1421
+ lines.push(`(判断:${why}。这些只是文本,没有发送任何东西——回不回、怎么回由你决定。)`);
1422
+ return lines.join('\n');
1423
+ }
1424
+ case 'search_semantic': {
1425
+ // 语义/同义检索(向量相似度 + 决策模型重排)。字面词搜不到时用这条。
1426
+ // **出境说明**:查询词要发阿里云百炼做嵌入,候选片段要发判断模型重排——
1427
+ // 两者都在仓库既有的云端路径上(不是新类别),但这里如实写出来。
1428
+ const query = String(args.query || '').trim();
1429
+ if (!query)
1430
+ return '(缺少 query 参数)';
1431
+ const topK = boundedToolInteger(args.top_k, 8, 20, 'top_k');
1432
+ // 外部机器默认只给预览(`semantic_search.py` 没有 --dry-run,按入参说清)
1433
+ if (ctx.requiresConfirm && args.confirm !== true) {
1434
+ return confirmPreview(`会把查询词「${query.slice(0, 40)}」发给阿里云百炼做嵌入,`
1435
+ + `再把前 ${topK} 条命中的本地片段(可能含聊天或文章原文)发给判断模型 Jev 重排。`);
1436
+ }
1437
+ // **查询词走环境变量、不进 argv**:仓库写进测试的隐私纪律(进程列表里看不到正文)
1438
+ const result = await runPythonJson('semantic_search.py', ['search', '--top-k', String(topK)], { env: { WEFLOW_SEARCH_QUERY: query }, timeoutMs: 90_000 });
1439
+ if (!result.ok)
1440
+ return fail('语义检索失败(索引可能还没建:weflow-cli search-index)', result);
1441
+ const rows = Array.isArray(result.data) ? result.data : [];
1442
+ if (!rows.length)
1443
+ return `(语义检索没有结果「${query}」)`;
1444
+ return `语义检索「${query}」前 ${rows.length} 条:` + String.fromCharCode(10) + rows.map((row) => {
1445
+ const title = String(row.title || row.source || '(无标题)').slice(0, 40);
1446
+ const score = typeof row.score === 'number' ? `(${row.score.toFixed(2)})` : '';
1447
+ const text = privacyGate.maskMessageBody(String(row.text || row.content || '').replace(/\s+/g, ' ').slice(0, 80));
1448
+ const nl = String.fromCharCode(10);
1449
+ return `· ${title}${score}${text ? nl + ' ' + text : ''}`;
1450
+ }).join(String.fromCharCode(10));
1451
+ }
1452
+ case 'export_chat': {
1453
+ // **写操作**,边界写死:只往 output/exports/ 下**新建**目录(名字带时间戳),绝不覆盖;
1454
+ // 路径由这里拼,模型给不了任意路径。用户是在对话里明确要求的,这就是那次确认。
1455
+ const contact = String(args.contact || '').trim();
1456
+ if (!contact)
1457
+ return '(缺少 contact 参数)';
1458
+ const limit = boundedToolInteger(args.limit, 500, 5000, 'limit');
1459
+ const format = String(args.format || 'html').toLowerCase();
1460
+ if (!['html', 'txt', 'json', 'excel'].includes(format)) {
1461
+ return `(参数错误: format 只能是 html/txt/json/excel,收到 ${format.slice(0, 12)})`;
1462
+ }
1463
+ const talker = await resolveTalker(contact);
1464
+ const safeName = contact.replace(/[\/:*?"<>|]/g, '_').slice(0, 20) || 'chat';
1465
+ const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 12);
1466
+ // 目录名到秒,同一秒里导两次会撞进同一个目录——那就不是"绝不覆盖"了。撞了就往后加序号。
1467
+ // 导出根目录可用环境变量改(测试用临时目录;也方便自定义)。默认在仓库的 output/exports 下。
1468
+ const exportRoot = process.env.WEFLOW_ASSISTANT_EXPORT_ROOT || join(PKG_ROOT, 'output', 'exports');
1469
+ let outDir = join(exportRoot, `${safeName}-${stamp}`);
1470
+ for (let n = 2; existsSync(outDir) && n < 100; n++) {
1471
+ outDir = join(exportRoot, `${safeName}-${stamp}-${n}`);
1472
+ }
1473
+ const result = format === 'txt' ? await exportService.exportTxt(talker, outDir, limit)
1474
+ : format === 'json' ? await exportService.exportJson(talker, outDir, limit)
1475
+ : format === 'excel' ? await exportService.exportExcel(talker, outDir, limit)
1476
+ : await exportService.exportHtml(talker, outDir, limit, '', undefined, undefined, true);
1477
+ if (!result.success)
1478
+ return `(导出失败: ${String(result.error || '未知').slice(0, 100)})`;
1479
+ const suffix = format === 'html' ? ',含图片' : '';
1480
+ // 报**真的写到了哪**:`outDir` 才是(撞名之后会带 `-2`),而事前拼的 `safeName-stamp`
1481
+ // 两次导出会报成同一个名字——实测第二次明明是 `…-2`,消息里却是基础名(2026-09-23 验收)。
1482
+ // 根目录是默认值时按仓库相对路径报(用户照着找得到);自定义根时只报目录名——
1483
+ // **不把绝对路径发进聊天**,那是本机路径,没必要出境。
1484
+ const shown = exportRoot === join(PKG_ROOT, 'output', 'exports')
1485
+ ? `output/exports/${basename(outDir)}`
1486
+ : basename(outDir);
1487
+ return `已导出 ${result.count ?? 0} 条消息到 ${shown}/(${format}${suffix})`;
535
1488
  }
536
1489
  case 'search_knowledge': {
537
1490
  const kw = String(args.keyword || '');
538
1491
  if (!kw)
539
1492
  return '(缺少 keyword 参数)';
540
- if (!existsSync(VAULT_WIKI_DIR))
1493
+ const liveDirs = VAULT_WIKI_DIRS.filter(dir => existsSync(dir));
1494
+ if (!liveDirs.length)
541
1495
  return '(知识库尚未生成, 先运行 weflow-cli wiki compile)';
542
- const files = readdirSync(VAULT_WIKI_DIR).filter(f => f.endsWith('.md'));
543
- const hits = files.filter(f => f.replace('.md', '').toLowerCase().includes(kw.toLowerCase()));
1496
+ // **两个目录一起搜**(文章知识库 + 聊天知识库)。分开是用户要求的,但"分开"
1497
+ // 不该变成"搜不到"——只读一个的话,另一半会静默缺席。
1498
+ const entries = [];
1499
+ let total = 0;
1500
+ for (const dir of liveDirs) {
1501
+ // 标注来源,好让模型(和读日志的人)知道这条来自哪个知识库
1502
+ const where = /[\\/]Chat[\\/]/.test(dir) ? '聊天' : '文章';
1503
+ for (const file of readdirSync(dir).filter(f => f.endsWith('.md'))) {
1504
+ entries.push({ dir, file, where });
1505
+ total += 1;
1506
+ }
1507
+ }
1508
+ const hits = entries.filter(e => e.file.replace('.md', '').toLowerCase().includes(kw.toLowerCase()));
544
1509
  if (!hits.length) {
545
- const contentHits = files.filter(f => readFileSync(join(VAULT_WIKI_DIR, f), 'utf8').toLowerCase().includes(kw.toLowerCase())).slice(0, 5);
1510
+ // **只搜正文,不搜「来源」段。** 页面的来源段可能列 300+ 条文章标题(2026-09-27 那批
1511
+ // 改动之后就是这样),拿整个文件做包含判断,会把「某篇来源的**标题**里含这个词」报成
1512
+ // 「**正文**提及」—— 实测 `Grok-4` 命中 4 张页、其中 3 张的正文里根本没有它。
1513
+ // `assistant-eval` 的 `knowledge-fallback-not-body` 钉住这件事。
1514
+ //
1515
+ // 顺带把**那一句**给出去:只给页名的话模型会自己补内容(实测它补出过「Grok-4 是作为
1516
+ // 被测试的模型之一出现的」,那是它从别处知道的,不是这一页里写的)。
1517
+ const contentHits = entries.map(e => {
1518
+ const text = pageBody(readFileSync(join(e.dir, e.file), 'utf8'));
1519
+ const at = text.toLowerCase().indexOf(kw.toLowerCase());
1520
+ if (at === -1)
1521
+ return null;
1522
+ const line = text.slice(Math.max(0, at - 80), at + 160)
1523
+ .split('\n').find(l => l.includes(kw)) || '';
1524
+ return { ...e, snippet: line.trim().slice(0, 180) };
1525
+ }).filter((e) => e !== null)
1526
+ .slice(0, 5);
546
1527
  if (!contentHits.length)
547
- return `(知识库未收录「${kw}」, 共 ${files.length} 个概念页)`;
548
- return `正文提及「${kw}」的概念页:\n` + contentHits.map(f => `· ${f.replace('.md', '')}`).join('\n');
1528
+ return `(知识库未收录「${kw}」, 共 ${total} 个概念页)`;
1529
+ return '正文提及「' + kw + '」的概念页:' + '\n'
1530
+ + contentHits.map(e => `· ${e.file.replace('.md', '')}(${e.where}): ${e.snippet}`).join('\n');
1531
+ }
1532
+ const hit = hits[0];
1533
+ const name = hit.file.replace('.md', '');
1534
+ const page = readFileSync(join(hit.dir, hit.file), 'utf8');
1535
+ // **顺带把邻居给出去**(见 `conceptNeighbors`):一次调用拿到"这个概念 + 连着它的一小块子图"。
1536
+ // 不点明"可以再用本工具查其中任意一个"的话,模型会把它当装饰性文字读过去。
1537
+ const depth = boundedToolInteger(args.depth, 1, 3, 'depth');
1538
+ const { levels, truncated, missing } = conceptSubgraph(name, depth);
1539
+ const out = [`「${name}」概念页(${hit.where}知识库):`, page.slice(0, 2000)];
1540
+ if (levels.length) {
1541
+ out.push('', depth > 1
1542
+ ? `—— 它周围的概念(${depth} 跳;「←」标出是从哪个概念连过来的)——`
1543
+ : '—— 它连到的概念(可以再用本工具查其中任意一个,或者加 depth 参数一次多看几跳)——');
1544
+ levels.forEach((level, index) => {
1545
+ if (levels.length > 1)
1546
+ out.push(`第 ${index + 1} 跳:`);
1547
+ for (const nb of level) {
1548
+ out.push(depth > 1 ? `· ${nb.name} ← ${nb.via} — ${nb.brief}` : `· ${nb.name} — ${nb.brief}`);
1549
+ }
1550
+ });
1551
+ // 封顶了要**说出来**:默默少给的话,模型会以为这就是全部
1552
+ if (truncated)
1553
+ out.push(`(图太大,只给了前 ${levels.flat().length} 个概念;
1554
+ 想看某个分支可以单独查它)`);
549
1555
  }
550
- const page = readFileSync(join(VAULT_WIKI_DIR, hits[0]), 'utf8');
551
- return `「${hits[0].replace('.md', '')}」概念页:\n${page.slice(0, 2000)}`;
1556
+ if (missing)
1557
+ out.push(`(另有 ${missing} 个相关概念尚未建页,查不到)`);
1558
+ return out.join('\n');
552
1559
  }
553
1560
  case 'get_stats': {
554
1561
  await ensureDb();
@@ -556,6 +1563,30 @@ export async function executeTool(name, args, ctx) {
556
1563
  const fav = await chatService.getFavorites({ limit: 1 });
557
1564
  return `会话数: ${sessions.length}\n收藏总数: ${fav.success ? fav.total : '未知'}`;
558
1565
  }
1566
+ case 'get_reading_stats': {
1567
+ const days = boundedToolInteger(args.days, 7, 90, 'days');
1568
+ const r = await runPythonJson('daily_stats.py', ['--days', String(days), '--json'], { timeoutMs: 60_000 });
1569
+ if (!r.ok)
1570
+ return fail('读取公众号统计失败', r);
1571
+ const rows = r.data?.sources ?? [];
1572
+ if (!rows.length)
1573
+ return `(最近 ${days} 天没有公众号推送记录)`;
1574
+ const period = r.data?.period ?? {};
1575
+ const byPushed = [...rows].sort((a, b) => (b.pushed ?? 0) - (a.pushed ?? 0)).slice(0, 5);
1576
+ const lines = [`最近 ${days} 天(${period.start} 至 ${period.end}):${rows.length} 个公众号有推送`];
1577
+ lines.push('发得最多:' + byPushed.map(x => `${x.name}(${x.pushed})`).join('、'));
1578
+ const processed = rows.filter(x => (x.processed ?? 0) > 0)
1579
+ .sort((a, b) => (b.processed ?? 0) - (a.processed ?? 0)).slice(0, 5);
1580
+ if (processed.length) {
1581
+ lines.push('日报处理得最多:' + processed.map(x => `${x.name}(${x.processed})`).join('、'));
1582
+ }
1583
+ else {
1584
+ // **直说日报没在跑**:这时"处理数 0"不是"这些号没内容",而是日报压根没运行。
1585
+ // 把它混成"没读到东西",用户会去查号,而该查的是日报任务。
1586
+ lines.push(`这 ${days} 天日报没有任何处理记录` + lastDailyNote());
1587
+ }
1588
+ return lines.join('\n');
1589
+ }
559
1590
  case 'search_memory': {
560
1591
  const hits = ctx.memory.searchFacts(ctx.userId, String(args.keyword || ''));
561
1592
  if (!hits.length)