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
@@ -300,8 +300,41 @@ program
300
300
  dailyStats: { cli: 'daily-stats --json' },
301
301
  diagnostics: { cli: 'check --json' },
302
302
  todos: { cli: 'todos list --json' },
303
+ awaiting: {
304
+ cli: 'awaiting --yes',
305
+ preview: 'awaiting --dry-run --json',
306
+ readsLocalChat: true,
307
+ invokesAI: true,
308
+ writesNothing: true,
309
+ confirmationRequired: true,
310
+ },
311
+ draft: {
312
+ cli: 'draft <会话> --yes --json',
313
+ preview: 'draft <会话> --dry-run --json',
314
+ readsLocalChat: true,
315
+ invokesAI: true,
316
+ // **它不发送任何东西**:只产出候选文本,回不回由人决定。
317
+ // 这条要能被机器读到——`sendsNothing` 比 `writesNothing` 更要紧。
318
+ writesNothing: true,
319
+ sendsNothing: true,
320
+ confirmationRequired: true,
321
+ },
303
322
  knowledge: { cli: 'search <query> --yes --json', preview: 'search <query> --dry-run --json', output: 'json', mcp: 'wechat.search_articles' },
304
323
  },
324
+ primitives: {
325
+ decide: {
326
+ cli: 'decide --request <file> --yes | decide --over <glob> --ask <text> --yes',
327
+ preview: 'decide --dry-run',
328
+ // 两条路的读法**不一样**,所以不能只写一个 false:
329
+ // --request 完全由调用方给,--over 会读匹配到的本地文件。
330
+ readsLocalData: { request: false, over: true },
331
+ invokesAI: true,
332
+ sendsCallerProvidedState: true,
333
+ confirmationRequired: true,
334
+ mcpExposed: false,
335
+ whyNotMcp: 'an MCP client must not be able to drive local outbound calls',
336
+ },
337
+ },
305
338
  workflows: {
306
339
  initialization: {
307
340
  preview: 'init --dry-run --json',
@@ -374,6 +407,17 @@ program
374
407
  execute: 'assistant <start|stop> --yes --json',
375
408
  confirmationRequired: true,
376
409
  },
410
+ localPanel: {
411
+ status: 'panel --status --json',
412
+ ask: 'panel --ask "<text>" --yes --json',
413
+ preview: 'panel --dry-run --json',
414
+ execute: 'panel --yes --json',
415
+ confirmationRequired: true,
416
+ opensLocalWindow: true,
417
+ loopbackOnly: true,
418
+ // 关掉窗口**不会**停掉助手——这件事必须能被机器读到,别让调用方以为退出即结束
419
+ keepsAssistantRunningAfterClose: true,
420
+ },
377
421
  messageChannelAuthentication: {
378
422
  loginPreview: 'login-wechat --dry-run --json',
379
423
  loginExecute: 'login-wechat --yes',
@@ -503,7 +547,28 @@ program
503
547
  aiDisabledByDefaultForExport: true,
504
548
  unknownMessageTypesPreserved: true,
505
549
  nonInteractiveTalkerResolution: true,
506
- mcpDefaultReadOnly: true,
550
+ // **这个名字曾经是假的**:MCP 那张表不是手写的只读清单,而是**助手工具表减去
551
+ // save_memory**,所以里面早就有写文件的 `export_chat` 和会把图片交给模型的
552
+ // `look_at_image`。如实改成 false,并把真正有出境的几样单独列出来(下面这个对象)。
553
+ // 读这一位的调用方按 false 走保守分支即可。
554
+ mcpDefaultReadOnly: false,
555
+ mcpSurface: {
556
+ // 原文只写了 "minus save_memory" —— 而那张表也排除了 `look_at_image`(MCP 只取文本,
557
+ // 它挂进侧信道的图没人接)。机器可读字段少说一句同样算不准,如实写全。
558
+ derivedFrom: 'assistant tools minus save_memory and look_at_image',
559
+ writesFiles: ['export_chat'],
560
+ // 逐个核实过(脚本里确实调云端模型的那些):
561
+ // who_owes_reply/search_chats → Jev;search_semantic → 阿里云百炼嵌入 + Jev 重排;draft_reply → Jev + DeepSeek。
562
+ // `look_at_image` **不在这里**:它靠侧信道把图交给助手自己的模型,而 MCP 只取工具返回的文本,
563
+ // 图没人接、回话却写着"你能看到它了"——所以它已经从 MCP 那张表里排除了(见 MCP_TOOL_DEFS)。
564
+ callsCloudModels: ['who_owes_reply', 'search_chats', 'search_semantic', 'draft_reply'],
565
+ // 机器调用**默认只给预览**、必须显式 confirm 才出境的那些(MCP 独有的边界)。
566
+ // **这里是四个,不是"只有 draft_reply"**:逐个读 handler 才发现那四个工具里
567
+ // 写的是同一句 `if (ctx.requiresConfirm && args.confirm !== true)`(1010/1041/1094/1170)。
568
+ // 原先只列了 draft_reply,而测试是照着这个字段的值抄的,于是两处一起错、谁都没红。
569
+ // 现在测试改成**从源码里数**(见 test/assistant-tools.test.ts),改一处不同步就会红。
570
+ requiresConfirm: ['search_chats', 'who_owes_reply', 'draft_reply', 'search_semantic'],
571
+ },
507
572
  mcpMessageLimit: { default: 100, max: 1000 },
508
573
  },
509
574
  };
@@ -908,9 +973,13 @@ const configCmd = program
908
973
  const configurableKeys = [
909
974
  'dbPath', 'decryptKey', 'dbPath3x', 'decryptKey3x', 'dataVersion', 'wxid',
910
975
  'ntDbPath', 'ntKey', 'ntSalt', 'contactDbPath', 'contactKey', 'contactSalt',
911
- 'vaultRepo', 'aiEngine', 'aiBaseUrl', 'aiModel', 'deepseekApiKey', 'wereadApiKey',
976
+ 'vaultRepo', 'aiEngine', 'aiBaseUrl', 'aiModel', 'deepseekApiKey', 'typesafeApiKey',
977
+ 'dashscopeApiKey', 'favPassphrase',
978
+ 'wereadApiKey',
912
979
  'assistantPrivacy', 'assistantWhitelist', 'assistantGroupWhitelist',
913
- 'assistantGroupRequireMention', 'dailySources', 'dailySourceCategories', 'dailyAiEnabled',
980
+ 'assistantGroupRequireMention', 'assistantFastRoute', 'assistantPanelUser', 'quickReplyContacts',
981
+ 'dailySources', 'dailySourceCategories',
982
+ 'dailyExcludeTopics', 'dailyAiEnabled',
914
983
  'emoticonSeed',
915
984
  ];
916
985
  function setConfigValue(key, value, quiet = false) {
@@ -1000,6 +1069,7 @@ configCmd
1000
1069
  console.log(`微信账号: ${config.wxid || chalk.gray('(未设置)')}`);
1001
1070
  console.log(`公众号日报来源: ${configService.get('dailySources') || chalk.gray('(全部公众号)')}`);
1002
1071
  console.log(`公众号日报 AI: ${configService.get('dailyAiEnabled') === 'false' ? '已关闭' : '已开启'}`);
1072
+ console.log(`公众号日报排除主题: ${configService.get('dailyExcludeTopics') || chalk.gray('(不排除)')}`);
1003
1073
  });
1004
1074
  configCmd
1005
1075
  .command('set <key> <value>')
@@ -1377,7 +1447,7 @@ syncCmd
1377
1447
  const result = await runSync(resolvedTalker, {
1378
1448
  since, full: !!opts.full, overlapSeconds: overlap, limit,
1379
1449
  scope: displayName,
1380
- read: (who, howMany) => chatService.getMessagesWithShards(who, howMany, 0),
1450
+ read: (who, howMany, from) => chatService.getMessagesWithShards(who, howMany, 0, from),
1381
1451
  });
