weflow-cli 1.7.0 → 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 (194) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +1949 -1
  3. package/OPERATIONS.md +774 -1
  4. package/README.en.md +93 -7
  5. package/README.md +31 -6
  6. package/bin/weflow-cli.ts +934 -27
  7. package/dist/bin/weflow-cli.js +952 -27
  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 +19 -2
  16. package/dist/src/core/ntCore.d.ts.map +1 -1
  17. package/dist/src/core/ntCore.js +11 -1
  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 +1 -1
  80. package/dist/src/services/chatService.d.ts.map +1 -1
  81. package/dist/src/services/chatService.js +5 -2
  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/pythonBridge.d.ts +41 -0
  88. package/dist/src/services/pythonBridge.d.ts.map +1 -0
  89. package/dist/src/services/pythonBridge.js +119 -0
  90. package/dist/src/services/pythonBridge.js.map +1 -0
  91. package/dist/src/services/syncService.d.ts +8 -2
  92. package/dist/src/services/syncService.d.ts.map +1 -1
  93. package/dist/src/services/syncService.js +26 -7
  94. package/dist/src/services/syncService.js.map +1 -1
  95. package/dist/src/services/wechatMessageService.d.ts +25 -0
  96. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  97. package/dist/src/services/wechatMessageService.js +69 -7
  98. package/dist/src/services/wechatMessageService.js.map +1 -1
  99. package/dist/src/utils/atomicWrite.d.ts +4 -0
  100. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  101. package/dist/src/utils/atomicWrite.js +60 -0
  102. package/dist/src/utils/atomicWrite.js.map +1 -0
  103. package/dist/src/utils/dateRange.d.ts +15 -0
  104. package/dist/src/utils/dateRange.d.ts.map +1 -1
  105. package/dist/src/utils/dateRange.js +47 -0
  106. package/dist/src/utils/dateRange.js.map +1 -1
  107. package/dist/src/utils/text.d.ts +18 -0
  108. package/dist/src/utils/text.d.ts.map +1 -0
  109. package/dist/src/utils/text.js +20 -0
  110. package/dist/src/utils/text.js.map +1 -0
  111. package/docs/AI_INTERFACE.md +2 -0
  112. package/docs/DECISIONS.md +1424 -0
  113. package/docs/EXTENDING.md +187 -0
  114. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  115. package/docs/MCP.md +23 -4
  116. package/docs/PROJECT_STATE.md +299 -98
  117. package/docs/SETUP.md +22 -0
  118. package/docs/SYNC_CONTRACT.md +31 -3
  119. package/mcp-server/index.ts +681 -657
  120. package/package.json +84 -83
  121. package/resources/panel/ball-position.cjs +153 -0
  122. package/resources/panel/index.html +46 -0
  123. package/resources/panel/main.cjs +447 -0
  124. package/resources/panel/mascot-focus.png +0 -0
  125. package/resources/panel/mascot-happy.png +0 -0
  126. package/resources/panel/mascot-sorry.png +0 -0
  127. package/resources/panel/mascot-tired.png +0 -0
  128. package/resources/panel/mascot.png +0 -0
  129. package/resources/panel/package.json +7 -0
  130. package/resources/panel/panel.css +407 -0
  131. package/resources/panel/preload.cjs +72 -0
  132. package/resources/panel/quick-menu.cjs +197 -0
  133. package/resources/panel/renderer.js +441 -0
  134. package/resources/panel/tray-menu.cjs +40 -0
  135. package/resources/panel/tray.png +0 -0
  136. package/scripts/_utils.py +403 -54
  137. package/scripts/annual_report.py +4 -2
  138. package/scripts/article_notes.py +423 -0
  139. package/scripts/backfill_articles.py +438 -0
  140. package/scripts/biz_daily.py +590 -105
  141. package/scripts/chat_notes.py +470 -0
  142. package/scripts/classify_daily.py +10 -18
  143. package/scripts/compile_wiki.py +1059 -46
  144. package/scripts/create_reading_notes.py +199 -14
  145. package/scripts/decide.py +253 -0
  146. package/scripts/draft_reply.py +671 -0
  147. package/scripts/export_chat_html.py +35 -33
  148. package/scripts/extract_todos.py +31 -5
  149. package/scripts/fav_notes.py +318 -0
  150. package/scripts/fix_topics.py +2 -1
  151. package/scripts/generate_ai_report.py +214 -9
  152. package/scripts/generate_html.py +18 -3
  153. package/scripts/generate_review.py +2 -2
  154. package/scripts/jev_client.py +294 -0
  155. package/scripts/jev_probe.py +440 -0
  156. package/scripts/nt_common.py +82 -0
  157. package/scripts/nt_decrypt.py +1755 -1460
  158. package/scripts/pipeline.py +27 -2
  159. package/scripts/promote_ideas.py +19 -4
  160. package/scripts/quality_eval.py +707 -0
  161. package/scripts/rag_chat.py +4 -4
  162. package/scripts/read_image.py +187 -0
  163. package/scripts/reply_debt.py +488 -0
  164. package/scripts/route_cards.py +453 -0
  165. package/scripts/semantic_search.py +208 -49
  166. package/scripts/user_notes.py +315 -0
  167. package/scripts/vault_rag.py +13 -7
  168. package/scripts/vault_search.py +21 -4
  169. package/scripts/wiki_lint.py +397 -0
  170. package/src/core/appMsgFormat.ts +117 -0
  171. package/src/core/ntCore.ts +27 -2
  172. package/src/core/sqlcipherCore.ts +5 -61
  173. package/src/core/wcdbCore.ts +6 -2
  174. package/src/panel/client.ts +49 -0
  175. package/src/panel/endpoint.ts +144 -0
  176. package/src/panel/launch.ts +101 -0
  177. package/src/panel/server.ts +424 -0
  178. package/src/panel/userId.ts +72 -0
  179. package/src/services/assistantDaemon.ts +57 -9
  180. package/src/services/assistantEval.ts +611 -0
  181. package/src/services/assistantMemory.ts +413 -57
  182. package/src/services/assistantPrivacy.ts +18 -4
  183. package/src/services/assistantRouter.ts +191 -0
  184. package/src/services/assistantService.ts +671 -74
  185. package/src/services/assistantTools.ts +1118 -57
  186. package/src/services/assistantTrace.ts +175 -0
  187. package/src/services/chatService.ts +6 -2
  188. package/src/services/configService.ts +403 -385
  189. package/src/services/pythonBridge.ts +139 -0
  190. package/src/services/syncService.ts +37 -9
  191. package/src/services/wechatMessageService.ts +73 -7
  192. package/src/utils/atomicWrite.ts +56 -0
  193. package/src/utils/dateRange.ts +46 -0
  194. package/src/utils/text.ts +20 -0
@@ -8,17 +8,26 @@
8
8
  * └─ 隐私关卡: 工具结果脱敏后才出境到云端 LLM (本地引擎则完全不出境)
9
9
  */
10
10
  import { WechatMessageService } from './wechatMessageService.js';