1382
1452
  if (opts.json) {
1383
1453
  console.log(JSON.stringify({
@@ -2004,12 +2074,27 @@ program
2004
2074
  process.exit(1);
2005
2075
  }
2006
2076
  if (!opts.yes) {
2007
- const { confirmed } = await inquirer.prompt([{
2008
- type: 'confirm',
2009
- name: 'confirmed',
2010
- message: token ? '已有登录状态,确认启动重新登录吗?' : '确认启动人工扫码登录吗?',
2011
- default: false,
2012
- }]);
2077
+ // stdin 不是终端时(管道、计划任务、会话里的 `!` 前缀执行)inquirer 会抛 ExitPromptError——
2078
+ // 屏幕上是一条堆栈而不是一句话。这里把它变成解释:扫码登录必须在真实终端里跑。
2079
+ // 与守护进程那条 `--yes` 是同一类毛病:交互确认被塞进一个没人能回答的 stdin。
2080
+ let confirmed = false;
2081
+ try {
2082
+ const answer = await inquirer.prompt([{
2083
+ type: 'confirm',
2084
+ name: 'confirmed',
2085
+ message: token ? '已有登录状态,确认启动重新登录吗?' : '确认启动人工扫码登录吗?',
2086
+ default: false,
2087
+ }]);
2088
+ confirmed = !!answer.confirmed;
2089
+ }
2090
+ catch (error) {
2091
+ if (error?.name === 'ExitPromptError' || /force closed the prompt/.test(String(error?.message))) {
2092
+ console.log(chalk.yellow('当前环境没有可交互的终端。扫码登录必须在真实终端窗口里执行,'));
2093
+ console.log(chalk.yellow('或者加 --yes 跳过这个确认(扫码那一步仍然要人来做)。'));
2094
+ process.exit(1);
2095
+ }
2096
+ throw error;
2097
+ }
2013
2098
  if (!confirmed) {
2014
2099
  console.log(chalk.gray(token ? '保持当前登录状态' : '已取消'));
2015
2100
  return;
@@ -2033,6 +2118,20 @@ program
2033
2118
  if (session.status === 'confirmed') {
2034
2119
  console.log(chalk.green('\n✓ 登录成功!'));
2035
2120
  console.log(chalk.gray(' 消息通道登录状态已安全保存'));
2121
+ // 助手的白名单为空时**拒绝所有人**:登录完对着自己的机器人说话,什么都不会回来。
2122
+ // 所以这里必须说清"下一步该干什么"。
2123
+ // 只打在人看的这条路上——`--json` 那条路照旧不返回账号标识。
2124
+ console.log('');
2125
+ console.log(chalk.cyan(' 助手默认拒绝所有人(白名单为空)。'));
2126
+ // 这里**不**替用户写白名单:登录响应给的是 ilink_user_id,而白名单要的是入站消息里
2127
+ // 那个 from_user_id(文档里写成 <@im.wechat ID>),两者是不是同一个值**没有被验证过**
2128
+ // ——猜错的后果是"白名单非空、看着配好了、却仍然拒你"。真值在第一条被拒的消息里,
2129
+ // 所以让它自己现形(见 assistant log 的 [首次配置] 那行)。
2130
+ console.log(chalk.gray(' 启用方式:先 assistant start,从你的微信给机器人发一条消息,'));
2131
+ console.log(chalk.gray(' 日志里会出现一行 ' + chalk.cyan('[首次配置]')
2132
+ + chalk.gray(' 带着你自己的发送者 ID 和该执行的命令。')));
2133
+ console.log(chalk.gray(' 只想先看它「本来会怎么答」而不改行为:')
2134
+ + chalk.cyan('weflow-cli config set assistantFastRoute log'));
2036
2135
  }
2037
2136
  else {
2038
2137
  console.log(chalk.red(`\n✗ 登录失败: ${session.error || '超时'}`));
@@ -3640,13 +3739,18 @@ program
3640
3739
  '005_Reference/Tools',
3641
3740
  '005_Reference/Methods',
3642
3741
  '006_Projects',
3643
- '007_Wiki/Concepts',
3742
+ // 这里原先还有 `007_Wiki/Concepts`:概念页实际写在下面的顶层 `Wiki/Concepts`
3743
+ // (`compile_wiki` 的 OUTPUT_ROOT)。两个都列,就建出一个永远空着的目录。
3644
3744
  '008_MOC',
3645
3745
  '999_Archive',
3646
3746
  '_attachments',
3747
+ // `Wiki/Concepts` 是唯一真的有人写的 Wiki 子目录(`compile_wiki` 的
3748
+ // OUTPUT_ROOT)。这里曾经还列着 `Wiki/Entities` 与 `Wiki/Topics`——没有任何
3749
+ // 代码写它们,README 里那张表却把它们当成已有的介绍给用户。**声明了就要有东西
3750
+ // 往里写**,否则就是又一个"永远空着的目录"(同 `007_Wiki`)。
3751
+ // 两个知识库各一个目录:`Wiki/Concepts` 是公众号文章线的,`Chat/Concepts` 是聊天线的
3647
3752
  'Wiki/Concepts',
3648
- 'Wiki/Entities',
3649
- 'Wiki/Topics',
3753
+ 'Chat/Concepts',
3650
3754
  ];
3651
3755
  const files = [
3652
3756
  {
@@ -3654,7 +3758,10 @@ program
3654
3758
  content: JSON.stringify({
3655
3759
  newFileLocation: 'folder',
3656
3760
  newFileFolderPath: 'Sources',
3657
- attachmentFolderPath: 'Assets',
3761
+ // **`_attachments`,不是 `Assets`**:布局里建的就是 `_attachments`(这个
3762
+ // 字面量以前只在这里出现一次,是个孤例),而 init 从不建 `Assets`——
3763
+ // 于是附件会被 Obsidian 放进一个不存在的目录。
3764
+ attachmentFolderPath: '_attachments',
3658
3765
  showInlineTitle: false,
3659
3766
  }, null, 2),
3660
3767
  },
@@ -3700,11 +3807,20 @@ program
3700
3807
  '',
3701
3808
  '| 目录 | 说明 |',
3702
3809
  '|------|------|',
3703
- '| `Sources/WeChat/` | 公众号文章(按日期+主题分类) |',
3704
- '| `Wiki/Concepts/` | 概念页(手动或 AI 生成) |',
3705
- '| `Wiki/Entities/` | 实体页(公众号、作者等) |',
3706
- '| `Wiki/Topics/` | 主题总览页 |',
3810
+ '| `Sources/WeChat/<日期>/` | 原始文章(`daily` 与回填写入,连同正文图片) |',
3811
+ '| `001_Daily/<日期>.md` | 每天一页日记 |',
3812
+ '| `002_Literature/WeChat/<日期>/` | 阅读笔记(由 `Sources` 生成) |',
3813
+ // **两个知识库各写一行**:文章线(`Wiki/`)与聊天线(`Chat/`)是分开的两套,
3814
+ // 只列文章那套的话,用户在库里看到的说明与他实际拥有的东西对不上。
3815
+ '| `Wiki/Concepts/`、`Wiki/00-Overview.md` | **文章知识库**的概念页与索引(`wiki compile` 生成) |',
3816
+ '| `Chat/Concepts/`、`Chat/00-Overview.md` | **聊天知识库**的概念页与索引(`wiki compile --source output/chat-notes` 生成) |',
3817
+ '| `Sources/Chat/会话-<名字>.md` | 每个会话一张知识卡(`chat` 生成:聊了什么、有哪些人、欠着什么) |',
3707
3818
  '| `Templates/` | 模板文件 |',
3819
+ '| `_attachments/` | Obsidian 附件(`.obsidian/app.json` 的 `attachmentFolderPath` 指向这里) |',
3820
+ '| `000_Inbox` `003_Ideas` `004_Permanent` `005_Reference` `006_Projects` `008_MOC` `999_Archive` | **给你自己写笔记用的层**:`vault promote` 才会往里放东西,空着是设计而不是没做完 |',
3821
+ '',
3822
+ '> 阅读笔记与日记里各有一段 `dataview` 查询,所以这个 Vault 需要 **Dataview 社区插件**',
3823
+ '> (社区插件不进仓库,换机器要自己装一次)。',
3708
3824
  '',
3709
3825
  '## 快速查询',
3710
3826
  '',
@@ -3818,22 +3934,43 @@ program
3818
3934
  .addCommand(new Command('compile')
3819
3935
  .description('扫描文章 [[Wikilinks]] 聚合生成概念页')
3820
3936
  .option('-l, --limit <n>', '最多生成概念数', '20')
3821
- .option('--source <dir>', '文章目录', './output/biz-daily')
3937
+ .option('--min-refs <n>', '至少被几篇材料提到才建页(2 能滤掉新闻里的一次性实体)', '1')
3938
+ .option('--source <dir>', '材料目录(**必须含 [[概念]] — 说明 形状的链接**,否则聚合不出任何概念)', './output/biz-daily')
3822
3939
  .option('-o, --output <dir>', '概念页输出目录', './output/wechat-vault/Wiki/Concepts')
3823
3940
  .option('--api-key <key>', 'DeepSeek API key')
3941
+ .option('--refresh-sources', '只把已有概念页的"来源"段补齐成当前全部来源(本地重写,**不调用模型**)')
3942
+ .option('--cards <dir...>', '产卡目录,可写多个;默认所有 output/*-notes(与索引的引用数同一口径)')
3824
3943
  .option('--dry-run', '仅预览,不读取文章、调用 AI 或写入概念页')
3825
3944
  .option('--yes', '确认调用 AI 并生成概念页')
3826
3945
  .option('--json', '输出机器可读结果,不返回概念或本地路径')
3827
3946
  .action(async (opts) => {
3828
3947
  const pkgRoot = resolvePackageRoot();
3829
3948
  const script = join(pkgRoot, 'scripts', 'compile_wiki.py');
3949
+ if (opts.refreshSources) {
3950
+ // 本地补来源:**不调模型**,所以不传 apiKey、preview 里也不说"调用 AI"。
3951
+ // 但它会把已有页重写一遍(实测会动 358 张),照样走"先预览、再确认"那两步。
3952
+ await runConfirmedPythonMutation({
3953
+ action: 'wiki.refreshSources',
3954
+ script,
3955
+ args: ['--refresh-sources', '--output', opts.output,
3956
+ ...(opts.cards ?? []).flatMap((c) => ['--cards', c])],
3957
+ cliOptions: opts,
3958
+ preview: { readsLocalCards: true, rewritesExistingPages: true, usesAi: false },
3959
+ confirmationMessage: `确认把 ${opts.output} 里已有概念页的"来源"段补齐?(本地重写,不调用 AI)`,
3960
+ apiKey: opts.apiKey,
3961
+ timeout: 300_000,
3962
+ });
3963
+ return;
3964
+ }
3830
3965
  const limit = parseCliInteger(opts.limit, 'limit', 1, 1000, opts.json);
3966
+ const minRefs = parseCliInteger(opts.minRefs ?? '1', 'min-refs', 1, 50, opts.json);
3831
3967
  await runConfirmedPythonMutation({
3832
3968
  action: 'wiki.compile',
3833
3969
  script,
3834
- args: ['--limit', String(limit), '--source', opts.source, '--output', opts.output],
3970
+ args: ['--limit', String(limit), '--source', opts.source, '--output', opts.output,
3971
+ '--min-refs', String(minRefs)],
3835
3972
  cliOptions: opts,
3836
- preview: { limit, readsLocalArticles: true, usesAi: true, writesConceptPages: true },
3973
+ preview: { limit, minRefs, readsLocalArticles: true, usesAi: true, writesConceptPages: true },
3837
3974
  confirmationMessage: `确认调用 AI 并生成最多 ${limit} 个概念页?`,
3838
3975
  apiKey: opts.apiKey,
3839
3976
  timeout: 300_000,
@@ -3857,6 +3994,7 @@ program
3857
3994
  .option('--skip-html', '跳过 HTML 阅读器生成')
3858
3995
  .option('--skip-ai-report', '跳过 AI 深度阅读报告')
3859
3996
  .option('--no-ai', '关闭全部 AI 调用,保留抓取和本地输出')
3997
+ .option('--no-summary', '只判断不生成:biz_daily 不调 LLM 写摘要/标签/简报(判断仍走 Jev)')
3860
3998
  .option('--ai-report-range <n>', 'AI 报告覆盖最近 N 天', '1')
3861
3999
  .option('--dry-run', '仅预览步骤,不读取聊天数据、调用网络或写入文件')
3862
4000
  .option('--yes', '确认运行流水线')
@@ -3954,6 +4092,8 @@ program
3954
4092
  args.push('--skip-ai-report');
3955
4093
  if (noAi)
3956
4094
  args.push('--no-ai');
4095
+ if (opts.noSummary && !noAi)
4096
+ args.push('--no-summary');
3957
4097
  try {
3958
4098
  if (!opts.json)
3959
4099
  console.log(chalk.cyan('\n启动端到端流水线...\n'));
@@ -4941,15 +5081,27 @@ program
4941
5081
  .description('构建语义搜索索引')
4942
5082
  .option('--full', '全量重建')
4943
5083
  .option('--api-key <key>', 'DashScope embedding API key')
5084
+ .option('--days <n>', '聊天记录收最近多少天(默认 90)')
5085
+ .option('--article-days <n>', '日报文章收最近多少个日期目录(默认 30)')
4944
5086
  .option('--dry-run', '仅预览,不读取数据库、调用网络或写入索引')
4945
5087
  .option('--yes', '确认构建或重建索引')
4946
5088
  .option('--json', '输出 JSON 格式')
4947
5089
  .action(async (opts) => {
5090
+ // 窗口的**运行值由这里说了算**:默认值 + 校验 + 透传都在这一处,预览报的就是真跑的那些
5091
+ // (脚本文档里另有自己的默认值,只供手动直接跑脚本时用;CLI 这条路永远显式传参,
5092
+ // 所以两边万一漂了也不会影响实际行为)。
5093
+ // 上限 36500 天≈100 年:不设上限的后果是"给个 1e9 就把遍历变成一次全库穿透"。
5094
+ const chatDays = opts.days === undefined ? 90 : parseCliInteger(opts.days, 'days', 1, 36_500, opts.json);
5095
+ const articleDays = opts.articleDays === undefined
5096
+ ? 30 : parseCliInteger(opts.articleDays, 'article-days', 1, 36_500, opts.json);
5097
+ const windowText = `聊天最近 ${chatDays} 天、日报最近 ${articleDays} 个日期目录`;
4948
5098
  const preview = {
4949
5099
  success: true,
4950
5100
  dryRun: true,
4951
5101
  action: 'search-index.build',
4952
5102
  mode: opts.full ? 'full' : 'incremental',
5103
+ chatDays,
5104
+ articleDays,
4953
5105
  readsLocalData: true,
4954
5106
  usesCloudEmbedding: true,
4955
5107
  replacesExistingIndex: !!opts.full,
@@ -4958,7 +5110,8 @@ program
4958
5110
  if (opts.json)
4959
5111
  console.log(JSON.stringify(preview));
4960
5112
  else
4961
- console.log(chalk.cyan(`语义索引预览:${opts.full ? '全量重建' : '增量构建'},将读取本地数据并调用向量服务`));
5113
+ console.log(chalk.cyan(`语义索引预览:${opts.full ? '全量重建' : '增量构建'};`
5114
+ + `窗口=${windowText}(要改:--days / --article-days),将读取本地数据并调用向量服务`));
4962
5115
  return;
4963
5116
  }
4964
5117
  if (!opts.yes) {
@@ -4969,7 +5122,10 @@ program
4969
5122
  const { confirmed } = await inquirer.prompt([{
4970
5123
  type: 'confirm',
4971
5124
  name: 'confirmed',
4972
- message: `确认${opts.full ? '全量重建' : '增量构建'}语义索引?本地文本将发送到已配置的向量服务。`,
5125
+ // 窗口是**代价的一部分**:天数越多,发给向量服务的文本越多。所以确认语里要写出来,
5126
+ // 而不是只说"本地文本将发送"。
5127
+ message: `确认${opts.full ? '全量重建' : '增量构建'}语义索引(${windowText})?`
5128
+ + '这些文本将发送到已配置的向量服务。',
4973
5129
  default: false,
4974
5130
  }]);
4975
5131
  if (!confirmed) {
@@ -4982,7 +5138,8 @@ program
4982
5138
  const execFileAsync = promisify(execFile);
4983
5139
  const pkgRoot = resolvePackageRoot();
4984
5140
  const script = join(pkgRoot, 'scripts', 'semantic_search.py');
4985
- const args = [script, 'build'];
5141
+ const args = [script, 'build', '--days', String(chatDays),
5142
+ '--article-days', String(articleDays)];
4986
5143
  if (opts.full)
4987
5144
  args.push('--full');
4988
5145
  try {
@@ -5187,6 +5344,529 @@ program
5187
5344
  await runPython('scripts/annual_report.py', a, opts.apiKey);
5188
5345
  }
5189
5346
  });
5347
+ // decide —— 本机判断层。一个 state + 一批类型化问题,一次调用。
5348
+ // state 由调用方给,命令自己**不读任何本地数据**;但它是出境调用,所以照 awaiting/search
5349
+ // 的规矩:--dry-run 只校验、--yes 才真跑。这一版**不暴露成 MCP 工具**——那会变成
5350
+ // 远端 MCP 客户端能驱动本机往第三方发任意文本,属于要单独决策的出境面。
5351
+ program
5352
+ .command('decide')
5353
+ .description('本机判断层:一个 state + 一批类型化问题,一次调用返回带概率的答案')
5354
+ .option('--request <file>', '请求 JSON 的路径(含 state 与 questions)')
5355
+ .option('--over <glob>', '批量模式:对匹配到的每个文件问 --ask 里的每个问题(可重复)', (value, previous) => previous.concat([value]), [])
5356
+ .option('--ask <text>', '批量模式下的一个是非题,逐文件展开(可重复)', (value, previous) => previous.concat([value]), [])
5357
+ .option('--max-chars <n>', '批量模式下每个文件喂多少字符(默认 1500;它决定上限)', '1500')
5358
+ .option('--limit <n>', '批量模式最多取多少个文件(默认 50)', '50')
5359
+ .option('--model <name>', '模型别名,默认 jev-latest')
5360
+ .option('--dry-run', '只校验请求并回显形状,不调用决策模型')
5361
+ .option('--yes', '确认把 state 发送到已配置的决策模型服务')
5362
+ .option('--json', '输出机器可读结果(本命令的输出本来就是 JSON)')
5363
+ .action(async (opts) => {
5364
+ const { execFile } = await import('child_process');
5365
+ const { promisify } = await import('util');
5366
+ const { readFileSync } = await import('fs');
5367
+ const execFileAsync = promisify(execFile);
5368
+ const pkgRoot = resolvePackageRoot();
5369
+ const decideScript = join(pkgRoot, 'scripts', 'decide.py');
5370
+ // 预览里要报"这批几个问题",所以在这里读一眼;**校验仍然只由脚本做**,
5371
+ // 免得两处校验逻辑各说各话。
5372
+ // 两种输入方式:--request 一个文件、或 --over 批量展开。脚本自己会拒掉
5373
+ // 两个都没给的情况,所以这里不做重复校验。
5374
+ const batch = Array.isArray(opts.over) && opts.over.length > 0;
5375
+ let questionCount = null;
5376
+ if (batch) {
5377
+ questionCount = opts.over.length * opts.ask.length || null;
5378
+ }
5379
+ else if (opts.request) {
5380
+ try {
5381
+ const parsed = JSON.parse(readFileSync(opts.request, 'utf8'));
5382
+ if (parsed && typeof parsed === 'object' && parsed.questions
5383
+ && typeof parsed.questions === 'object') {
5384
+ questionCount = Object.keys(parsed.questions).length;
5385
+ }
5386
+ }
5387
+ catch {
5388
+ // 读不到或不是 JSON:交给脚本去报一个准确得多的错误。
5389
+ }
5390
+ }
5391
+ const limit = parseCliInteger(opts.limit, 'limit', 1, 500, opts.json);
5392
+ const maxChars = parseCliInteger(opts.maxChars, 'max-chars', 1, 100000, opts.json);
5393
+ const args = [decideScript,
5394
+ ...(batch
5395
+ ? [...opts.over.flatMap((g) => ['--over', g]),
5396
+ ...opts.ask.flatMap((a) => ['--ask', a]),
5397
+ '--max-chars', String(maxChars), '--limit', String(limit)]
5398
+ : ['--request', opts.request]),
5399
+ ...(opts.model ? ['--model', opts.model] : [])];
5400
+ if (opts.dryRun) {
5401
+ args.push('--dry-run');
5402
+ }
5403
+ else if (!opts.yes) {
5404
+ const preview = {
5405
+ success: false, dryRun: false, action: 'decide',
5406
+ code: 'CONFIRMATION_REQUIRED',
5407
+ questionCount, readsLocalData: batch, invokesAI: true,
5408
+ };
5409
+ if (opts.json) {
5410
+ console.log(JSON.stringify(preview));
5411
+ process.exit(1);
5412
+ }
5413
+ const { confirmed } = await inquirer.prompt([{
5414
+ type: 'confirm',
5415
+ name: 'confirmed',
5416
+ message: `确认把请求里的 state 发送到已配置的决策模型服务吗?`
5417
+ + (questionCount === null ? '' : `(${questionCount} 个问题)`),
5418
+ default: false,
5419
+ }]);
5420
+ if (!confirmed) {
5421
+ console.log(chalk.gray('已取消'));
5422
+ return;
5423
+ }
5424
+ }
5425
+ try {
5426
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
5427
+ timeout: 120_000, maxBuffer: 10 * 1024 * 1024,
5428
+ env: pythonProcessEnv(),
5429
+ });
5430
+ process.stdout.write(stdout);
5431
+ }
5432
+ catch (error) {
5433
+ const payload = error.stdout;
5434
+ if (payload && payload.trim()) {
5435
+ // 脚本自己已经给出了结构化的失败,原样透传比自己编一个更有用。
5436
+ process.stdout.write(payload);
5437
+ }
5438
+ else {
5439
+ console.log(JSON.stringify({ success: false, code: 'DECIDE_FAILED',
5440
+ action: 'decide', error: safeSubprocessError(error) }));
5441
+ }
5442
+ process.exit(1);
5443
+ }
5444
+ });
5445
+ // awaiting —— 谁在等我回话。读本地聊天并把正文发给决策模型,所以照 search 的规矩:
5446
+ // --dry-run 预览、--yes 才真跑。它**不写任何本地数据**,但仍是一次正文出境,
5447
+ // 而聊天正文比日报的文章正文敏感一档。
5448
+ program
5449
+ .command('awaiting')
5450
+ .description('谁在等我回话:逐会话判断有没有欠下的回复(读取聊天正文并调用决策模型)')
5451
+ .option('--days <n>', '只看最近多少天有动静的会话', '14')
5452
+ .option('--limit <n>', '最多判多少个会话', '40')
5453
+ .option('--min-prob <p>', 'waiting 概率低于此值不算欠账', '0.5')
5454
+ .option('--dry-run', '仅预览会判哪些会话、要发多少字符,不调用决策模型')
5455
+ .option('--yes', '确认把聊天正文发送到已配置的决策模型服务')
5456
+ .option('--json', '输出机器可读结果;执行仍需 --yes')
5457
+ .action(async (opts) => {
5458
+ const { execFile } = await import('child_process');
5459
+ const { promisify } = await import('util');
5460
+ const execFileAsync = promisify(execFile);
5461
+ const pkgRoot = resolvePackageRoot();
5462
+ const script = join(pkgRoot, 'scripts', 'reply_debt.py');
5463
+ const days = parseCliInteger(opts.days, 'days', 1, 3650, opts.json);
5464
+ const limit = parseCliInteger(opts.limit, 'limit', 1, 500, opts.json);
5465
+ const args = [script, '--days', String(days), '--limit', String(limit),
5466
+ '--min-prob', String(opts.minProb),
5467
+ ...(opts.json ? ['--json'] : [])];
5468
+ if (opts.dryRun) {
5469
+ // 交给脚本自己报数:它才知道要发多少字符。这一步只读本地,零出境。
5470
+ args.push('--dry-run');
5471
+ }
5472
+ else if (!opts.yes) {
5473
+ const preview = {
5474
+ success: false, dryRun: false, action: 'reply-debt.scan',
5475
+ code: 'CONFIRMATION_REQUIRED', days, limit,
5476
+ readsLocalChat: true, invokesAI: true, writesNothing: true,
5477
+ };
5478
+ if (opts.json) {
5479
+ console.log(JSON.stringify(preview));
5480
+ process.exit(1);
5481
+ }
5482
+ const { confirmed } = await inquirer.prompt([{
5483
+ type: 'confirm',
5484
+ name: 'confirmed',
5485
+ message: '确认把最近这些会话的聊天正文发送到已配置的决策模型服务吗?',
5486
+ default: false,
5487
+ }]);
5488
+ if (!confirmed) {
5489
+ console.log(chalk.gray('已取消'));
5490
+ return;
5491
+ }
5492
+ }
5493
+ try {
5494
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
5495
+ timeout: 600_000, maxBuffer: 50 * 1024 * 1024,
5496
+ env: pythonProcessEnv(),
5497
+ });
5498
+ process.stdout.write(stdout);
5499
+ }
5500
+ catch (error) {
5501
+ if (opts.json) {
5502
+ console.log(JSON.stringify({ success: false, code: 'AWAITING_FAILED', action: 'reply-debt.scan', error: safeSubprocessError(error) }));
5503
+ }
5504
+ else {
5505
+ console.error(chalk.red(`\n✗ ${safeSubprocessError(error)}`));
5506
+ }
5507
+ process.exit(1);
5508
+ }
5509
+ });
5510
+ // draft:判断在前、起草在后、**绝不发送**
5511
+ program
5512
+ .command('draft <talker>')
5513
+ .description('帮我起草回复:先判断(意图/风险/该不该给实质),再起草候选 —— **只产出文本,不发送**')
5514
+ .option('--count <n>', '要几条候选', '3')
5515
+ .option('--gate-commitment', '承诺未兑现时也拒绝起草(默认只在提示词里约束)')
5516
+ .option('--dry-run', '仅预览会发多少字符给哪两个模型,不调用')
5517
+ .option('--yes', '确认把这段对话发送到判断模型与生成模型')
5518
+ .option('--json', '输出机器可读结果;执行仍需 --yes')
5519
+ .action(async (talker, opts) => {
5520
+ const { execFile } = await import('child_process');
5521
+ const { promisify } = await import('util');
5522
+ const execFileAsync = promisify(execFile);
5523
+ const pkgRoot = resolvePackageRoot();
5524
+ const script = join(pkgRoot, 'scripts', 'draft_reply.py');
5525
+ const count = parseCliInteger(opts.count, 'count', 1, 5, opts.json);
5526
+ // **"确认过了"只用一个变量表示**(`--yes` 或交互回答"是"),参数在确认之后才拼。
5527
+ // 原来的写法是先拼参数、再问,于是**交互确认那条路是坏的**:答了"是",`args` 里
5528
+ // 却从没补上 `--yes`,脚本照样拒绝(用户看到的是"我确认了它却说没确认")。
5529
+ // 两者合成一个变量之后,这种漏传在结构上不可能再发生。
5530
+ let confirmed = !!opts.yes;
5531
+ if (!opts.dryRun && !confirmed) {
5532
+ const preview = {
5533
+ success: false, dryRun: false, action: 'draft-reply',
5534
+ code: 'CONFIRMATION_REQUIRED', talker, count,
5535
+ readsLocalChat: true, invokesAI: true, writesNothing: true, sendsNothing: true,
5536
+ };
5537
+ if (opts.json) {
5538
+ console.log(JSON.stringify(preview));
5539
+ process.exit(1);
5540
+ }
5541
+ const answer = await inquirer.prompt([{
5542
+ type: 'confirm',
5543
+ name: 'confirmed',
5544
+ message: '确认把这段对话发送到判断模型与生成模型吗?(只会产出候选文本,不会替你发送)',
5545
+ default: false,
5546
+ }]);
5547
+ confirmed = !!answer.confirmed;
5548
+ if (!confirmed) {
5549
+ console.log(chalk.gray('已取消'));
5550
+ return;
5551
+ }
5552
+ }
5553
+ const args = [script, '--talker', String(talker), '--count', String(count),
5554
+ // 脚本自己有第二道闸门(`--yes` 才真的把对话发出去),所以要透传
5555
+ ...(confirmed ? ['--yes'] : []),
5556
+ ...(opts.gateCommitment ? ['--gate-commitment'] : []),
5557
+ ...(opts.json ? ['--json'] : []),
5558
+ // dry-run 交给脚本自己报数(它才知道这段对话有多少字符),这一步只读本地、零出境
5559
+ ...(opts.dryRun ? ['--dry-run'] : [])];
5560
+ try {
5561
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
5562
+ timeout: 300_000, maxBuffer: 50 * 1024 * 1024,
5563
+ env: pythonProcessEnv(),
5564
+ });
5565
+ process.stdout.write(stdout);
5566
+ }
5567
+ catch (error) {
5568
+ if (opts.json) {
5569
+ console.log(JSON.stringify({ success: false, code: 'DRAFT_FAILED', action: 'draft-reply', error: safeSubprocessError(error) }));
5570
+ }
5571
+ else {
5572
+ console.error(chalk.red(`\n✗ ${safeSubprocessError(error)}`));
5573
+ }
5574
+ process.exit(1);
5575
+ }
5576
+ });
5577
+ // wiki lint:知识库体检(全本地:不联网、不调用模型)
5578
+ program.commands.find(c => c.name() === 'wiki')?.addCommand(new Command('lint')
5579
+ .description('知识库体检:断链、孤儿、空页、同名(本地,不联网)')
5580
+ .option('--dir <dir>', '概念页目录', './output/wechat-vault/Wiki/Concepts')
5581
+ .option('--json', '输出机器可读结果')
5582
+ .action(async (opts) => {
5583
+ const { execFile } = await import('child_process');
5584
+ const { promisify } = await import('util');
5585
+ const execFileAsync = promisify(execFile);
5586
+ const script = join(resolvePackageRoot(), 'scripts', 'wiki_lint.py');
5587
+ const args = [script, '--dir', String(opts.dir), ...(opts.json ? ['--json'] : [])];
5588
+ try {
5589
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
5590
+ timeout: 120_000, maxBuffer: 20 * 1024 * 1024, env: pythonProcessEnv(),
5591
+ });
5592
+ process.stdout.write(stdout);
5593
+ }
5594
+ catch (error) {
5595
+ if (opts.json)
5596
+ console.log(JSON.stringify({ success: false, code: 'WIKI_LINT_FAILED', error: safeSubprocessError(error) }));
5597
+ else
5598
+ console.error(chalk.red(`
5599
+ ✗ ${safeSubprocessError(error)}`));
5600
+ process.exit(1);
5601
+ }
5602
+ }));
5603
+ // chat-notes:把会话整理成知识卡,喂给 wiki compile 聚成概念页
5604
+ program
5605
+ .command('chat-notes')
5606
+ .description('把最近一段时间的会话整理成知识卡(之后用 wiki compile 聚成概念页)—— **只写本地文件,不发送**')
5607
+ .option('--days <n>', '看最近多少天', '30')
5608
+ .option('--limit <n>', '最多处理几个会话', '40')
5609
+ .option('--dry-run', '仅预览:几个会话、多少字符会发给生成模型(只读本地,零出境)')
5610
+ .option('--yes', '确认把对话发送给生成模型')
5611
+ .option('--json', '输出机器可读结果;执行仍需 --yes')
5612
+ .action(async (opts) => {
5613
+ const { execFile } = await import('child_process');
5614
+ const { promisify } = await import('util');
5615
+ const execFileAsync = promisify(execFile);
5616
+ const pkgRoot = resolvePackageRoot();
5617
+ const script = join(pkgRoot, 'scripts', 'chat_notes.py');
5618
+ const days = parseCliInteger(opts.days, 'days', 1, 3650, opts.json);
5619
+ const limit = parseCliInteger(opts.limit, 'limit', 1, 200, opts.json);
5620
+ // 与 `draft` 同一条纪律:**"确认过了"只用一个变量**,参数在确认之后才拼
5621
+ let confirmed = !!opts.yes;
5622
+ if (!opts.dryRun && !confirmed) {
5623
+ const preview = {
5624
+ success: false, dryRun: false, action: 'chat-notes',
5625
+ code: 'CONFIRMATION_REQUIRED', days, limit,
5626
+ readsLocalChat: true, invokesAI: true, writesLocalFiles: true, sendsNothing: true,
5627
+ };
5628
+ if (opts.json) {
5629
+ console.log(JSON.stringify(preview));
5630
+ process.exit(1);
5631
+ }
5632
+ const answer = await inquirer.prompt([{
5633
+ type: 'confirm',
5634
+ name: 'confirmed',
5635
+ message: '确认把最近这些会话的对话发送给生成模型吗?(只会产出本地知识卡,不会替你发送任何消息)',
5636
+ default: false,
5637
+ }]);
5638
+ confirmed = !!answer.confirmed;
5639
+ if (!confirmed) {
5640
+ console.log(chalk.gray('已取消'));
5641
+ return;
5642
+ }
5643
+ }
5644
+ const args = [script, '--days', String(days), '--limit', String(limit),
5645
+ ...(confirmed ? ['--yes'] : []),
5646
+ ...(opts.json ? ['--json'] : []),
5647
+ ...(opts.dryRun ? ['--dry-run'] : [])];
5648
+ try {
5649
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
5650
+ timeout: 900_000, maxBuffer: 50 * 1024 * 1024,
5651
+ env: pythonProcessEnv(),
5652
+ });
5653
+ process.stdout.write(stdout);
5654
+ }
5655
+ catch (error) {
5656
+ if (opts.json) {
5657
+ console.log(JSON.stringify({ success: false, code: 'CHAT_NOTES_FAILED', action: 'chat-notes', error: safeSubprocessError(error) }));
5658
+ }
5659
+ else {
5660
+ console.error(chalk.red(`\n✗ ${safeSubprocessError(error)}`));
5661
+ }
5662
+ process.exit(1);
5663
+ }
5664
+ });
5665
+ // user-notes:读你自己写的笔记(第四条来源,也是唯一读"人写的东西"的一条)
5666
+ program
5667
+ .command('user-notes')
5668
+ .description('读你自己写的笔记,长成知识卡(之后用 wiki compile 聚成概念页)—— **你的笔记只读,绝不修改**')
5669
+ .option('--limit <n>', '最多处理几篇笔记', '20')
5670
+ .option('--vault <dir>', '库目录', './output/wechat-vault')
5671
+ .option('--refresh', '已有卡的也重做')
5672
+ .option('--dry-run', '仅预览:几篇笔记会发给模型(只读本地,零出境)')
5673
+ .option('--yes', '确认把笔记发给生成模型')
5674
+ .option('--json', '输出机器可读结果;执行仍需 --yes')
5675
+ .action(async (opts) => {
5676
+ const { execFile } = await import('child_process');
5677
+ const { promisify } = await import('util');
5678
+ const execFileAsync = promisify(execFile);
5679
+ const script = join(resolvePackageRoot(), 'scripts', 'user_notes.py');
5680
+ const limit = parseCliInteger(opts.limit, 'limit', 1, 500, opts.json);
5681
+ let confirmed = !!opts.yes;
5682
+ if (!opts.dryRun && !confirmed) {
5683
+ const preview = {
5684
+ success: false, dryRun: false, action: 'user-notes',
5685
+ code: 'CONFIRMATION_REQUIRED', limit,
5686
+ readsLocalData: true, invokesAI: true, writesLocalFiles: true,
5687
+ modifiesYourNotes: false, sendsNothing: true,
5688
+ };
5689
+ if (opts.json) {
5690
+ console.log(JSON.stringify(preview));
5691
+ process.exit(1);
5692
+ }
5693
+ const answer = await inquirer.prompt([{
5694
+ type: 'confirm',
5695
+ name: 'confirmed',
5696
+ message: '确认把你自己写的这些笔记发给生成模型吗?(你的笔记只读,产出的卡另存;只写本地文件)',
5697
+ default: false,
5698
+ }]);
5699
+ confirmed = !!answer.confirmed;
5700
+ if (!confirmed) {
5701
+ console.log(chalk.gray('已取消'));
5702
+ return;
5703
+ }
5704
+ }
5705
+ const args = [script, '--limit', String(limit), '--vault', String(opts.vault),
5706
+ ...(opts.refresh ? ['--refresh'] : []),
5707
+ ...(confirmed ? ['--yes'] : []),
5708
+ ...(opts.json ? ['--json'] : []),
5709
+ ...(opts.dryRun ? ['--dry-run'] : [])];
5710
+ try {
5711
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
5712
+ timeout: 1_800_000, maxBuffer: 50 * 1024 * 1024,
5713
+ env: pythonProcessEnv(),
5714
+ });
5715
+ process.stdout.write(stdout);
5716
+ }
5717
+ catch (error) {
5718
+ if (opts.json) {
5719
+ console.log(JSON.stringify({ success: false, code: 'USER_NOTES_FAILED', action: 'user-notes',
5720
+ error: safeSubprocessError(error), detail: String(error?.stdout || '').slice(0, 2000) }));
5721
+ }
5722
+ else {
5723
+ console.error(chalk.red(`\n✗ ${safeSubprocessError(error)}`));
5724
+ }
5725
+ process.exit(1);
5726
+ }
5727
+ });
5728
+ // fav-notes:把微信收藏里的文章变成知识卡(第三条来源)
5729
+ program
5730
+ .command('fav-notes')
5731
+ .description('把微信收藏里的文章整理成知识卡(之后用 wiki compile 聚成概念页)—— **只写本地文件,不发送**')
5732
+ .option('--limit <n>', '最多处理几条收藏(按收藏时间倒序)', '10')
5733
+ .option('--refresh', '已有卡的也重做(默认增量:只做还没卡的)')
5734
+ .option('--dry-run', '仅预览:几条、多少要联网抓(只读本地,零出境)')
5735
+ .option('--yes', '确认把收藏的正文发给生成模型')
5736
+ .option('--json', '输出机器可读结果;执行仍需 --yes')
5737
+ .action(async (opts) => {
5738
+ const { execFile } = await import('child_process');
5739
+ const { promisify } = await import('util');
5740
+ const execFileAsync = promisify(execFile);
5741
+ const script = join(resolvePackageRoot(), 'scripts', 'fav_notes.py');
5742
+ const limit = parseCliInteger(opts.limit, 'limit', 1, 500, opts.json);
5743
+ // 与另外两条线同一条纪律:**「确认过了」只用一个变量**,参数在确认之后才拼
5744
+ let confirmed = !!opts.yes;
5745
+ if (!opts.dryRun && !confirmed) {
5746
+ const preview = {
5747
+ success: false, dryRun: false, action: 'fav-notes',
5748
+ code: 'CONFIRMATION_REQUIRED', limit,
5749
+ readsLocalData: true, fetchesArticleUrls: true, invokesAI: true,
5750
+ writesLocalFiles: true, sendsNothing: true,
5751
+ };
5752
+ if (opts.json) {
5753
+ console.log(JSON.stringify(preview));
5754
+ process.exit(1);
5755
+ }
5756
+ const answer = await inquirer.prompt([{
5757
+ type: 'confirm',
5758
+ name: 'confirmed',
5759
+ message: '确认把这些收藏的正文发给生成模型吗?(只写本地知识卡;正文取不到时会去抓文章链接)',
5760
+ default: false,
5761
+ }]);
5762
+ confirmed = !!answer.confirmed;
5763
+ if (!confirmed) {
5764
+ console.log(chalk.gray('已取消'));
5765
+ return;
5766
+ }
5767
+ }
5768
+ const args = [script, '--limit', String(limit),
5769
+ ...(opts.refresh ? ['--refresh'] : []),
5770
+ ...(confirmed ? ['--yes'] : []),
5771
+ ...(opts.json ? ['--json'] : []),
5772
+ ...(opts.dryRun ? ['--dry-run'] : [])];
5773
+ try {
5774
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
5775
+ timeout: 1_800_000, maxBuffer: 50 * 1024 * 1024,
5776
+ env: pythonProcessEnv(),
5777
+ });
5778
+ process.stdout.write(stdout);
5779
+ }
5780
+ catch (error) {
5781
+ if (opts.json) {
5782
+ console.log(JSON.stringify({ success: false, code: 'FAV_NOTES_FAILED', action: 'fav-notes',
5783
+ error: safeSubprocessError(error), detail: String(error?.stdout || '').slice(0, 2000) }));
5784
+ }
5785
+ else {
5786
+ console.error(chalk.red(`\n✗ ${safeSubprocessError(error)}`));
5787
+ }
5788
+ process.exit(1);
5789
+ }
5790
+ });
5791
+ // article-notes:从文章笔记里提炼概念,喂给 wiki compile
5792
+ program
5793
+ .command('article-notes')
5794
+ .description('从 Vault 的文章笔记里提炼概念(之后用 wiki compile 聚成概念页)—— **只写本地文件,不发送**')
5795
+ .option('--source <dir>', '文章笔记目录', './output/wechat-vault/002_Literature')
5796
+ .option('--limit <n>', '最多处理几篇(按发布时间倒序)', '30')
5797
+ .option('--since <date>', '只看这个日期之后的(YYYY-MM-DD)—— 比 --limit 精确,例:"9 月的都跑一遍"')
5798
+ .option('--until <date>', '只看这个日期之前的(YYYY-MM-DD)')
5799
+ .option('--topic <name>', '只做这些主题,可重复或用逗号分隔(如 --topic AI);不传=全部', (value, previous = []) => [...previous, value], [])
5800
+ .option('--workers <n>', '并发问几篇(只并行"问",写仍是串行)', '6')
5801
+ .option('--dry-run', '仅预览:几篇、多少字会发给生成模型(只读本地,零出境)')
5802
+ .option('--refresh-summaries', '只按原笔记重算已有卡片的摘要(本地,不调用模型、不必 --yes)')
5803
+ .option('--yes', '确认把文章发给生成模型')
5804
+ .option('--json', '输出机器可读结果;执行仍需 --yes')
5805
+ .action(async (opts) => {
5806
+ const { execFile } = await import('child_process');
5807
+ const { promisify } = await import('util');
5808
+ const execFileAsync = promisify(execFile);
5809
+ const pkgRoot = resolvePackageRoot();
5810
+ const script = join(pkgRoot, 'scripts', 'article_notes.py');
5811
+ const limit = parseCliInteger(opts.limit, 'limit', 1, 5000, opts.json);
5812
+ // 主题筛选:这一步是**纯花钱的**(一篇一次调用),而实测主题分布是新闻 57% /
5813
+ // AI 28% / 学术 13%,所以"只做 AI"是省掉一半以上花费的那个开关。它必须出现在
5814
+ // 确认框里——不然用户看到的是"要花 1.6 万次调用",而不是"要花 4 千次"。
5815
+ const topics = opts.topic
5816
+ .flatMap(value => String(value).split(','))
5817
+ .map(value => value.trim())
5818
+ .filter(Boolean);
5819
+ const workers = parseCliInteger(opts.workers, 'workers', 1, 64, opts.json);
5820
+ // 与 `chat-notes`/`draft` 同一条纪律:**"确认过了"只用一个变量**,参数在确认之后才拼
5821
+ let confirmed = !!opts.yes;
5822
+ if (!opts.dryRun && !opts.refreshSummaries && !confirmed) {
5823
+ const preview = {
5824
+ success: false, dryRun: false, action: 'article-notes',
5825
+ code: 'CONFIRMATION_REQUIRED', source: opts.source, limit, topics,
5826
+ readsLocalData: true, invokesAI: true, writesLocalFiles: true, sendsNothing: true,
5827
+ };
5828
+ if (opts.json) {
5829
+ console.log(JSON.stringify(preview));
5830
+ process.exit(1);
5831
+ }
5832
+ const answer = await inquirer.prompt([{
5833
+ type: 'confirm',
5834
+ name: 'confirmed',
5835
+ message: '确认把这些文章(只问概念,摘要照抄)发给生成模型吗?(只写本地知识卡,不会发送任何消息)',
5836
+ default: false,
5837
+ }]);
5838
+ confirmed = !!answer.confirmed;
5839
+ if (!confirmed) {
5840
+ console.log(chalk.gray('已取消'));
5841
+ return;
5842
+ }
5843
+ }
5844
+ const args = [script, '--source', String(opts.source), '--limit', String(limit),
5845
+ ...(opts.since ? ['--since', String(opts.since)] : []),
5846
+ ...(opts.until ? ['--until', String(opts.until)] : []),
5847
+ ...(topics.length ? ['--topic', topics.join(',')] : []),
5848
+ '--workers', String(workers),
5849
+ ...(opts.refreshSummaries ? ['--refresh-summaries'] : []),
5850
+ ...(confirmed ? ['--yes'] : []),
5851
+ ...(opts.json ? ['--json'] : []),
5852
+ ...(opts.dryRun ? ['--dry-run'] : [])];
5853
+ try {
5854
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
5855
+ timeout: 1_800_000, maxBuffer: 50 * 1024 * 1024,
5856
+ env: pythonProcessEnv(),
5857
+ });
5858
+ process.stdout.write(stdout);
5859
+ }
5860
+ catch (error) {
5861
+ if (opts.json) {
5862
+ console.log(JSON.stringify({ success: false, code: 'ARTICLE_NOTES_FAILED', action: 'article-notes', error: safeSubprocessError(error) }));
5863
+ }
5864
+ else {
5865
+ console.error(chalk.red(`\n✗ ${safeSubprocessError(error)}`));
5866
+ }
5867
+ process.exit(1);
5868
+ }
5869
+ });
5190
5870
  // todos