11
+ import { decideRoute, MIN_NEEDS_TOOL } from './assistantRouter.js';
12
+ import { selectFactsForInjection, frameLocalData } from './assistantMemory.js';
11
13
  import { configService } from './configService.js';
12
14
  import { AssistantMemory } from './assistantMemory.js';
13
15
  import { privacyGate } from './assistantPrivacy.js';
14
- import { TOOL_DEFS, executeTool } from './assistantTools.js';
16
+ import { availableToolDefs, unavailableToolReason, executeTool } from './assistantTools.js';
17
+ import { producedContent } from './assistantTools.js';
18
+ import { recordTurn, describeForChat, summarizeArgs, clipReasoning } from './assistantTrace.js';
15
19
  import { appendLog } from './assistantDaemon.js';
16
20
  import { buildEvidenceReviewInput } from './evidenceService.js';
17
21
  import { evaluateAssistantAccess } from './assistantRouting.js';
22
+ import { nowLine } from '../utils/dateRange.js';
23
+ import { resolvePanelUserId, PANEL_FALLBACK_BUCKET } from '../panel/userId.js';
24
+ import { startPanelServer } from '../panel/server.js';
25
+ import { clearEndpoint } from '../panel/endpoint.js';
18
26
  const MAX_TOOL_ROUNDS = 6;
19
27
  /** 每日 LLM 处理上限 (护栏: 防 bug 死循环/异常流量烧钱; 0 = 不限制) */
20
28
  const DAILY_LIMIT = 100;
21
- const BASE_PROMPT = `你是"第二大脑", 运行在用户自己的电脑上, 通过微信与用户对话。
29
+ const SEP = String.fromCharCode(10); // 提示词里的换行。写成常量,省得在每种写入路径上各自操心转义
30
+ const BASE_PROMPT = `你是"第二大脑", 运行在用户自己的电脑上, 通过微信或本机面板与用户对话。
22
31
  你可以调用工具查询用户本地微信数据(会话/聊天记录/收藏), 以及读写关于用户的长期记忆。
23
32
 
24
33
  行为准则:
@@ -26,8 +35,55 @@ const BASE_PROMPT = `你是"第二大脑", 运行在用户自己的电脑上,
26
35
  - 用户让你记住某事时, 用 save_memory 工具保存
27
36
  - 回复用微信聊天风格, 简洁, 不用 markdown 符号
28
37
  - 数据不足时直说需要什么, 不要瞎猜
29
- - 回复控制在 300 字内, 列表类可放宽`;
38
+ - 回复控制在 300 字内, 列表类可放宽
39
+ - **必须真的调用过工具之后**才能对结果下结论。没调工具就说「内容被屏蔽了」「我查到了」
40
+ 都是编造, 审计里会留下 tools=0。
41
+ - 工具**失败**时(结果以括号起头, 如「(起草没跑通: …)」), 只许转述工具给出的那个原因。
42
+ 工具没写原因、或只给了退出码, 就直说「工具没给出原因」并给下一步(重试一次 / 换个问法),
43
+ **不许替它推测**一个听起来合理的原因——「大概是模型判断不一致」「可能是网络」这类
44
+ 都是编的, 用户会当成事实。也不许把工具失败说成"查不到"、把"查不到"说成工具失败。`;
45
+ /**
46
+ * 把内部消息展开成请求要的形状——**图片只在这一处**变成多模态数组。
47
+ *
48
+ * 其余所有环节(记忆、脱敏、审计、配额)继续按文本 `content` 工作,所以加图片没有把
49
+ * `ApiMessage.content` 变成一个到处都要判类型的联合体。
50
+ *
51
+ * `allowImages` 为假时图片**不发**,并在正文里留下一句说明:静默少发一张图,和"模型
52
+ * 看不见"在下游无法区分——这是这个仓库反复踩过的那类坑。
53
+ *
54
+ * 纯函数,不读配置。是否允许由调用方(`callLLM`)按隐私模式判定。
55
+ */
56
+ export function toApiMessages(messages, allowImages) {
57
+ let imagesSent = 0;
58
+ let imagesDropped = 0;
59
+ const expanded = messages.map(m => {
60
+ const images = m.images ?? [];
61
+ if (!images.length)
62
+ return m;
63
+ if (!allowImages) {
64
+ imagesDropped += images.length;
65
+ return { ...m, images: undefined,
66
+ content: `${m.content}\n(另有 ${images.length} 张图片未随本轮发出:当前隐私模式不允许图片出境)` };
67
+ }
68
+ imagesSent += images.length;
69
+ return {
70
+ ...m,
71
+ images: undefined,
72
+ content: [
73
+ { type: 'text', text: m.content },
74
+ ...images.map(img => ({
75
+ type: 'image_url',
76
+ image_url: { url: `data:${img.mime};base64,${img.b64}` },
77
+ })),
78
+ ],
79
+ };
80
+ });
81
+ return { messages: expanded, imagesSent, imagesDropped };
82
+ }
30
83
  export class AssistantService {
84
+ constructor(options = {}) {
85
+ this.routeDecider = options.routeDecider;
86
+ }
31
87
  svc = null;
32
88
  memory = new AssistantMemory();
33
89
  running = false;
@@ -36,6 +92,23 @@ export class AssistantService {
36
92
  /** 每日用量计数 (内存态, 重启重置 — 配额护栏防烧钱, 无需持久精确) */
37
93
  dailyCount = 0;
38
94
  dailyDate = new Date().toDateString();
95
+ /** 快路径的判断调用。留出注入点:测试用假判断层驱动,不联网、不 spawn Python */
96
+ routeDecider;
97
+ /**
98
+ * `handleMessage` 的重入护栏。
99
+ *
100
+ * `turnCalls` / `lastReasoning` / `lastNeedsLocalData` 是**实例字段**,它们的正确性只依赖
101
+ * "handleMessage 被串行调用"这个不变式——而那个不变式今天只写在注释里。第二个入口
102
+ * (本机面板)一出现,早晚有人想加一条直连的快速路径。重入就直接抛,
103
+ * 把"记忆窗口悄悄错乱"换成一声巨响。
104
+ */
105
+ inFlight = false;
106
+ /** `start()` 给的日志出口。本机入口不经过那个调用点传参,所以存一份 */
107
+ logSink;
108
+ /** 本机入口的回环端点(见 src/panel/server.ts 的信任边界) */
109
+ panelServer = null;
110
+ /** 本机入口用哪个记忆桶。与微信直聊同一个 id 时才是"共用一个大脑" */
111
+ memoryBucket = PANEL_FALLBACK_BUCKET;
39
112
  engineConfig() {
40
113
  const engine = String(configService.get('aiEngine') || 'deepseek');
41
114
  if (engine === 'ollama' || engine === 'lmstudio') {
@@ -77,7 +150,14 @@ export class AssistantService {
77
150
  if (!key && !local) {
78
151
  throw new Error('未配置 LLM (config set deepseekApiKey 或切换 ollama)');
79
152
  }
80
- const body = { model, messages, max_tokens: maxTokens, temperature: 0.4 };
153
+ // 图片出境与正文出境不是一回事:strict 模式(第三方聊天正文不出境)下图片一律不发。
154
+ // `look_at_image` 自己也会拒绝取图,但"工具肯给"与"这道门肯放"是两件事——漏一层就是
155
+ // 聊天图片出境。本地引擎不出机器,不受这条限制。
156
+ const allowImages = privacyGate.mode() !== 'strict' || privacyGate.isLocalInference();
157
+ const { messages: expanded, imagesSent, imagesDropped } = toApiMessages(messages, allowImages);
158
+ // `images` 不是 API 的字段,展平后要去掉;其余字段原样透传。
159
+ const payload = expanded.map(({ images, ...rest }) => rest);
160
+ const body = { model, messages: payload, max_tokens: maxTokens, temperature: 0.4 };
81
161
  if (tools?.length) {
82
162
  body.tools = tools;
83
163
  body.tool_choice = 'auto';
@@ -92,11 +172,24 @@ export class AssistantService {
92
172
  signal: AbortSignal.timeout(120_000),
93
173
  });
94
174
  privacyGate.audit('CLOUD_CALL', JSON.stringify(body).length, local ? 'local' : `${url.split('//')[1].split('/')[0]}`);
175
+ // 只记"真的出机器"的那种。本地引擎收图片时没有东西离开本机,记成 SENT 是错的说法。
176
+ if (!local && imagesSent) {
177
+ const bytes = messages.reduce((n, m) => n + (m.images ?? []).reduce((b, i) => b + i.b64.length, 0), 0);
178
+ const ids = messages.flatMap(m => (m.images ?? []).map(i => i.localId)).filter(Boolean).join(',');
179
+ privacyGate.audit('IMAGE_SENT', bytes, `n=${imagesSent}` + (ids ? ` ids=${ids}` : ''));
180
+ }
181
+ if (imagesDropped)
182
+ privacyGate.audit('IMAGE_HELD', imagesDropped, 'strict');
95
183
  if (!res.ok) {
96
184
  const t = await res.text().catch(() => '');
97
185
  throw new Error(`LLM ${res.status}: ${t.slice(0, 120)}`);
98
186
  }
99
- return res.json();
187
+ // 推理内容(如果这个模型给的话)单独挂在 message.reasoning_content 上,不混进 content。
188
+ // **只在这里接住**:不把它塞回对话(那是模型的独白,不是给用户的内容),只留给轨迹。
189
+ const envelope = await res.json();
190
+ const reasoning = envelope?.choices?.[0]?.message?.reasoning_content;
191
+ this.lastReasoning = typeof reasoning === 'string' ? reasoning : '';
192
+ return envelope;
100
193
  }
101
194
  /** 在明确授权后分析本地聊天,输出证据线索而非法律结论。 */
102
195
  async reviewEvidence(talker, messages, allowCloud = false) {
@@ -128,38 +221,320 @@ export class AssistantService {
128
221
  redactions: input.redactions,
129
222
  };
130
223
  }
131
- /** 组装系统提示: 基础人格 + L2 摘要 + L3 事实 */
132
- buildSystemPrompt(userId) {
133
- const parts = [BASE_PROMPT];
224
+ /** 当前的隐私状态,作为**事实**写进系统提示。
225
+ *
226
+ * 曾经这里是一段写死的假设句(「若因严格模式读不到正文…」),实测撞了车:模型把它当成
227
+ * 当前状态,于是**一次工具都没调**就回用户"我调了工具,但内容被严格模式挡掉了"——
228
+ * 审计里 `tools=0`,是编造。现在按实际档位说,且与"没调工具不许下结论"那条配套。
229
+ */
230
+ privacyStateLine() {
231
+ const mode = privacyGate.mode();
232
+ const local = privacyGate.isLocalInference();
233
+ if (local) {
234
+ return '[隐私] 本地推理:数据不出这台机器,工具结果里的聊天正文是原文,可以如实引用。';
235
+ }
236
+ if (mode === 'strict') {
237
+ return '[隐私] 当前 strict 模式:第三方聊天正文会以「[内容N字已按严格模式屏蔽]」的形式出现在'
238
+ + '工具结果里,那时你只能看到时间与字数。遇到这种情况如实说明,并给出三条路:'
239
+ + '①改 balanced(正文发给当前模型,电话/证件/邮箱/密钥/链接仍打码);'
240
+ + '②改用本地模型 aiEngine=ollama/lmstudio(数据不出机器,屏蔽自动不生效);③保持现状。';
241
+ }
242
+ return `[隐私] 当前 ${mode} 模式:工具结果里的聊天正文是原文(电话/证件/邮箱/密钥/链接已打成`
243
+ + '占位符),可以直接引用,不必声称被屏蔽。';
244
+ }
245
+ /** 组装系统提示: 基础人格 + 隐私状态 + L2 摘要 + L3 事实。
246
+ *
247
+ * 两个纪律:
248
+ * 1. **事实按相关度取一部分**,而不是 30 条全塞——无关的那些是噪声,不只是花钱;
249
+ * 2. 本地数据(摘要、事实)一律**加帧 + 转义框标签**:它们的内容里完全可能写着
250
+ * `</weflow-local-data>` 再跟一段像系统指令的话,不转义就等于让数据自己把框关上。
251
+ */
252
+ buildSystemPrompt(userId, question = '') {
253
+ // 当前时间:**没有它,任何相对时间都是猜**。"上周三""昨天""这周"要变成工具能用的
254
+ // 日期,模型得先知道今天是几号(`get_messages` 的 since/until 就是这么用的)。
255
+ const parts = [BASE_PROMPT, `[当前时间] ${nowLine()}(本机时区)`, this.privacyStateLine()];
134
256
  const summary = this.memory.summary(userId);
135
- if (summary)
136
- parts.push(`\n[此前对话摘要]\n${summary}`);
137
- const facts = this.memory.facts(userId);
138
- if (facts.length) {
139
- parts.push(`\n[关于用户的长期记忆]\n${facts.map(f => `· ${f.content}`).join('\n')}`);
257
+ if (summary) {
258
+ parts.push('[此前对话摘要]' + SEP + frameLocalData('memory.summary', summary));
259
+ }
260
+ const { selected, withheld } = selectFactsForInjection(this.memory.facts(userId), question);
261
+ if (selected.length) {
262
+ const lines = selected.map(f => `· ${f.content}`).join(SEP);
263
+ // 少给了几条要**如实说**:谎报「以下是全部记忆」比少给更糟——模型会以为自己看到了全部。
264
+ const note = withheld > 0 ? `${SEP}(另有 ${withheld} 条与这次问题关系较远,未列出)` : '';
265
+ parts.push('[关于用户的长期记忆]' + SEP + frameLocalData('memory.facts', lines + note));
140
266
  }
141
- return parts.join('\n');
267
+ return parts.join(SEP);
142
268
  }
143
- /** 单条消息处理: 指令路由 → ReAct 循环 → 记忆更新 */
269
+ /** 白名单为空的首次配置提示是否已经打过了(只打一次,别把日志刷满) */
270
+ firstRunHintShown = false;
271
+ /** 最近一次路由认为「这条消息需要查本机数据」的概率(0 = 没问过) */
272
+ lastNeedsLocalData = 0;
273
+ /** 最近一次模型调用里**真返回**的推理内容(`reasoning_content`)。默认模型不返回,所以常是空串 */
274
+ lastReasoning = '';
275
+ /** 每个用户最近一轮的轨迹:微信里发「轨迹」看的就是它(跨进程的历史在文件里,见 assistantTrace) */
276
+ traces = new Map();
277
+ /**
278
+ * 本轮已经调过的「工具名 + 参数」。同一条消息里重复调用**同样的参数**不会得到新信息,
279
+ * 而实测(评测的 ambiguous-contact 用例)模型会连着调三次 list_sessions。
280
+ * 消息是串行处理的(queue),所以实例字段在这里是安全的。
281
+ */
282
+ turnCalls = new Set();
283
+ /** 白名单为空时,把"该把谁加进去"连同**完整**的发送者 ID 打一行。
284
+ *
285
+ * 为什么可以打完整的 ID:填白名单用的就是这个值,而它只在入站消息里出现——登录响应给的
286
+ * `ilink_user_id` 与它是不是同一个值**没有验证过**,所以不能拿登录来猜(猜错的后果是
287
+ * "白名单非空、看着配好了、却仍然拒你")。启发式在这里正好用得上:真值自己会来。
288
+ *
289
+ * 三条自我约束:白名单非空时不提示(那时人已经配过了)、只对**直聊**提示(群里的
290
+ * sender_id 是群成员,把它加进白名单是错的)、只提示一次。
291
+ * `assistant log --json` 照旧只回元数据,不返回日志内容。
292
+ */
293
+ maybeAnnounceWhitelistBootstrap(access, msg, onLog) {
294
+ if (this.firstRunHintShown)
295
+ return;
296
+ if (String(configService.get('assistantWhitelist') || '').trim())
297
+ return;
298
+ if (access.reason !== 'direct-not-whitelisted')
299
+ return;
300
+ this.firstRunHintShown = true;
301
+ const line = '[首次配置] 白名单为空,所以谁都没回。若刚才这条是你本人发的,执行:'
302
+ + ` weflow-cli config set assistantWhitelist "${msg.senderId}"`;
303
+ onLog?.(line);
304
+ appendLog(line);
305
+ privacyGate.audit('WHITELIST_BOOTSTRAP_HINT', 0, 'whitelist-empty');
306
+ }
307
+ /** 快路径开关。默认 `off`:不改行为,直到有人愿意盯着它(D-035) */
308
+ fastRouteMode() {
309
+ const raw = String(configService.get('assistantFastRoute') || '').trim().toLowerCase();
310
+ return raw === 'on' || raw === 'log' ? raw : 'off';
311
+ }
312
+ /** ReAct 主循环:一轮一轮问模型,直到它给出不带工具调用的答复。
313
+ *
314
+ * 抽成方法是因为守卫(见 handleMessage)要在"顶回去一次"之后**再跑一遍同一条循环**——
315
+ * 两处各写一份,早晚有一处会漏掉既有的容错(参数解析、工具审计、轮数上限)。
316
+ */
317
+ async runReactLoop(userId, messages, trace) {
318
+ let reply = '';
319
+ let toolCalls = 0;
320
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
321
+ // 只摆这台机器**跑得了**的工具:摆了跑不了的,模型会去试、拿一个错回来
322
+ const data = await this.callLLM(messages, availableToolDefs());
323
+ const msg = data.choices?.[0]?.message;
324
+ if (!msg)
325
+ throw new Error('LLM 返回为空');
326
+ trace.rounds += 1;
327
+ // 模型这次真返回了推理内容的话,留一份(截断)。当前默认模型不返回,那就是空。
328
+ if (this.lastReasoning) {
329
+ trace.reasoningChars += this.lastReasoning.length;
330
+ if (!trace.reasoning)
331
+ trace.reasoning = clipReasoning(this.lastReasoning);
332
+ }
333
+ if (msg.tool_calls?.length) {
334
+ messages.push({ role: 'assistant', content: msg.content || '', tool_calls: msg.tool_calls });
335
+ for (const tc of msg.tool_calls) {
336
+ toolCalls++;
337
+ let args = {};
338
+ try {
339
+ args = JSON.parse(tc.function?.arguments || '{}');
340
+ }
341
+ catch { /* 参数容错 */ }
342
+ await this.runToolCall(userId, messages, tc.id, tc.function?.name || '', args, trace);
343
+ }
344
+ continue;
345
+ }
346
+ reply = (msg.content || '').trim() || '(空回复)';
347
+ trace.steps.push({ kind: 'note', detail: `第 ${round + 1} 轮往返后给出答复` });
348
+ break;
349
+ }
350
+ if (!reply) {
351
+ reply = '(这轮处理太复杂了, 换个问法试试?)';
352
+ // 别把它记成"答完了":这是撞上限,不是回答
353
+ trace.stop = 'rounds-exhausted';
354
+ }
355
+ return { reply, toolCalls };
356
+ }
357
+ /** 执行一次工具调用:脱敏、审计、把结果塞回对话。
358
+ *
359
+ * 快路径与 ReAct 循环**共用这一份**:D-035 里那条不可回归的安全属性是「没有审计行就不得
360
+ * 派发工具」,两条路各写一遍,早晚有一条会漏。
361
+ */
362
+ async runToolCall(userId, messages, callId, name, args, trace) {
363
+ const callKey = `${name}:${JSON.stringify(args ?? {})}`;
364
+ if (this.turnCalls.has(callKey)) {
365
+ // 同样的参数再来一次不会得到新东西。**顶回去说清楚**,而不是默默重跑一遍——
366
+ // 实测模型会连着调三次 list_sessions,既慢又把它自己的上下文刷满。
367
+ trace.steps.push({ kind: 'note', detail: `重复调用 ${name}(参数相同)→ 跳过` });
368
+ messages.push({ role: 'tool', tool_call_id: callId,
369
+ content: `(这一步与前面某次调用完全相同,结果不会变。结果已经在上面的对话里,`
370
+ + `直接用它回答,不要再重复调用同一个工具。)` });
371
+ return;
372
+ }
373
+ this.turnCalls.add(callKey);
374
+ const ctx = { userId, memory: this.memory };
375
+ const raw = await executeTool(name, args, ctx);
376
+ trace.toolCalls += 1;
377
+ trace.steps.push({
378
+ kind: 'tool', name, args: summarizeArgs(args), bytes: raw.length,
379
+ produced: producedContent(raw),
380
+ });
381
+ const { safe, redactions } = privacyGate.redact(raw);
382
+ privacyGate.audit(`TOOL:${name}`, raw.length, redactions ? `redacted=${redactions}` : '');
383
+ messages.push({ role: 'tool', tool_call_id: callId, content: safe });
384
+ // 工具取来的图片作为**紧随其后的一条 user 消息**附上:`tool` 消息的 content 只能是
385
+ // 字符串(OpenAI 的形状),塞不进多模态数组。图片本身不过 redact(它不是文本),
386
+ // 出不出境由 `toApiMessages` 按隐私模式判定——那是唯一一道门。
387
+ if (ctx.pendingImages?.length) {
388
+ const n = ctx.pendingImages.length;
389
+ messages.push({
390
+ role: 'user',
391
+ content: `(这是你刚取来的图片${n > 1 ? `,共 ${n} 张` : ''})`,
392
+ images: ctx.pendingImages,
393
+ });
394
+ }
395
+ }
396
+ /** 快路径:先问一次「该查哪个能力」,把这一个工具执行掉,于是循环第一轮就看得到结果。
397
+ *
398
+ * 返回派发了几个工具(0 = 回退)。**任何不确定都回退**,而回退就是原样跑循环——不是
399
+ * 「另一个更差的兜底」。`log` 模式下只算不派发,用来在真实流量上观察它本来会怎么走。
400
+ */
401
+ async maybeFastRoute(userId, text, messages, trace) {
402
+ const mode = this.fastRouteMode();
403
+ if (mode === 'off')
404
+ return 0;
405
+ let decision;
406
+ try {
407
+ decision = await decideRoute(text, { runDecide: this.routeDecider });
408
+ }
409
+ catch (error) {
410
+ appendLog(`[快路径] 路由异常,按原样回退: ${error?.message ?? error}`);
411
+ trace.steps.push({ kind: 'route', detail: '判断层异常,直接走循环' });
412
+ return 0;
413
+ }
414
+ this.lastNeedsLocalData = decision.needsTool;
415
+ if (!decision.capability) {
416
+ appendLog(`[快路径] 回退: ${decision.reason}`);
417
+ privacyGate.audit('FASTROUTE_SKIP', 0, decision.reason.slice(0, 120));
418
+ trace.steps.push({ kind: 'route', detail: `判断层: ${decision.reason}` });
419
+ return 0;
420
+ }
421
+ if (mode === 'log') {
422
+ // 灰度期:只记「本来会走哪条」,行为一个字不改
423
+ appendLog(`[快路径/只记] ${decision.reason}`);
424
+ privacyGate.audit('FASTROUTE_WOULD', 0, `${decision.capability.name} ${decision.model}`);
425
+ trace.steps.push({ kind: 'route', detail: `判断层(只记,未派发): ${decision.reason}` });
426
+ return 0;
427
+ }
428
+ trace.steps.push({ kind: 'route', detail: `判断层: ${decision.reason}` });
429
+ // 快路径是**直接派发**的(不经工具表),所以这里要自己过同一道判据
430
+ const blocked = unavailableToolReason(decision.capability.tool);
431
+ if (blocked) {
432
+ trace.steps.push({ kind: 'route', detail: `快路径想派发 ${decision.capability.tool},但${blocked};回退成循环` });
433
+ return 0;
434
+ }
435
+ const callId = `fastroute-${Date.now()}`;
436
+ appendLog(`[快路径] ${decision.reason}`);
437
+ privacyGate.audit('FASTROUTE_HIT', 0, `${decision.capability.name} ${decision.model}`);
438
+ messages.push({
439
+ role: 'assistant',
440
+ content: '',
441
+ tool_calls: [{
442
+ id: callId,
443
+ type: 'function',
444
+ function: {
445
+ name: decision.capability.tool,
446
+ arguments: JSON.stringify(decision.capability.args),
447
+ },
448
+ }],
449
+ });
450
+ await this.runToolCall(userId, messages, callId, decision.capability.tool, decision.capability.args, trace);
451
+ return 1;
452
+ }
453
+ /**
454
+ * 单条消息处理: 指令路由 → ReAct 循环 → 记忆更新。
455
+ *
456
+ * **外部只许经由 `enqueueTurn` 调用**(它保证串行);直接调会被 `inFlight` 挡下——
457
+ * 见那个字段的注释。
458
+ */
144
459
  async handleMessage(userId, text, kind) {
460
+ if (this.inFlight) {
461
+ throw new Error('handleMessage 重入:它依赖串行调用(记忆窗口、turnCalls 都是实例字段)');
462
+ }
463
+ this.inFlight = true;
464
+ try {
465
+ return await this.handleMessageInner(userId, text, kind);
466
+ }
467
+ finally {
468
+ this.inFlight = false;
469
+ }
470
+ }
471
+ async handleMessageInner(userId, text, kind) {
145
472
  if (kind !== 'text')
146
473
  return '目前只支持文字消息哦';
147
474
  const t = text.trim();
475
+ const startedAt = Date.now();
476
+ this.turnCalls.clear();
477
+ // 一轮的轨迹:判断了什么、调了什么、几轮、为什么停。**内置指令也要记**——
478
+ // 不然"我发了「轨迹」却看不到刚才那一轮"这种事自己就会发生(它本身也是一轮)。
479
+ const trace = {
480
+ at: new Date(startedAt).toISOString(), userId, questionChars: t.length, steps: [],
481
+ rounds: 0, toolCalls: 0, reasoning: '', reasoningChars: 0, answerChars: 0,
482
+ stop: 'answered', elapsedMs: 0,
483
+ };
484
+ // `stop` 不给就保留循环/异常那边已经定好的结束原因(主路径走这里收尾)。
485
+ const finish = (reply, stop) => {
486
+ trace.answerChars = reply.length;
487
+ trace.elapsedMs = Date.now() - startedAt;
488
+ if (stop)
489
+ trace.stop = stop;
490
+ // 内置指令**不占**内存里那一份"上一轮":`轨迹` 本身就是一轮,占了它之后连着问两次
491
+ // 第二次就什么都看不到了。文件里照样留档(每轮都记)。
492
+ if (trace.stop !== 'builtin')
493
+ this.traces.set(userId, trace);
494
+ recordTurn(trace);
495
+ return reply;
496
+ };
148
497
  if (t === '帮助' || t.toLowerCase() === 'help') {
149
498
  return ['🧠 第二大脑 Agent', '',
150
499
  '直接用自然语言问, 我会自动查本地数据回答:',
151
500
  '「我最近都在忙什么?」',
152
501
  '「总结我和XX的聊天」',
153
502
  '「收藏里有哪些AI文章?」',
503
+ '「我最近都在读什么?」',
154
504
  '「记住: 我的项目叫weflow-cli」',
155
505
  '', '记忆: 三层 (窗口/摘要/长期事实), 重启不丢',
156
506
  '隐私: 数据库不出本机, 出境内容自动脱敏',
157
- '', '指令: 记忆 | 清空记忆'].join('\n');
507
+ '', '指令: 记忆 | 隐私 | 清空记忆 | 轨迹'].join('\n');
508
+ }
509
+ if (t === '轨迹' || t === '过程' || t === '思考过程') {
510
+ // 上一轮**怎么走到那个答复的**。看的是本地那份轨迹,不是模型的自述——
511
+ // 模型说"我查了"与它真的查了,是两件事(审计里那条 TOOL_GUARD_PUSHBACK 就是这么来的)。
512
+ return finish(describeForChat(this.traces.get(userId)), 'builtin');
158
513
  }
159
514
  if (t === '清空记忆' || t === '重置') {
160
515
  this.memory.reset(userId);
161
516
  privacyGate.audit('MEMORY_RESET', 0, userId.slice(0, 8));
162
- return '✓ 对话记忆已清空, 重新开始';
517
+ return finish('✓ 对话记忆已清空, 重新开始', 'builtin');
518
+ }
519
+ if (t === '隐私' || t === 'privacy') {
520
+ // 只读:把当前档位和改法说清楚。**不让一条微信消息直接改隐私档位**——
521
+ // 那等于把隐私开关搬进对话里,而配置本来就是用户在自己电脑上显式设定的东西。
522
+ const mode = privacyGate.mode();
523
+ const local = privacyGate.isLocalInference();
524
+ const lines = [`隐私模式: ${mode}${local ? '(本地推理, 数据不出机器)' : '(云端推理)'}`];
525
+ lines.push(`工具拿到的聊天正文: ${local ? '原文(不出机器)'
526
+ : mode === 'strict' ? '被屏蔽, 只剩时间与字数'
527
+ : mode === 'open' ? '原文, 不脱敏'
528
+ : '原文, 但电话/证件/邮箱/密钥/链接会打码'}`);
529
+ lines.push('在电脑上改(改完要重启助手):');
530
+ lines.push('weflow-cli config set assistantPrivacy balanced # 正文出境, PII 打码');
531
+ lines.push('weflow-cli config set assistantPrivacy open # 全不打码');
532
+ lines.push('weflow-cli config set assistantPrivacy strict # 正文不出境');
533
+ if (!local) {
534
+ lines.push('或者换本地模型, 内容根本不出机器:');
535
+ lines.push('weflow-cli config set aiEngine ollama');
536
+ }
537
+ return finish(lines.join(String.fromCharCode(10)), 'builtin');
163
538
  }
164
539
  if (t === '记忆') {
165
540
  const facts = this.memory.facts(userId);
@@ -170,47 +545,55 @@ export class AssistantService {
170
545
  if (facts.length)
171
546
  lines.push(facts.slice(-5).map(f => `· ${f.content}`).join('\n'));
172
547
  lines.push(`隐私模式: ${privacyGate.mode()}${privacyGate.isLocalInference() ? ' (本地推理, 不出境)' : ''}`);
548
+ // 落盘失败要说出来:用户在这里问"你记住了什么",如果上一次根本没存上,
549
+ // 那份答案就是假的。`save()` 不抛异常是刻意的,代价是必须有地方把它讲出来。
550
+ if (this.memory.lastSaveError) {
551
+ lines.push(`⚠ 上次保存失败: ${this.memory.lastSaveError}`);
552
+ }
173
553
  lines.push(`今日用量: ${this.dailyCount}/${DAILY_LIMIT}`);
174
554
  return lines.join('\n');
175
555
  }
176
556
  // === ReAct 主循环 ===
177
557
  this.memory.addTurn(userId, 'user', t);
178
558
  const messages = [
179
- { role: 'system', content: this.buildSystemPrompt(userId) },
559
+ { role: 'system', content: this.buildSystemPrompt(userId, t) },
180
560
  ...this.memory.workingWindow(userId).map(turn => ({ role: turn.role, content: turn.content })),
181
561
  ];
182
562
  let reply = '';
183
563
  let toolCalls = 0;
184
564
  try {
185
- for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
186
- const data = await this.callLLM(messages, TOOL_DEFS);
187
- const msg = data.choices?.[0]?.message;
188
- if (!msg)
189
- throw new Error('LLM 返回为空');
190
- if (msg.tool_calls?.length) {
191
- messages.push({ role: 'assistant', content: msg.content || '', tool_calls: msg.tool_calls });
192
- for (const tc of msg.tool_calls) {
193
- toolCalls++;
194
- let args = {};
195
- try {
196
- args = JSON.parse(tc.function?.arguments || '{}');
197
- }
198
- catch { /* 参数容错 */ }
199
- const raw = await executeTool(tc.function?.name || '', args, { userId, memory: this.memory });
200
- const { safe, redactions } = privacyGate.redact(raw);
201
- privacyGate.audit(`TOOL:${tc.function?.name}`, raw.length, redactions ? `redacted=${redactions}` : '');
202
- messages.push({ role: 'tool', tool_call_id: tc.id, content: safe });
203
- }
204
- continue;
565
+ toolCalls += await this.maybeFastRoute(userId, t, messages, trace);
566
+ const first = await this.runReactLoop(userId, messages, trace);
567
+ reply = first.reply;
568
+ toolCalls += first.toolCalls;
569
+ // 守卫:路由说「这条消息需要查本机数据」,而这一轮**一次工具都没调** —— 两者自相矛盾。
570
+ // 实测撞到过两次:它回"我确实调了工具查了",而审计里 `tools=0`、没有任何 TOOL: 行。
571
+ // 这里不去解释原因,只把矛盾顶回去**一次**:让模型看见"你手上没有工具结果"再答。
572
+ // 只在路由判过"需要查本机数据"时触发(那时才有这个信号),且补问失败不许把原答复弄丢。
573
+ if (toolCalls === 0 && this.lastNeedsLocalData >= MIN_NEEDS_TOOL) {
574
+ privacyGate.audit('TOOL_GUARD_PUSHBACK', 0, `needs_local_data=${this.lastNeedsLocalData.toFixed(2)}`);
575
+ appendLog('[守卫] 路由说需要查本机数据,但这一轮没调任何工具;顶回去一次');
576
+ try {
577
+ messages.push({
578
+ role: 'system',
579
+ content: '注意:你刚才的回答没有调用任何工具,因此你手上并没有本机数据。'
580
+ + '这个问题需要本机的真实数据。请现在就调用合适的工具;'
581
+ + '若确实查不到,说明你调用了哪个工具、它返回了什么。',
582
+ });
583
+ trace.steps.push({ kind: 'note', detail: '守卫:路由说需要本机数据而这一轮没调工具,顶回去一次' });
584
+ const second = await this.runReactLoop(userId, messages, trace);
585
+ reply = second.reply;
586
+ toolCalls += second.toolCalls;
587
+ }
588
+ catch (e) {
589
+ appendLog(`[守卫] 补问失败,保留原答复: ${e?.message ?? e}`);
205
590
  }
206
- reply = (msg.content || '').trim() || '(空回复)';
207
- break;
208
591
  }
209
- if (!reply)
210
- reply = '(这轮处理太复杂了, 换个问法试试?)';
211
592
  }
212
593
  catch (e) {
213
594
  reply = `❌ 大脑暂时离线: ${e.message?.slice(0, 100)}\n(本地指令仍可用: 发「帮助」)`;
595
+ trace.stop = 'llm-error';
596
+ trace.error = String(e?.message ?? e).slice(0, 120);
214
597
  }
215
598
  // === 记忆更新 (L2 压缩 + L3 提取) ===
216
599
  this.memory.addTurn(userId, 'assistant', reply);
@@ -225,59 +608,191 @@ export class AssistantService {
225
608
  catch { /* 提取失败不阻断 */ }
226
609
  this.memory.save();
227
610
  privacyGate.audit('TURN_DONE', reply.length, `tools=${toolCalls}`);
611
+ finish(reply);
228
612
  return reply;
229
613
  }
614
+ /**
615
+ * 一轮的处理。**这是唯一一处**——微信通道与本机面板都走这里。
616
+ *
617
+ * 抽出来的理由是这个仓库付过学费的老毛病:判定逻辑两处各写一份,早晚有一处漏掉既有的容错
618
+ * (同样的判词写在本文件 `:319` 与 `:361` 附近的注释里)。配额、忽略非文本、异常兜底
619
+ * 这些行为**只该有一份**。
620
+ *
621
+ * `origin` 决定要不要过白名单:微信来的必须过;**本机入口的门是端点 token,不是白名单**。
622
+ * 把白名单套到"坐在机器前的人"身上是错的——空名单时会把面板自己拒掉,而且
623
+ * `maybeAnnounceWhitelistBootstrap` 还会打出一条**教用户把自己的面板 id 加进微信白名单**的错提示。
624
+ *
625
+ * 本方法**不负责"往哪回"**:结果交给调用方(微信是 `sendText`,面板是 HTTP 响应)。
626
+ */
627
+ async runTurn(input, onLog) {
628
+ if (!this.running)
629
+ return { status: 'not-running' };
630
+ const { sessionId, text, kind, origin } = input;
631
+ if (origin === 'wechat') {
632
+ const msg = input.message;
633
+ const access = this.accessDecision(msg);
634
+ if (!access.allowed) {
635
+ privacyGate.audit(`DENY_${access.reason.toUpperCase().replace(/-/g, '_')}`, 0, sessionId.slice(0, 12));
636
+ onLog?.(` → 拒绝: ${sessionId.slice(0, 12)}… ${access.reason} (未回复, 不耗 LLM)`);
637
+ this.maybeAnnounceWhitelistBootstrap(access, msg, onLog);
638
+ return { status: 'denied', reason: access.reason };
639
+ }
640
+ if (kind !== 'text') {
641
+ onLog?.(` → 忽略非文本消息 (${kind})`);
642
+ return { status: 'ignored', reason: 'non-text' };
643
+ }
644
+ onLog?.(`[${new Date().toLocaleTimeString('zh-CN')}] ${sessionId.slice(0, 12)}…: ${text.slice(0, 40)}`);
645
+ }
646
+ else {
647
+ // 本机入口**不往日志里写内容**,这是有意的不对称:微信那边没有别的界面,
648
+ // 日志是唯一线索;面板有界面,日志里留长度就够查问题了。别当成不一致"修"回去。
649
+ onLog?.(`[panel] ${text.length}字`);
650
+ }
651
+ if (this.dailyQuotaLeft() <= 0) {
652
+ privacyGate.audit('DENY_DAILY_LIMIT', this.dailyCount);
653
+ appendLog(`[配额] 今日 ${DAILY_LIMIT} 条上限已用尽, 拒绝 (via=${origin})`);
654
+ return { status: 'quota-exceeded', text: '今日额度已用完, 明天再来吧' };
655
+ }
656
+ try {
657
+ const reply = await this.handleMessage(sessionId, text, kind);
658
+ this.dailyCount++;
659
+ // **这里不报"已回复"**:回没回得出去是投递方才知道的事(微信的 sendText 会失败)。
660
+ // 两处都报的话,微信那条路会在日志里出现两遍"已回复",看着像处理了两条消息。
661
+ return { status: 'replied', text: reply };
662
+ }
663
+ catch (e) {
664
+ onLog?.(` → 处理异常: ${e.message}`);
665
+ appendLog(`[${new Date().toLocaleTimeString('zh-CN')}] 异常: ${e.message}`);
666
+ return { status: 'error', message: String(e?.message ?? e) };
667
+ }
668
+ }
669
+ /**
670
+ * 串行入队 + 投递。**投递在队列里做**,不在队列外——微信那边"先回复完再处理下一条"
671
+ * 是既有行为,把它挪到队列外会让两条消息的回复有乱序的可能(前一条发出慢一点就反超)。
672
+ *
673
+ * 返回的 Promise 在这个请求**处理且投递完**之后 resolve,调用方可以直接 await 它去写响应。
674
+ */
675
+ enqueueTurn(input, deliver, onLog) {
676
+ // 没给日志出口就用 `start()` 时那个:本机入口不经过 `start()` 的调用点传参,
677
+ // 漏了这一步会让面板那一侧**一行日志都不留**(第一版就是这样,测试抓到了)。
678
+ const log = onLog ?? this.logSink;
679
+ this.queue = this.queue.then(async () => {
680
+ const outcome = await this.runTurn(input, log);
681
+ await deliver(outcome);
682
+ }).catch((e) => appendLog(`队列异常: ${e.message}`));
683
+ return this.queue;
684
+ }
230
685
  /** 启动常驻监听 */
231
686
  async start(onLog) {
232
687
  const token = configService.get('wechatOcToken');
233
- if (!token)
234
- throw new Error('未登录消息通道, 先运行 weflow-cli login-wechat');
235
- this.svc = new WechatMessageService({ token });
688
+ this.logSink = onLog;
236
689
  this.running = true;
690
+ if (token) {
691
+ this.svc = new WechatMessageService({ token });
692
+ }
693
+ else {
694
+ // **降级启动**:没有消息通道也要起得来。
695
+ //
696
+ // 从前这里直接抛("未登录消息通道, 先运行 weflow-cli login-wechat"),于是"没登录微信"
697
+ // == 助手整个起不来——本机的实地记录就是这样(docs/PROJECT_STATE.md 那条)。
698
+ // 代价要说清:`assistant start` 从此可能"报成功但通道是空的",所以 status 必须能把
699
+ // 这两者分开(`messageChannelLoggedIn` 是"配了 token 吗",它回答不了这个问题)。
700
+ appendLog('[通道] 未登录消息通道,以本机入口模式启动');
701
+ onLog?.('⚠ 消息通道未登录:微信里暂时说不了话;本机面板仍然可用');
702
+ }
703
+ // 记忆加载出过事(版本不认识 / 文件坏了)要说出来:否则用户面对一份空记忆,
704
+ // 只会以为「它忘了我」。原文件此时已经留档,所以这句话里带着文件名。
705
+ if (this.memory.problem) {
706
+ appendLog(`[记忆] ${this.memory.problem}`);
707
+ privacyGate.audit('MEMORY_LOAD_ISSUE', 0, this.memory.problem.slice(0, 80));
708
+ onLog?.(`⚠ 记忆: ${this.memory.problem}`);
709
+ }
237
710
  const { local } = this.engineConfig();
238
- onLog?.(`助手已启动 (bot: ${configService.get('wechatOcAccountId')}, ` +
711
+ // bot 那一段只在真有通道时报:没有通道还报一个 bot 账号,是让人以为它在收消息
712
+ onLog?.('助手已启动 (' + (token ? `bot: ${configService.get('wechatOcAccountId')}, ` : '本机入口模式, ') +
239
713
  `引擎: ${local ? '本地' : '云端'}, 记忆用户数: ${this.memory.userCount()})`);
714
+ // 本机入口:**只绑回环** + 每次启动随机口令。悬浮窗与 `weflow-cli panel` 都走它,
715
+ // 于是两个入口落在**同一个进程**里(配额、串行队列、记忆都只有一份)。
716
+ const bucket = resolvePanelUserId({
717
+ whitelist: configService.get('assistantWhitelist'),
718
+ configured: configService.get('assistantPanelUser'),
719
+ });
720
+ this.memoryBucket = bucket.userId;
721
+ try {
722
+ this.panelServer = await startPanelServer({
723
+ service: this,
724
+ memoryBucket: this.memoryBucket,
725
+ memoryNote: bucket.note,
726
+ channel: token ? 'wechat' : 'local',
727
+ onLog,
728
+ });
729
+ appendLog(`[本机入口] 监听 127.0.0.1:${this.panelServer.port}`
730
+ + ` channel=${token ? 'wechat' : 'local'} bucket=${this.memoryBucket}`);
731
+ onLog?.(`本机入口: http://127.0.0.1:${this.panelServer.port}(记忆桶 ${this.memoryBucket})`);
732
+ }
733
+ catch (e) {
734
+ // 端点起不来不该把助手一起弄死:微信那边还能用(端口被占是唯一常见的失败)
735
+ appendLog(`[本机入口] 启动失败: ${e.message}`);
736
+ onLog?.(`⚠ 本机入口启动失败: ${e.message}`);
737
+ }
738
+ if (!this.svc)
739
+ return;
240
740
  this.svc.onMessage((msg) => {
241
741
  // 串行入队: 并发到达的消息按顺序处理, 记忆窗口不交错
242
- this.queue = this.queue.then(async () => {
243
- if (!this.running)
244
- return;
245
- const access = this.accessDecision(msg);
246
- const sessionId = msg.conversationId;
247
- if (!access.allowed) {
248
- privacyGate.audit(`DENY_${access.reason.toUpperCase().replace(/-/g, '_')}`, 0, sessionId.slice(0, 12));
249
- onLog?.(` → 拒绝: ${sessionId.slice(0, 12)}… ${access.reason} (未回复, 不耗 LLM)`);
742
+ this.enqueueTurn({ sessionId: msg.conversationId, text: msg.messageStr, kind: msg.messageKind, origin: 'wechat', message: msg }, async (outcome) => {
743
+ if (!('text' in outcome) || !outcome.text)
250
744
  return;
745
+ const ok = await this.svc?.sendText(msg.conversationId, outcome.text);
746
+ if (outcome.status === 'replied') {
747
+ onLog?.(` → ${ok ? `已回复 (${outcome.text.length}字, 今日 ${this.dailyCount}/${DAILY_LIMIT})` : '回复失败'}`);
251
748
  }
252
- if (msg.messageKind !== 'text') {
253
- onLog?.(` → 忽略非文本消息 (${msg.messageKind})`);
254
- return;
749
+ else {
750
+ onLog?.(` → ${ok ? '已回复额度提示' : '回复失败'}`);
255
751
  }
256
- if (this.dailyQuotaLeft() <= 0) {
257
- privacyGate.audit('DENY_DAILY_LIMIT', this.dailyCount);
258
- appendLog(`[配额] 今日 ${DAILY_LIMIT} 条上限已用尽, 拒绝: ${msg.messageStr.slice(0, 30)}`);
259
- await this.svc.sendText(sessionId, '今日额度已用完, 明天再来吧').catch(() => { });
260
- return;
261
- }
262
- onLog?.(`[${new Date().toLocaleTimeString('zh-CN')}] ${sessionId.slice(0, 12)}…: ${msg.messageStr.slice(0, 40)}`);
263
- try {
264
- const reply = await this.handleMessage(sessionId, msg.messageStr, msg.messageKind);
265
- this.dailyCount++;
266
- const ok = await this.svc.sendText(sessionId, reply);
267
- onLog?.(` → ${ok ? `已回复 (${reply.length}字, 今日 ${this.dailyCount}/${DAILY_LIMIT})` : '回复失败'}`);
268
- }
269
- catch (e) {
270
- onLog?.(` → 处理异常: ${e.message}`);
271
- appendLog(`[${new Date().toLocaleTimeString('zh-CN')}] 异常: ${e.message}`);
272
- }
273
- }).catch((e) => appendLog(`队列异常: ${e.message}`));
752
+ }, onLog).catch(() => { });
274
753
  });
275
754
  await this.svc.startPolling();
276
755
  }
756
+ /** 本机入口用:投一条消息进同一条队列,返回这一轮的结果 */
757
+ ask(sessionId, text, onLog) {
758
+ let outcome = { status: 'error', message: '未取得结果' };
759
+ return this.enqueueTurn({ sessionId, text, kind: 'text', origin: 'panel' }, (o) => {
760
+ outcome = o;
761
+ // 投递结果由这一侧报(`runTurn` 不知道投递成没成)
762
+ if (o.status === 'replied') {
763
+ (onLog ?? this.logSink)?.(` → 已回复 (${o.text.length}字, 今日 ${this.dailyCount}/${DAILY_LIMIT}, via=panel)`);
764
+ }
765
+ }, onLog).then(() => outcome);
766
+ }
767
+ /** 消息通道接上了吗(未登录时是 false)。status 要靠它区分两种"没通道" */
768
+ /**
769
+ * 微信通道**现在**是否可用。
770
+ *
771
+ * 原来是 `this.svc !== null`——那只是"启动时配了 token",于是 token 失效之后它照样报 true,
772
+ * 面板与状态页都在说"微信 + 本机"而通道其实已经死了(用户看不到任何异常)。
773
+ * 现在把长轮询那边的实况算进来:服务端判过期时,这里如实变 false(面板就会显示"仅本机入口")。
774
+ */
775
+ isChannelActive() { return this.svc !== null && !this.svc.isTokenExpired?.(); }
776
+ /** 配额用量。**内存态**(重启归零),所以它不是"今天一共花了多少"的账本,是护栏的余量 */
777
+ quotaState() {
778
+ this.dailyQuotaLeft(); // 先让它处理日期翻转,否则跨零点后会报昨天的数
779
+ return { used: this.dailyCount, limit: DAILY_LIMIT };
780
+ }
277
781
  stop() {
278
782
  this.running = false;
279
783
  this.svc?.stop();
784
+ // 关掉本机入口,并**主动删掉端点文件**——这一条与"崩溃时不删"是两回事:
785
+ // 走 `stop()` 的是有序关闭,删掉能让面板立刻知道;崩溃/SIGTERM 残留的那种,
786
+ // 由读取端探活(`readEndpoint()` 会检查 pid)兜住。
787
+ const closing = this.panelServer;
788
+ this.panelServer = null;
789
+ if (closing) {
790
+ clearEndpoint();
791
+ void closing.close();
792
+ }
280
793
  }
794
+ /** 本机入口的实际监听端口;没起来就是 null */
795
+ panelPort() { return this.panelServer ? this.panelServer.port : null; }
281
796
  isRunning() { return this.running; }
282
797
  memoryUserCount() { return this.memory.userCount(); }
283
798
  }