5191
5871
  const todosCmd = program
5192
5872
  .command('todos')
@@ -5303,6 +5983,7 @@ program
5303
5983
  .option('--dry-run', '仅预览文章,不调用 AI 或写入日报')
5304
5984
  .option('--yes', '确认执行日报生成;JSON 模式需要此选项')
5305
5985
  .option('--no-ai', '关闭本次日报的所有 AI 处理')
5986
+ .option('--no-summary', '只判断不生成:不调 LLM 写摘要/标签/简报(主题与相关度仍由 Jev 判断),无需 DeepSeek key')
5306
5987
  .option('--json', '输出机器可读的最终结果;运行日志写入 stderr')
5307
5988
  .action(async (opts) => {
5308
5989
  const { spawn } = await import('child_process');
@@ -5338,6 +6019,9 @@ program
5338
6019
  const args = [pipeline, '--date', targetDate, '--engine', noAi ? 'local' : 'deepseek', '--interest', 'AI', '--skip-wiki'];
5339
6020
  if (noAi)
5340
6021
  args.push('--no-ai');
6022
+ // 与 --no-ai 不同:这一条保留判断(Jev),只关掉 LLM 的文字生成。
6023
+ if (opts.noSummary && !noAi)
6024
+ args.push('--no-summary');
5341
6025
  for (const source of opts.source || [])
5342
6026
  args.push('--source', source);
5343
6027
  if (opts.skipClassify)
@@ -5529,7 +6213,7 @@ assistantCmd
5529
6213
  process.exit(1);
5530
6214
  }
5531
6215
  const { startDaemon } = await import('../src/services/assistantDaemon.js');
5532
- const r = startDaemon();
6216
+ const r = await startDaemon();
5533
6217
  if (opts.json) {
5534
6218
  console.log(JSON.stringify(r.started
5535
6219
  ? { success: true, started: true, pid: r.pid, ...preview }
@@ -5587,13 +6271,23 @@ assistantCmd
5587
6271
  const token = configService.get('wechatOcToken');
5588
6272
  const aiKey = configService.get('deepseekApiKey');
5589
6273
  const { privacyGate } = await import('../src/services/assistantPrivacy.js');
6274
+ const { readEndpoint } = await import('../src/panel/endpoint.js');
5590
6275
  const wl = String(configService.get('assistantWhitelist') || '').trim();
5591
6276
  const groups = String(configService.get('assistantGroupWhitelist') || '').trim();
6277
+ // 端点是**运行态**的权威来源:它由守护进程原子写出,而且读取端会探活(进程没了就当没有)。
6278
+ // 它能回答一个配置回答不了的问题:**通道到底接上了没有**。
6279
+ // `messageChannelLoggedIn` 说的只是"配了 token 吗"——token 配了而进程以本机模式跑着,
6280
+ // 这两个字段会一个 true 一个 false,今天完全没有别的办法区分。
6281
+ const endpoint = alive ? readEndpoint() : null;
5592
6282
  if (opts.json) {
5593
6283
  console.log(JSON.stringify({
5594
6284
  success: true,
5595
6285
  daemonRunning: alive,
5596
6286
  messageChannelLoggedIn: !!token,
6287
+ channelActive: !!endpoint && endpoint.channel === 'wechat',
6288
+ mode: endpoint ? endpoint.channel : null,
6289
+ panelPort: endpoint ? endpoint.port : null,
6290
+ memoryBucket: endpoint ? endpoint.memoryBucket : null,
5597
6291
  aiConfigured: privacyGate.isLocalInference() || !!aiKey,
5598
6292
  localInference: privacyGate.isLocalInference(),
5599
6293
  privacyMode: privacyGate.mode(),
@@ -5605,6 +6299,13 @@ assistantCmd
5605
6299
  }
5606
6300
  console.log(`守护进程: ${alive ? chalk.green(`运行中 (pid ${pid})`) : chalk.gray('未运行')}`);
5607
6301
  console.log(`消息通道: ${token ? chalk.green('已登录') : chalk.red('未登录 (先 login-wechat)')}`);
6302
+ if (endpoint) {
6303
+ console.log(` └ 实际接入: ${endpoint.channel === 'wechat' ? chalk.green('微信') : chalk.yellow('仅本机入口')}`
6304
+ + `|本机入口 http://127.0.0.1:${endpoint.port}|记忆桶 ${endpoint.memoryBucket}`);
6305
+ }
6306
+ else if (alive) {
6307
+ console.log(chalk.gray(' └ 本机入口未启动(看 assistant log 里的“本机入口启动失败”)'));
6308
+ }
5608
6309
  console.log(`LLM 大脑: ${aiKey ? chalk.green('DeepSeek 已配置') : chalk.gray('未配置 (config set deepseekApiKey)')}`);
5609
6310
  console.log(`隐私模式: ${chalk.cyan(privacyGate.mode())}${privacyGate.isLocalInference() ? chalk.green(' (本地推理, 数据不出境)') : chalk.gray(' (工具结果脱敏后出境)')}`);
5610
6311
  console.log(`白名单: ${wl ? chalk.green(`${wl.split(/[,;\s]+/).filter(Boolean).length} 人`) : chalk.red('未设置 (默认拒绝所有人; config set assistantWhitelist "<@im.wechat ID>")')}`);
@@ -5636,6 +6337,30 @@ assistantCmd
5636
6337
  }
5637
6338
  console.log(content);
5638
6339
  });
6340
+ assistantCmd
6341
+ .command('trace')
6342
+ .description('看助手最近几轮是怎么走的(判断层 → 工具 → 答复),含模型返回的推理内容')
6343
+ .option('-n, --last <number>', '看最近几轮', '3')
6344
+ .option('--json', '输出结构化轨迹')
6345
+ .action(async (opts) => {
6346
+ const { readTurns, describeTurn, traceFile } = await import('../src/services/assistantTrace.js');
6347
+ const count = parseCliInteger(opts.last, 'last', 1, 50, opts.json);
6348
+ const turns = readTurns(count);
6349
+ if (opts.json) {
6350
+ console.log(JSON.stringify({ success: true, file: traceFile(), count: turns.length, turns }));
6351
+ return;
6352
+ }
6353
+ if (!turns.length) {
6354
+ console.log('还没有轨迹。助手收到消息之后才会有;文件:' + traceFile());
6355
+ return;
6356
+ }
6357
+ console.log(traceFile());
6358
+ for (const turn of turns) {
6359
+ for (const line of describeTurn(turn))
6360
+ console.log(line);
6361
+ console.log('');
6362
+ }
6363
+ });
5639
6364
  assistantCmd
5640
6365
  .command('run')
5641
6366
  .description('前台运行 (调试用; 常驻请用 start)')
@@ -5679,6 +6404,206 @@ assistantCmd
5679
6404
  console.log(chalk.cyan('第二大脑前台运行中... (Ctrl+C 退出)'));
5680
6405
  await assistant.start((line) => console.log(chalk.gray(line)));
5681
6406
  });
6407
+ /**
6408
+ * 本机面板:不登录微信也能跟助手说话的入口。
6409
+ *
6410
+ * **它只是客户端**:消息走守护进程的回环端点,于是两个入口共用一个大脑
6411
+ * (同一份记忆、同一条配额、同一条串行队列)。面板自己起一个 `AssistantService` 会变成
6412
+ * 第二个大脑——那是这个功能最容易走错的一步。
6413
+ */
6414
+ program
6415
+ .command('panel')
6416
+ .description('打开本机面板窗口 (Electron 悬浮球;没有 Electron 就降级用 Edge --app)')
6417
+ .option('--status', '只看状态,不打开窗口')
6418
+ .option('--ask <text>', '在命令行里问一句(面板减去像素)')
6419
+ .option('--dry-run', '仅预览,不启动窗口或守护进程')
6420
+ .option('--yes', '确认打开')
6421
+ .option('--json', '输出 JSON 格式')
6422
+ .action(async (opts) => {
6423
+ const { readEndpoint, endpointFile } = await import('../src/panel/endpoint.js');
6424
+ const { panelStatus, panelAsk, panelPair } = await import('../src/panel/client.js');
6425
+ // ---------------------------------------------------------- 只读:状态
6426
+ if (opts.status) {
6427
+ const endpoint = readEndpoint();
6428
+ if (!endpoint) {
6429
+ if (opts.json)
6430
+ console.log(JSON.stringify({ success: false, code: 'PANEL_NOT_RUNNING', error: '本机入口没在运行(先 weflow-cli assistant start)' }));
6431
+ else
6432
+ console.log(chalk.gray('本机入口没在运行(先 weflow-cli assistant start)'));
6433
+ process.exit(1);
6434
+ }
6435
+ const r = await panelStatus(endpoint);
6436
+ if (opts.json) {
6437
+ console.log(JSON.stringify(r.ok
6438
+ ? { success: true, port: endpoint.port, ...r.data }
6439
+ : { success: false, code: r.code }));
6440
+ return;
6441
+ }
6442
+ if (!r.ok) {
6443
+ console.log(chalk.yellow(`读状态失败: ${r.code}`));
6444
+ return;
6445
+ }
6446
+ const d = r.data;
6447
+ console.log(`本机入口: ${chalk.green(`http://127.0.0.1:${endpoint.port}`)}`);
6448
+ console.log(`接入: ${d.channelActive ? '微信 + 本机' : '仅本机入口'}|今日 ${d.quota.used}/${d.quota.limit}|LLM ${d.aiConfigured ? '已配置' : '未配置'}`);
6449
+ console.log(chalk.gray(`记忆桶: ${d.memoryBucket}`));
6450
+ if (d.memoryNote)
6451
+ console.log(chalk.gray(` ${d.memoryNote}`));
6452
+ return;
6453
+ }
6454
+ // ---------------------------------------------------------- 在命令行里问一句
6455
+ if (opts.ask !== undefined) {
6456
+ const text = String(opts.ask || '').trim();
6457
+ if (!text) {
6458
+ if (opts.json)
6459
+ console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error: '--ask 不能为空' }));
6460
+ else
6461
+ console.log(chalk.red('--ask 不能为空'));
6462
+ process.exit(1);
6463
+ }
6464
+ if (opts.json && !opts.yes) {
6465
+ console.log(JSON.stringify({ success: false, code: 'CONFIRMATION_REQUIRED', error: '这会真的问助手(可能调用 AI 并占用配额),用 --yes 确认' }));
6466
+ process.exit(1);
6467
+ }
6468
+ const endpoint = readEndpoint();
6469
+ if (!endpoint) {
6470
+ if (opts.json)
6471
+ console.log(JSON.stringify({ success: false, code: 'PANEL_NOT_RUNNING', error: '本机入口没在运行(先 weflow-cli assistant start)' }));
6472
+ else
6473
+ console.log(chalk.yellow('本机入口没在运行(先 weflow-cli assistant start)'));
6474
+ process.exit(1);
6475
+ }
6476
+ const r = await panelAsk(endpoint, text);
6477
+ if (opts.json) {
6478
+ console.log(JSON.stringify(r.ok
6479
+ ? { success: true, status: r.data.status, reply: r.data.reply }
6480
+ : { success: false, code: r.code, error: r.error }));
6481
+ if (!r.ok)
6482
+ process.exit(1);
6483
+ return;
6484
+ }
6485
+ console.log(r.ok ? r.data.reply : chalk.yellow(`没成: ${r.code}`));
6486
+ return;
6487
+ }
6488
+ // ---------------------------------------------------------- 打开窗口
6489
+ const { resolveElectronBinary, resolveBrowserBinary, electronLaunchArgs, browserLaunchArgs, browserProfileDir } = await import('../src/panel/launch.js');
6490
+ const { isDaemonAlive, startDaemon } = await import('../src/services/assistantDaemon.js');
6491
+ const { weflowHome } = await import('../src/panel/endpoint.js');
6492
+ const electron = resolveElectronBinary({
6493
+ packageRoot: resolvePackageRoot(),
6494
+ localAppData: process.env.LOCALAPPDATA,
6495
+ programFiles: process.env.ProgramFiles,
6496
+ });
6497
+ const browser = electron.found ? { found: null } : resolveBrowserBinary({
6498
+ programFiles: process.env.ProgramFiles,
6499
+ programFilesX86: process.env['ProgramFiles(x86)'],
6500
+ localAppData: process.env.LOCALAPPDATA,
6501
+ });
6502
+ const shell = electron.found ? 'electron' : browser.found ? 'browser' : 'none';
6503
+ const running = isDaemonAlive().alive;
6504
+ const preview = {
6505
+ action: 'panel.open',
6506
+ shell,
6507
+ daemonRunning: running,
6508
+ // 明确声明后果:面板会需要一个守护进程;关掉窗口**不会**顺手把它停掉
6509
+ willStartDaemon: !running,
6510
+ keepsRunningAfterClose: true,
6511
+ endpointFile: endpointFile(),
6512
+ };
6513
+ if (opts.dryRun) {
6514
+ if (opts.json)
6515
+ console.log(JSON.stringify({ success: true, dryRun: true, ...preview }));
6516
+ else
6517
+ console.log(chalk.cyan(`将打开面板(${shell === 'none' ? '没有可用的窗口程序,只会打印地址' : shell})`
6518
+ + `${running ? '' : ',并启动本机助手守护进程'}`));
6519
+ return;
6520
+ }
6521
+ if (opts.json && !opts.yes) {
6522
+ console.log(JSON.stringify({ success: false, code: 'CONFIRMATION_REQUIRED', error: '使用 --yes 确认打开面板', ...preview }));
6523
+ process.exit(1);
6524
+ }
6525
+ // 守护进程没起来就先起,并**轮询到真的就绪**——不要相信 spawn 的返回值
6526
+ // (`startDaemon` 里没有 `child.unref()`,stdio 又是 pipe;照抄 daily-reader 的 30×100ms 循环)
6527
+ if (!running) {
6528
+ const started = await startDaemon();
6529
+ if (!started.started && !started.error?.includes('已经运行')) {
6530
+ const msg = `本机助手启动失败: ${started.error ?? '未知原因'}`;
6531
+ if (opts.json)
6532
+ console.log(JSON.stringify({ success: false, code: 'ASSISTANT_START_FAILED', error: msg, ...preview }));
6533
+ else
6534
+ console.log(chalk.red(msg));
6535
+ process.exit(1);
6536
+ }
6537
+ }
6538
+ let endpoint = readEndpoint();
6539
+ for (let attempt = 0; attempt < 30 && !endpoint; attempt++) {
6540
+ await new Promise((resolve) => setTimeout(resolve, 100));
6541
+ endpoint = readEndpoint();
6542
+ }
6543
+ if (!endpoint) {
6544
+ const msg = '本机入口还没就绪(看 weflow-cli assistant log 里的“本机入口启动失败”,端口可能被占)';
6545
+ if (opts.json)
6546
+ console.log(JSON.stringify({ success: false, code: 'PANEL_NOT_READY', error: msg, ...preview }));
6547
+ else
6548
+ console.log(chalk.red(msg));
6549
+ process.exit(1);
6550
+ }
6551
+ const { spawn } = await import('child_process');
6552
+ let launched;
6553
+ let url = null;
6554
+ if (electron.found) {
6555
+ // argv 里**什么都没有**:不带 token、不带端口、不带 URL(命令行对同机任何进程可见)
6556
+ const child = spawn(electron.found, electronLaunchArgs(join(resolvePackageRoot(), 'resources', 'panel')), {
6557
+ detached: true, stdio: 'ignore', windowsHide: true,
6558
+ });
6559
+ child.unref();
6560
+ launched = 'electron';
6561
+ }
6562
+ else if (browser.found) {
6563
+ // 浏览器那条路只能靠一次性口令:它单次使用、60 秒过期,所以进命令行与浏览器历史都不算泄漏
6564
+ const pair = await panelPair(endpoint);
6565
+ if (!pair.ok) {
6566
+ const msg = `换一次性口令失败: ${pair.code}`;
6567
+ if (opts.json)
6568
+ console.log(JSON.stringify({ success: false, code: 'PAIR_FAILED', error: msg, ...preview }));
6569
+ else
6570
+ console.log(chalk.red(msg));
6571
+ process.exit(1);
6572
+ }
6573
+ url = pair.data.url;
6574
+ const child = spawn(browser.found, browserLaunchArgs({ url, profileDir: browserProfileDir(weflowHome()) }), {
6575
+ detached: true, stdio: 'ignore', windowsHide: true,
6576
+ });
6577
+ child.unref();
6578
+ launched = 'browser';
6579
+ }
6580
+ else {
6581
+ // 两条路都没有:**别静默什么都不做**,把地址交出去
6582
+ const pair = await panelPair(endpoint);
6583
+ url = pair.ok ? pair.data.url : `http://127.0.0.1:${endpoint.port}/panel`;
6584
+ launched = 'none';
6585
+ }
6586
+ if (opts.json) {
6587
+ // 展开在前、实际结果在后:`preview.shell` 是"打算用哪个",这里是"实际用了哪个",
6588
+ // 两者可能不同(例如打算用 Electron 但 spawn 之后没起来)——以后者为准
6589
+ console.log(JSON.stringify({ ...preview, success: true, shell: launched, port: endpoint.port, url }));
6590
+ return;
6591
+ }
6592
+ if (launched === 'electron') {
6593
+ console.log(chalk.green(`✓ 面板已启动(记忆桶 ${endpoint.memoryBucket})`));
6594
+ console.log(chalk.gray(' 关掉窗口只是隐藏;助手仍在后台跑,要停它用 weflow-cli assistant stop'));
6595
+ }
6596
+ else if (launched === 'browser') {
6597
+ console.log(chalk.green('✓ 已用浏览器打开面板窗口'));
6598
+ console.log(chalk.yellow(' 注意:这条路给不了悬浮球——浏览器窗口不能无边框/置顶,也没有托盘与快捷键。'));
6599
+ console.log(chalk.gray(' 想要真·悬浮球:npm i -g electron 之后再跑一次'));
6600
+ }
6601
+ else {
6602
+ console.log(chalk.yellow('没找到 Electron,也没找到 Edge/Chrome。自己打开这个地址即可:'));
6603
+ console.log(chalk.cyan(` ${url}`));
6604
+ console.log(chalk.gray(' 想要悬浮球:npm i -g electron'));
6605
+ }
6606
+ });
5682
6607
  program
5683
6608
  .command('daily-stats')
5684
6609
  .description('统计公众号推送频率与日报处理频率')
@@ -5872,10 +6797,10 @@ program
5872
6797
  ['wechat.search_public', '搜索全网公众号文章'],
5873
6798
  ['wechat.export_messages', '按稳定数据契约读取会话消息'],
5874
6799
  ];
5875
- const { MCP_READ_ONLY_TOOL_DEFS } = await import('../src/services/assistantTools.js');
6800
+ const { MCP_TOOL_DEFS } = await import('../src/services/assistantTools.js');
5876
6801
  const all = [
5877
6802
  ...STATIC_TOOLS,
5878
- ...MCP_READ_ONLY_TOOL_DEFS.filter(t => t.function.name !== 'get_stats')
6803
+ ...MCP_TOOL_DEFS.filter(t => t.function.name !== 'get_stats')
5879
6804
  .map(t => [`wechat.${t.function.name}`, t.function.description.split(/[。(]/)[0]]),
5880
6805
  ];
5881
6806
  for (const [n, d] of all) {