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
package/bin/weflow-cli.ts CHANGED
@@ -318,8 +318,41 @@ program
318
318
  dailyStats: { cli: 'daily-stats --json' },
319
319
  diagnostics: { cli: 'check --json' },
320
320
  todos: { cli: 'todos list --json' },
321
+ awaiting: {
322
+ cli: 'awaiting --yes',
323
+ preview: 'awaiting --dry-run --json',
324
+ readsLocalChat: true,
325
+ invokesAI: true,
326
+ writesNothing: true,
327
+ confirmationRequired: true,
328
+ },
329
+ draft: {
330
+ cli: 'draft <会话> --yes --json',
331
+ preview: 'draft <会话> --dry-run --json',
332
+ readsLocalChat: true,
333
+ invokesAI: true,
334
+ // **它不发送任何东西**:只产出候选文本,回不回由人决定。
335
+ // 这条要能被机器读到——`sendsNothing` 比 `writesNothing` 更要紧。
336
+ writesNothing: true,
337
+ sendsNothing: true,
338
+ confirmationRequired: true,
339
+ },
321
340
  knowledge: { cli: 'search <query> --yes --json', preview: 'search <query> --dry-run --json', output: 'json', mcp: 'wechat.search_articles' },
322
341
  },
342
+ primitives: {
343
+ decide: {
344
+ cli: 'decide --request <file> --yes | decide --over <glob> --ask <text> --yes',
345
+ preview: 'decide --dry-run',
346
+ // 两条路的读法**不一样**,所以不能只写一个 false:
347
+ // --request 完全由调用方给,--over 会读匹配到的本地文件。
348
+ readsLocalData: { request: false, over: true },
349
+ invokesAI: true,
350
+ sendsCallerProvidedState: true,
351
+ confirmationRequired: true,
352
+ mcpExposed: false,
353
+ whyNotMcp: 'an MCP client must not be able to drive local outbound calls',
354
+ },
355
+ },
323
356
  workflows: {
324
357
  initialization: {
325
358
  preview: 'init --dry-run --json',
@@ -392,6 +425,17 @@ program
392
425
  execute: 'assistant <start|stop> --yes --json',
393
426
  confirmationRequired: true,
394
427
  },
428
+ localPanel: {
429
+ status: 'panel --status --json',
430
+ ask: 'panel --ask "<text>" --yes --json',
431
+ preview: 'panel --dry-run --json',
432
+ execute: 'panel --yes --json',
433
+ confirmationRequired: true,
434
+ opensLocalWindow: true,
435
+ loopbackOnly: true,
436
+ // 关掉窗口**不会**停掉助手——这件事必须能被机器读到,别让调用方以为退出即结束
437
+ keepsAssistantRunningAfterClose: true,
438
+ },
395
439
  messageChannelAuthentication: {
396
440
  loginPreview: 'login-wechat --dry-run --json',
397
441
  loginExecute: 'login-wechat --yes',
@@ -521,7 +565,28 @@ program
521
565
  aiDisabledByDefaultForExport: true,
522
566
  unknownMessageTypesPreserved: true,
523
567
  nonInteractiveTalkerResolution: true,
524
- mcpDefaultReadOnly: true,
568
+ // **这个名字曾经是假的**:MCP 那张表不是手写的只读清单,而是**助手工具表减去
569
+ // save_memory**,所以里面早就有写文件的 `export_chat` 和会把图片交给模型的
570
+ // `look_at_image`。如实改成 false,并把真正有出境的几样单独列出来(下面这个对象)。
571
+ // 读这一位的调用方按 false 走保守分支即可。
572
+ mcpDefaultReadOnly: false,
573
+ mcpSurface: {
574
+ // 原文只写了 "minus save_memory" —— 而那张表也排除了 `look_at_image`(MCP 只取文本,
575
+ // 它挂进侧信道的图没人接)。机器可读字段少说一句同样算不准,如实写全。
576
+ derivedFrom: 'assistant tools minus save_memory and look_at_image',
577
+ writesFiles: ['export_chat'],
578
+ // 逐个核实过(脚本里确实调云端模型的那些):
579
+ // who_owes_reply/search_chats → Jev;search_semantic → 阿里云百炼嵌入 + Jev 重排;draft_reply → Jev + DeepSeek。
580
+ // `look_at_image` **不在这里**:它靠侧信道把图交给助手自己的模型,而 MCP 只取工具返回的文本,
581
+ // 图没人接、回话却写着"你能看到它了"——所以它已经从 MCP 那张表里排除了(见 MCP_TOOL_DEFS)。
582
+ callsCloudModels: ['who_owes_reply', 'search_chats', 'search_semantic', 'draft_reply'],
583
+ // 机器调用**默认只给预览**、必须显式 confirm 才出境的那些(MCP 独有的边界)。
584
+ // **这里是四个,不是"只有 draft_reply"**:逐个读 handler 才发现那四个工具里
585
+ // 写的是同一句 `if (ctx.requiresConfirm && args.confirm !== true)`(1010/1041/1094/1170)。
586
+ // 原先只列了 draft_reply,而测试是照着这个字段的值抄的,于是两处一起错、谁都没红。
587
+ // 现在测试改成**从源码里数**(见 test/assistant-tools.test.ts),改一处不同步就会红。
588
+ requiresConfirm: ['search_chats', 'who_owes_reply', 'draft_reply', 'search_semantic'],
589
+ },
525
590
  mcpMessageLimit: { default: 100, max: 1000 },
526
591
  },
527
592
  }
@@ -941,9 +1006,13 @@ const configCmd = program
941
1006
  const configurableKeys = [
942
1007
  'dbPath', 'decryptKey', 'dbPath3x', 'decryptKey3x', 'dataVersion', 'wxid',
943
1008
  'ntDbPath', 'ntKey', 'ntSalt', 'contactDbPath', 'contactKey', 'contactSalt',
944
- 'vaultRepo', 'aiEngine', 'aiBaseUrl', 'aiModel', 'deepseekApiKey', 'wereadApiKey',
1009
+ 'vaultRepo', 'aiEngine', 'aiBaseUrl', 'aiModel', 'deepseekApiKey', 'typesafeApiKey',
1010
+ 'dashscopeApiKey', 'favPassphrase',
1011
+ 'wereadApiKey',
945
1012
  'assistantPrivacy', 'assistantWhitelist', 'assistantGroupWhitelist',
946
- 'assistantGroupRequireMention', 'dailySources', 'dailySourceCategories', 'dailyAiEnabled',
1013
+ 'assistantGroupRequireMention', 'assistantFastRoute', 'assistantPanelUser', 'quickReplyContacts',
1014
+ 'dailySources', 'dailySourceCategories',
1015
+ 'dailyExcludeTopics', 'dailyAiEnabled',
947
1016
  'emoticonSeed',
948
1017
  ] as const
949
1018
 
@@ -1032,6 +1101,7 @@ configCmd
1032
1101
  console.log(`微信账号: ${config.wxid || chalk.gray('(未设置)')}`)
1033
1102
  console.log(`公众号日报来源: ${configService.get('dailySources') || chalk.gray('(全部公众号)')}`)
1034
1103
  console.log(`公众号日报 AI: ${configService.get('dailyAiEnabled') === 'false' ? '已关闭' : '已开启'}`)
1104
+ console.log(`公众号日报排除主题: ${configService.get('dailyExcludeTopics') || chalk.gray('(不排除)')}`)
1035
1105
  })
1036
1106
 
1037
1107
  configCmd
@@ -1383,7 +1453,7 @@ syncCmd
1383
1453
  const result = await runSync(resolvedTalker, {
1384
1454
  since, full: !!opts.full, overlapSeconds: overlap, limit,
1385
1455
  scope: displayName,
1386
- read: (who, howMany) => chatService.getMessagesWithShards(who, howMany, 0),
1456
+ read: (who, howMany, from) => chatService.getMessagesWithShards(who, howMany, 0, from),
1387
1457
  })
1388
1458
  if (opts.json) {
1389
1459
  console.log(JSON.stringify({
@@ -1966,12 +2036,26 @@ program
1966
2036
  process.exit(1)
1967
2037
  }
1968
2038
  if (!opts.yes) {
1969
- const { confirmed } = await inquirer.prompt([{
1970
- type: 'confirm',
1971
- name: 'confirmed',
1972
- message: token ? '已有登录状态,确认启动重新登录吗?' : '确认启动人工扫码登录吗?',
1973
- default: false,
1974
- }])
2039
+ // stdin 不是终端时(管道、计划任务、会话里的 `!` 前缀执行)inquirer 会抛 ExitPromptError——
2040
+ // 屏幕上是一条堆栈而不是一句话。这里把它变成解释:扫码登录必须在真实终端里跑。
2041
+ // 与守护进程那条 `--yes` 是同一类毛病:交互确认被塞进一个没人能回答的 stdin。
2042
+ let confirmed = false
2043
+ try {
2044
+ const answer = await inquirer.prompt([{
2045
+ type: 'confirm',
2046
+ name: 'confirmed',
2047
+ message: token ? '已有登录状态,确认启动重新登录吗?' : '确认启动人工扫码登录吗?',
2048
+ default: false,
2049
+ }])
2050
+ confirmed = !!answer.confirmed
2051
+ } catch (error: any) {
2052
+ if (error?.name === 'ExitPromptError' || /force closed the prompt/.test(String(error?.message))) {
2053
+ console.log(chalk.yellow('当前环境没有可交互的终端。扫码登录必须在真实终端窗口里执行,'))
2054
+ console.log(chalk.yellow('或者加 --yes 跳过这个确认(扫码那一步仍然要人来做)。'))
2055
+ process.exit(1)
2056
+ }
2057
+ throw error
2058
+ }
1975
2059
  if (!confirmed) {
1976
2060
  console.log(chalk.gray(token ? '保持当前登录状态' : '已取消'))
1977
2061
  return
@@ -1999,6 +2083,20 @@ program
1999
2083
  if (session.status === 'confirmed') {
2000
2084
  console.log(chalk.green('\n✓ 登录成功!'))
2001
2085
  console.log(chalk.gray(' 消息通道登录状态已安全保存'))
2086
+ // 助手的白名单为空时**拒绝所有人**:登录完对着自己的机器人说话,什么都不会回来。
2087
+ // 所以这里必须说清"下一步该干什么"。
2088
+ // 只打在人看的这条路上——`--json` 那条路照旧不返回账号标识。
2089
+ console.log('')
2090
+ console.log(chalk.cyan(' 助手默认拒绝所有人(白名单为空)。'))
2091
+ // 这里**不**替用户写白名单:登录响应给的是 ilink_user_id,而白名单要的是入站消息里
2092
+ // 那个 from_user_id(文档里写成 <@im.wechat ID>),两者是不是同一个值**没有被验证过**
2093
+ // ——猜错的后果是"白名单非空、看着配好了、却仍然拒你"。真值在第一条被拒的消息里,
2094
+ // 所以让它自己现形(见 assistant log 的 [首次配置] 那行)。
2095
+ console.log(chalk.gray(' 启用方式:先 assistant start,从你的微信给机器人发一条消息,'))
2096
+ console.log(chalk.gray(' 日志里会出现一行 ' + chalk.cyan('[首次配置]')
2097
+ + chalk.gray(' 带着你自己的发送者 ID 和该执行的命令。')))
2098
+ console.log(chalk.gray(' 只想先看它「本来会怎么答」而不改行为:')
2099
+ + chalk.cyan('weflow-cli config set assistantFastRoute log'))
2002
2100
  } else {
2003
2101
  console.log(chalk.red(`\n✗ 登录失败: ${session.error || '超时'}`))
2004
2102
  }
@@ -3490,13 +3588,18 @@ program
3490
3588
  '005_Reference/Tools',
3491
3589
  '005_Reference/Methods',
3492
3590
  '006_Projects',
3493
- '007_Wiki/Concepts',
3591
+ // 这里原先还有 `007_Wiki/Concepts`:概念页实际写在下面的顶层 `Wiki/Concepts`
3592
+ // (`compile_wiki` 的 OUTPUT_ROOT)。两个都列,就建出一个永远空着的目录。
3494
3593
  '008_MOC',
3495
3594
  '999_Archive',
3496
3595
  '_attachments',
3596
+ // `Wiki/Concepts` 是唯一真的有人写的 Wiki 子目录(`compile_wiki` 的
3597
+ // OUTPUT_ROOT)。这里曾经还列着 `Wiki/Entities` 与 `Wiki/Topics`——没有任何
3598
+ // 代码写它们,README 里那张表却把它们当成已有的介绍给用户。**声明了就要有东西
3599
+ // 往里写**,否则就是又一个"永远空着的目录"(同 `007_Wiki`)。
3600
+ // 两个知识库各一个目录:`Wiki/Concepts` 是公众号文章线的,`Chat/Concepts` 是聊天线的
3497
3601
  'Wiki/Concepts',
3498
- 'Wiki/Entities',
3499
- 'Wiki/Topics',
3602
+ 'Chat/Concepts',
3500
3603
  ]
3501
3604
  const files = [
3502
3605
  {
@@ -3504,7 +3607,10 @@ program
3504
3607
  content: JSON.stringify({
3505
3608
  newFileLocation: 'folder',
3506
3609
  newFileFolderPath: 'Sources',
3507
- attachmentFolderPath: 'Assets',
3610
+ // **`_attachments`,不是 `Assets`**:布局里建的就是 `_attachments`(这个
3611
+ // 字面量以前只在这里出现一次,是个孤例),而 init 从不建 `Assets`——
3612
+ // 于是附件会被 Obsidian 放进一个不存在的目录。
3613
+ attachmentFolderPath: '_attachments',
3508
3614
  showInlineTitle: false,
3509
3615
  }, null, 2),
3510
3616
  },
@@ -3550,11 +3656,20 @@ program
3550
3656
  '',
3551
3657
  '| 目录 | 说明 |',
3552
3658
  '|------|------|',
3553
- '| `Sources/WeChat/` | 公众号文章(按日期+主题分类) |',
3554
- '| `Wiki/Concepts/` | 概念页(手动或 AI 生成) |',
3555
- '| `Wiki/Entities/` | 实体页(公众号、作者等) |',
3556
- '| `Wiki/Topics/` | 主题总览页 |',
3659
+ '| `Sources/WeChat/<日期>/` | 原始文章(`daily` 与回填写入,连同正文图片) |',
3660
+ '| `001_Daily/<日期>.md` | 每天一页日记 |',
3661
+ '| `002_Literature/WeChat/<日期>/` | 阅读笔记(由 `Sources` 生成) |',
3662
+ // **两个知识库各写一行**:文章线(`Wiki/`)与聊天线(`Chat/`)是分开的两套,
3663
+ // 只列文章那套的话,用户在库里看到的说明与他实际拥有的东西对不上。
3664
+ '| `Wiki/Concepts/`、`Wiki/00-Overview.md` | **文章知识库**的概念页与索引(`wiki compile` 生成) |',
3665
+ '| `Chat/Concepts/`、`Chat/00-Overview.md` | **聊天知识库**的概念页与索引(`wiki compile --source output/chat-notes` 生成) |',
3666
+ '| `Sources/Chat/会话-<名字>.md` | 每个会话一张知识卡(`chat` 生成:聊了什么、有哪些人、欠着什么) |',
3557
3667
  '| `Templates/` | 模板文件 |',
3668
+ '| `_attachments/` | Obsidian 附件(`.obsidian/app.json` 的 `attachmentFolderPath` 指向这里) |',
3669
+ '| `000_Inbox` `003_Ideas` `004_Permanent` `005_Reference` `006_Projects` `008_MOC` `999_Archive` | **给你自己写笔记用的层**:`vault promote` 才会往里放东西,空着是设计而不是没做完 |',
3670
+ '',
3671
+ '> 阅读笔记与日记里各有一段 `dataview` 查询,所以这个 Vault 需要 **Dataview 社区插件**',
3672
+ '> (社区插件不进仓库,换机器要自己装一次)。',
3558
3673
  '',
3559
3674
  '## 快速查询',
3560
3675
  '',
@@ -3671,9 +3786,13 @@ program
3671
3786
  new Command('compile')
3672
3787
  .description('扫描文章 [[Wikilinks]] 聚合生成概念页')
3673
3788
  .option('-l, --limit <n>', '最多生成概念数', '20')
3674
- .option('--source <dir>', '文章目录', './output/biz-daily')
3789
+ .option('--min-refs <n>', '至少被几篇材料提到才建页(2 能滤掉新闻里的一次性实体)', '1')
3790
+ .option('--source <dir>', '材料目录(**必须含 [[概念]] — 说明 形状的链接**,否则聚合不出任何概念)', './output/biz-daily')
3675
3791
  .option('-o, --output <dir>', '概念页输出目录', './output/wechat-vault/Wiki/Concepts')
3676
3792
  .option('--api-key <key>', 'DeepSeek API key')
3793
+ .option('--refresh-sources',
3794
+ '只把已有概念页的"来源"段补齐成当前全部来源(本地重写,**不调用模型**)')
3795
+ .option('--cards <dir...>', '产卡目录,可写多个;默认所有 output/*-notes(与索引的引用数同一口径)')
3677
3796
  .option('--dry-run', '仅预览,不读取文章、调用 AI 或写入概念页')
3678
3797
  .option('--yes', '确认调用 AI 并生成概念页')
3679
3798
  .option('--json', '输出机器可读结果,不返回概念或本地路径')
@@ -3681,13 +3800,32 @@ program
3681
3800
  const pkgRoot = resolvePackageRoot()
3682
3801
  const script = join(pkgRoot, 'scripts', 'compile_wiki.py')
3683
3802
 
3803
+ if (opts.refreshSources) {
3804
+ // 本地补来源:**不调模型**,所以不传 apiKey、preview 里也不说"调用 AI"。
3805
+ // 但它会把已有页重写一遍(实测会动 358 张),照样走"先预览、再确认"那两步。
3806
+ await runConfirmedPythonMutation({
3807
+ action: 'wiki.refreshSources',
3808
+ script,
3809
+ args: ['--refresh-sources', '--output', opts.output,
3810
+ ...(opts.cards ?? []).flatMap((c: string) => ['--cards', c])],
3811
+ cliOptions: opts,
3812
+ preview: { readsLocalCards: true, rewritesExistingPages: true, usesAi: false },
3813
+ confirmationMessage: `确认把 ${opts.output} 里已有概念页的"来源"段补齐?(本地重写,不调用 AI)`,
3814
+ apiKey: opts.apiKey,
3815
+ timeout: 300_000,
3816
+ })
3817
+ return
3818
+ }
3819
+
3684
3820
  const limit = parseCliInteger(opts.limit, 'limit', 1, 1000, opts.json)
3821
+ const minRefs = parseCliInteger(opts.minRefs ?? '1', 'min-refs', 1, 50, opts.json)
3685
3822
  await runConfirmedPythonMutation({
3686
3823
  action: 'wiki.compile',
3687
3824
  script,
3688
- args: ['--limit', String(limit), '--source', opts.source, '--output', opts.output],
3825
+ args: ['--limit', String(limit), '--source', opts.source, '--output', opts.output,
3826
+ '--min-refs', String(minRefs)],
3689
3827
  cliOptions: opts,
3690
- preview: { limit, readsLocalArticles: true, usesAi: true, writesConceptPages: true },
3828
+ preview: { limit, minRefs, readsLocalArticles: true, usesAi: true, writesConceptPages: true },
3691
3829
  confirmationMessage: `确认调用 AI 并生成最多 ${limit} 个概念页?`,
3692
3830
  apiKey: opts.apiKey,
3693
3831
  timeout: 300_000,
@@ -3714,6 +3852,7 @@ program
3714
3852
  .option('--skip-html', '跳过 HTML 阅读器生成')
3715
3853
  .option('--skip-ai-report', '跳过 AI 深度阅读报告')
3716
3854
  .option('--no-ai', '关闭全部 AI 调用,保留抓取和本地输出')
3855
+ .option('--no-summary', '只判断不生成:biz_daily 不调 LLM 写摘要/标签/简报(判断仍走 Jev)')
3717
3856
  .option('--ai-report-range <n>', 'AI 报告覆盖最近 N 天', '1')
3718
3857
  .option('--dry-run', '仅预览步骤,不读取聊天数据、调用网络或写入文件')
3719
3858
  .option('--yes', '确认运行流水线')
@@ -3796,6 +3935,7 @@ program
3796
3935
  if (opts.skipHtml) args.push('--skip-html')
3797
3936
  if (opts.skipAiReport) args.push('--skip-ai-report')
3798
3937
  if (noAi) args.push('--no-ai')
3938
+ if (opts.noSummary && !noAi) args.push('--no-summary')
3799
3939
 
3800
3940
  try {
3801
3941
  if (!opts.json) console.log(chalk.cyan('\n启动端到端流水线...\n'))
@@ -4713,22 +4853,35 @@ program
4713
4853
  .description('构建语义搜索索引')
4714
4854
  .option('--full', '全量重建')
4715
4855
  .option('--api-key <key>', 'DashScope embedding API key')
4856
+ .option('--days <n>', '聊天记录收最近多少天(默认 90)')
4857
+ .option('--article-days <n>', '日报文章收最近多少个日期目录(默认 30)')
4716
4858
  .option('--dry-run', '仅预览,不读取数据库、调用网络或写入索引')
4717
4859
  .option('--yes', '确认构建或重建索引')
4718
4860
  .option('--json', '输出 JSON 格式')
4719
4861
  .action(async (opts) => {
4862
+ // 窗口的**运行值由这里说了算**:默认值 + 校验 + 透传都在这一处,预览报的就是真跑的那些
4863
+ // (脚本文档里另有自己的默认值,只供手动直接跑脚本时用;CLI 这条路永远显式传参,
4864
+ // 所以两边万一漂了也不会影响实际行为)。
4865
+ // 上限 36500 天≈100 年:不设上限的后果是"给个 1e9 就把遍历变成一次全库穿透"。
4866
+ const chatDays = opts.days === undefined ? 90 : parseCliInteger(opts.days, 'days', 1, 36_500, opts.json)
4867
+ const articleDays = opts.articleDays === undefined
4868
+ ? 30 : parseCliInteger(opts.articleDays, 'article-days', 1, 36_500, opts.json)
4869
+ const windowText = `聊天最近 ${chatDays} 天、日报最近 ${articleDays} 个日期目录`
4720
4870
  const preview = {
4721
4871
  success: true,
4722
4872
  dryRun: true,
4723
4873
  action: 'search-index.build',
4724
4874
  mode: opts.full ? 'full' : 'incremental',
4875
+ chatDays,
4876
+ articleDays,
4725
4877
  readsLocalData: true,
4726
4878
  usesCloudEmbedding: true,
4727
4879
  replacesExistingIndex: !!opts.full,
4728
4880
  }
4729
4881
  if (opts.dryRun) {
4730
4882
  if (opts.json) console.log(JSON.stringify(preview))
4731
- else console.log(chalk.cyan(`语义索引预览:${opts.full ? '全量重建' : '增量构建'},将读取本地数据并调用向量服务`))
4883
+ else console.log(chalk.cyan(`语义索引预览:${opts.full ? '全量重建' : '增量构建'};`
4884
+ + `窗口=${windowText}(要改:--days / --article-days),将读取本地数据并调用向量服务`))
4732
4885
  return
4733
4886
  }
4734
4887
  if (!opts.yes) {
@@ -4739,7 +4892,10 @@ program
4739
4892
  const { confirmed } = await inquirer.prompt([{
4740
4893
  type: 'confirm',
4741
4894
  name: 'confirmed',
4742
- message: `确认${opts.full ? '全量重建' : '增量构建'}语义索引?本地文本将发送到已配置的向量服务。`,
4895
+ // 窗口是**代价的一部分**:天数越多,发给向量服务的文本越多。所以确认语里要写出来,
4896
+ // 而不是只说"本地文本将发送"。
4897
+ message: `确认${opts.full ? '全量重建' : '增量构建'}语义索引(${windowText})?`
4898
+ + '这些文本将发送到已配置的向量服务。',
4743
4899
  default: false,
4744
4900
  }])
4745
4901
  if (!confirmed) {
@@ -4752,7 +4908,8 @@ program
4752
4908
  const execFileAsync = promisify(execFile)
4753
4909
  const pkgRoot = resolvePackageRoot()
4754
4910
  const script = join(pkgRoot, 'scripts', 'semantic_search.py')
4755
- const args: string[] = [script, 'build']
4911
+ const args: string[] = [script, 'build', '--days', String(chatDays),
4912
+ '--article-days', String(articleDays)]
4756
4913
  if (opts.full) args.push('--full')
4757
4914
  try {
4758
4915
  const { stdout } = await execFileAsync(getPythonCommand(), args, {
@@ -4943,6 +5100,526 @@ program
4943
5100
  }
4944
5101
  })
4945
5102
 
5103
+ // decide —— 本机判断层。一个 state + 一批类型化问题,一次调用。
5104
+ // state 由调用方给,命令自己**不读任何本地数据**;但它是出境调用,所以照 awaiting/search
5105
+ // 的规矩:--dry-run 只校验、--yes 才真跑。这一版**不暴露成 MCP 工具**——那会变成
5106
+ // 远端 MCP 客户端能驱动本机往第三方发任意文本,属于要单独决策的出境面。
5107
+ program
5108
+ .command('decide')
5109
+ .description('本机判断层:一个 state + 一批类型化问题,一次调用返回带概率的答案')
5110
+ .option('--request <file>', '请求 JSON 的路径(含 state 与 questions)')
5111
+ .option('--over <glob>', '批量模式:对匹配到的每个文件问 --ask 里的每个问题(可重复)',
5112
+ (value: string, previous: string[]) => previous.concat([value]), [] as string[])
5113
+ .option('--ask <text>', '批量模式下的一个是非题,逐文件展开(可重复)',
5114
+ (value: string, previous: string[]) => previous.concat([value]), [] as string[])
5115
+ .option('--max-chars <n>', '批量模式下每个文件喂多少字符(默认 1500;它决定上限)', '1500')
5116
+ .option('--limit <n>', '批量模式最多取多少个文件(默认 50)', '50')
5117
+ .option('--model <name>', '模型别名,默认 jev-latest')
5118
+ .option('--dry-run', '只校验请求并回显形状,不调用决策模型')
5119
+ .option('--yes', '确认把 state 发送到已配置的决策模型服务')
5120
+ .option('--json', '输出机器可读结果(本命令的输出本来就是 JSON)')
5121
+ .action(async (opts) => {
5122
+ const { execFile } = await import('child_process')
5123
+ const { promisify } = await import('util')
5124
+ const { readFileSync } = await import('fs')
5125
+ const execFileAsync = promisify(execFile)
5126
+ const pkgRoot = resolvePackageRoot()
5127
+ const decideScript = join(pkgRoot, 'scripts', 'decide.py')
5128
+
5129
+ // 预览里要报"这批几个问题",所以在这里读一眼;**校验仍然只由脚本做**,
5130
+ // 免得两处校验逻辑各说各话。
5131
+ // 两种输入方式:--request 一个文件、或 --over 批量展开。脚本自己会拒掉
5132
+ // 两个都没给的情况,所以这里不做重复校验。
5133
+ const batch = Array.isArray(opts.over) && opts.over.length > 0
5134
+ let questionCount: number | null = null
5135
+ if (batch) {
5136
+ questionCount = opts.over.length * opts.ask.length || null
5137
+ } else if (opts.request) {
5138
+ try {
5139
+ const parsed = JSON.parse(readFileSync(opts.request, 'utf8'))
5140
+ if (parsed && typeof parsed === 'object' && parsed.questions
5141
+ && typeof parsed.questions === 'object') {
5142
+ questionCount = Object.keys(parsed.questions).length
5143
+ }
5144
+ } catch {
5145
+ // 读不到或不是 JSON:交给脚本去报一个准确得多的错误。
5146
+ }
5147
+ }
5148
+ const limit = parseCliInteger(opts.limit, 'limit', 1, 500, opts.json)
5149
+ const maxChars = parseCliInteger(opts.maxChars, 'max-chars', 1, 100000, opts.json)
5150
+
5151
+ const args = [decideScript,
5152
+ ...(batch
5153
+ ? [...opts.over.flatMap((g: string) => ['--over', g]),
5154
+ ...opts.ask.flatMap((a: string) => ['--ask', a]),
5155
+ '--max-chars', String(maxChars), '--limit', String(limit)]
5156
+ : ['--request', opts.request]),
5157
+ ...(opts.model ? ['--model', opts.model] : [])]
5158
+ if (opts.dryRun) {
5159
+ args.push('--dry-run')
5160
+ } else if (!opts.yes) {
5161
+ const preview = {
5162
+ success: false, dryRun: false, action: 'decide',
5163
+ code: 'CONFIRMATION_REQUIRED',
5164
+ questionCount, readsLocalData: batch, invokesAI: true,
5165
+ }
5166
+ if (opts.json) {
5167
+ console.log(JSON.stringify(preview))
5168
+ process.exit(1)
5169
+ }
5170
+ const { confirmed } = await inquirer.prompt([{
5171
+ type: 'confirm',
5172
+ name: 'confirmed',
5173
+ message: `确认把请求里的 state 发送到已配置的决策模型服务吗?`
5174
+ + (questionCount === null ? '' : `(${questionCount} 个问题)`),
5175
+ default: false,
5176
+ }])
5177
+ if (!confirmed) {
5178
+ console.log(chalk.gray('已取消'))
5179
+ return
5180
+ }
5181
+ }
5182
+
5183
+ try {
5184
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
5185
+ timeout: 120_000, maxBuffer: 10 * 1024 * 1024,
5186
+ env: pythonProcessEnv(),
5187
+ })
5188
+ process.stdout.write(stdout)
5189
+ } catch (error) {
5190
+ const payload = (error as { stdout?: string }).stdout
5191
+ if (payload && payload.trim()) {
5192
+ // 脚本自己已经给出了结构化的失败,原样透传比自己编一个更有用。
5193
+ process.stdout.write(payload)
5194
+ } else {
5195
+ console.log(JSON.stringify({ success: false, code: 'DECIDE_FAILED',
5196
+ action: 'decide', error: safeSubprocessError(error) }))
5197
+ }
5198
+ process.exit(1)
5199
+ }
5200
+ })
5201
+
5202
+ // awaiting —— 谁在等我回话。读本地聊天并把正文发给决策模型,所以照 search 的规矩:
5203
+ // --dry-run 预览、--yes 才真跑。它**不写任何本地数据**,但仍是一次正文出境,
5204
+ // 而聊天正文比日报的文章正文敏感一档。
5205
+ program
5206
+ .command('awaiting')
5207
+ .description('谁在等我回话:逐会话判断有没有欠下的回复(读取聊天正文并调用决策模型)')
5208
+ .option('--days <n>', '只看最近多少天有动静的会话', '14')
5209
+ .option('--limit <n>', '最多判多少个会话', '40')
5210
+ .option('--min-prob <p>', 'waiting 概率低于此值不算欠账', '0.5')
5211
+ .option('--dry-run', '仅预览会判哪些会话、要发多少字符,不调用决策模型')
5212
+ .option('--yes', '确认把聊天正文发送到已配置的决策模型服务')
5213
+ .option('--json', '输出机器可读结果;执行仍需 --yes')
5214
+ .action(async (opts) => {
5215
+ const { execFile } = await import('child_process')
5216
+ const { promisify } = await import('util')
5217
+ const execFileAsync = promisify(execFile)
5218
+ const pkgRoot = resolvePackageRoot()
5219
+ const script = join(pkgRoot, 'scripts', 'reply_debt.py')
5220
+ const days = parseCliInteger(opts.days, 'days', 1, 3650, opts.json)
5221
+ const limit = parseCliInteger(opts.limit, 'limit', 1, 500, opts.json)
5222
+ const args = [script, '--days', String(days), '--limit', String(limit),
5223
+ '--min-prob', String(opts.minProb),
5224
+ ...(opts.json ? ['--json'] : [])]
5225
+
5226
+ if (opts.dryRun) {
5227
+ // 交给脚本自己报数:它才知道要发多少字符。这一步只读本地,零出境。
5228
+ args.push('--dry-run')
5229
+ } else if (!opts.yes) {
5230
+ const preview = {
5231
+ success: false, dryRun: false, action: 'reply-debt.scan',
5232
+ code: 'CONFIRMATION_REQUIRED', days, limit,
5233
+ readsLocalChat: true, invokesAI: true, writesNothing: true,
5234
+ }
5235
+ if (opts.json) {
5236
+ console.log(JSON.stringify(preview))
5237
+ process.exit(1)
5238
+ }
5239
+ const { confirmed } = await inquirer.prompt([{
5240
+ type: 'confirm',
5241
+ name: 'confirmed',
5242
+ message: '确认把最近这些会话的聊天正文发送到已配置的决策模型服务吗?',
5243
+ default: false,
5244
+ }])
5245
+ if (!confirmed) {
5246
+ console.log(chalk.gray('已取消'))
5247
+ return
5248
+ }
5249
+ }
5250
+
5251
+ try {
5252
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
5253
+ timeout: 600_000, maxBuffer: 50 * 1024 * 1024,
5254
+ env: pythonProcessEnv(),
5255
+ })
5256
+ process.stdout.write(stdout)
5257
+ } catch (error) {
5258
+ if (opts.json) {
5259
+ console.log(JSON.stringify({ success: false, code: 'AWAITING_FAILED', action: 'reply-debt.scan', error: safeSubprocessError(error) }))
5260
+ } else {
5261
+ console.error(chalk.red(`\n✗ ${safeSubprocessError(error)}`))
5262
+ }
5263
+ process.exit(1)
5264
+ }
5265
+ })
5266
+
5267
+ // draft:判断在前、起草在后、**绝不发送**
5268
+ program
5269
+ .command('draft <talker>')
5270
+ .description('帮我起草回复:先判断(意图/风险/该不该给实质),再起草候选 —— **只产出文本,不发送**')
5271
+ .option('--count <n>', '要几条候选', '3')
5272
+ .option('--gate-commitment', '承诺未兑现时也拒绝起草(默认只在提示词里约束)')
5273
+ .option('--dry-run', '仅预览会发多少字符给哪两个模型,不调用')
5274
+ .option('--yes', '确认把这段对话发送到判断模型与生成模型')
5275
+ .option('--json', '输出机器可读结果;执行仍需 --yes')
5276
+ .action(async (talker: string, opts) => {
5277
+ const { execFile } = await import('child_process')
5278
+ const { promisify } = await import('util')
5279
+ const execFileAsync = promisify(execFile)
5280
+ const pkgRoot = resolvePackageRoot()
5281
+ const script = join(pkgRoot, 'scripts', 'draft_reply.py')
5282
+ const count = parseCliInteger(opts.count, 'count', 1, 5, opts.json)
5283
+ // **"确认过了"只用一个变量表示**(`--yes` 或交互回答"是"),参数在确认之后才拼。
5284
+ // 原来的写法是先拼参数、再问,于是**交互确认那条路是坏的**:答了"是",`args` 里
5285
+ // 却从没补上 `--yes`,脚本照样拒绝(用户看到的是"我确认了它却说没确认")。
5286
+ // 两者合成一个变量之后,这种漏传在结构上不可能再发生。
5287
+ let confirmed = !!opts.yes
5288
+ if (!opts.dryRun && !confirmed) {
5289
+ const preview = {
5290
+ success: false, dryRun: false, action: 'draft-reply',
5291
+ code: 'CONFIRMATION_REQUIRED', talker, count,
5292
+ readsLocalChat: true, invokesAI: true, writesNothing: true, sendsNothing: true,
5293
+ }
5294
+ if (opts.json) {
5295
+ console.log(JSON.stringify(preview))
5296
+ process.exit(1)
5297
+ }
5298
+ const answer = await inquirer.prompt([{
5299
+ type: 'confirm',
5300
+ name: 'confirmed',
5301
+ message: '确认把这段对话发送到判断模型与生成模型吗?(只会产出候选文本,不会替你发送)',
5302
+ default: false,
5303
+ }])
5304
+ confirmed = !!answer.confirmed
5305
+ if (!confirmed) {
5306
+ console.log(chalk.gray('已取消'))
5307
+ return
5308
+ }
5309
+ }
5310
+ const args = [script, '--talker', String(talker), '--count', String(count),
5311
+ // 脚本自己有第二道闸门(`--yes` 才真的把对话发出去),所以要透传
5312
+ ...(confirmed ? ['--yes'] : []),
5313
+ ...(opts.gateCommitment ? ['--gate-commitment'] : []),
5314
+ ...(opts.json ? ['--json'] : []),
5315
+ // dry-run 交给脚本自己报数(它才知道这段对话有多少字符),这一步只读本地、零出境
5316
+ ...(opts.dryRun ? ['--dry-run'] : [])]
5317
+
5318
+ try {
5319
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
5320
+ timeout: 300_000, maxBuffer: 50 * 1024 * 1024,
5321
+ env: pythonProcessEnv(),
5322
+ })
5323
+ process.stdout.write(stdout)
5324
+ } catch (error) {
5325
+ if (opts.json) {
5326
+ console.log(JSON.stringify({ success: false, code: 'DRAFT_FAILED', action: 'draft-reply', error: safeSubprocessError(error) }))
5327
+ } else {
5328
+ console.error(chalk.red(`\n✗ ${safeSubprocessError(error)}`))
5329
+ }
5330
+ process.exit(1)
5331
+ }
5332
+ })
5333
+
5334
+ // wiki lint:知识库体检(全本地:不联网、不调用模型)
5335
+ program.commands.find(c => c.name() === 'wiki')?.addCommand(
5336
+ new Command('lint')
5337
+ .description('知识库体检:断链、孤儿、空页、同名(本地,不联网)')
5338
+ .option('--dir <dir>', '概念页目录', './output/wechat-vault/Wiki/Concepts')
5339
+ .option('--json', '输出机器可读结果')
5340
+ .action(async (opts) => {
5341
+ const { execFile } = await import('child_process')
5342
+ const { promisify } = await import('util')
5343
+ const execFileAsync = promisify(execFile)
5344
+ const script = join(resolvePackageRoot(), 'scripts', 'wiki_lint.py')
5345
+ const args = [script, '--dir', String(opts.dir), ...(opts.json ? ['--json'] : [])]
5346
+ try {
5347
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
5348
+ timeout: 120_000, maxBuffer: 20 * 1024 * 1024, env: pythonProcessEnv(),
5349
+ })
5350
+ process.stdout.write(stdout)
5351
+ } catch (error) {
5352
+ if (opts.json) console.log(JSON.stringify({ success: false, code: 'WIKI_LINT_FAILED', error: safeSubprocessError(error) }))
5353
+ else console.error(chalk.red(`
5354
+ ✗ ${safeSubprocessError(error)}`))
5355
+ process.exit(1)
5356
+ }
5357
+ }))
5358
+
5359
+ // chat-notes:把会话整理成知识卡,喂给 wiki compile 聚成概念页
5360
+ program
5361
+ .command('chat-notes')
5362
+ .description('把最近一段时间的会话整理成知识卡(之后用 wiki compile 聚成概念页)—— **只写本地文件,不发送**')
5363
+ .option('--days <n>', '看最近多少天', '30')
5364
+ .option('--limit <n>', '最多处理几个会话', '40')
5365
+ .option('--dry-run', '仅预览:几个会话、多少字符会发给生成模型(只读本地,零出境)')
5366
+ .option('--yes', '确认把对话发送给生成模型')
5367
+ .option('--json', '输出机器可读结果;执行仍需 --yes')
5368
+ .action(async (opts) => {
5369
+ const { execFile } = await import('child_process')
5370
+ const { promisify } = await import('util')
5371
+ const execFileAsync = promisify(execFile)
5372
+ const pkgRoot = resolvePackageRoot()
5373
+ const script = join(pkgRoot, 'scripts', 'chat_notes.py')
5374
+ const days = parseCliInteger(opts.days, 'days', 1, 3650, opts.json)
5375
+ const limit = parseCliInteger(opts.limit, 'limit', 1, 200, opts.json)
5376
+ // 与 `draft` 同一条纪律:**"确认过了"只用一个变量**,参数在确认之后才拼
5377
+ let confirmed = !!opts.yes
5378
+ if (!opts.dryRun && !confirmed) {
5379
+ const preview = {
5380
+ success: false, dryRun: false, action: 'chat-notes',
5381
+ code: 'CONFIRMATION_REQUIRED', days, limit,
5382
+ readsLocalChat: true, invokesAI: true, writesLocalFiles: true, sendsNothing: true,
5383
+ }
5384
+ if (opts.json) {
5385
+ console.log(JSON.stringify(preview))
5386
+ process.exit(1)
5387
+ }
5388
+ const answer = await inquirer.prompt([{
5389
+ type: 'confirm',
5390
+ name: 'confirmed',
5391
+ message: '确认把最近这些会话的对话发送给生成模型吗?(只会产出本地知识卡,不会替你发送任何消息)',
5392
+ default: false,
5393
+ }])
5394
+ confirmed = !!answer.confirmed
5395
+ if (!confirmed) {
5396
+ console.log(chalk.gray('已取消'))
5397
+ return
5398
+ }
5399
+ }
5400
+ const args = [script, '--days', String(days), '--limit', String(limit),
5401
+ ...(confirmed ? ['--yes'] : []),
5402
+ ...(opts.json ? ['--json'] : []),
5403
+ ...(opts.dryRun ? ['--dry-run'] : [])]
5404
+ try {
5405
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
5406
+ timeout: 900_000, maxBuffer: 50 * 1024 * 1024,
5407
+ env: pythonProcessEnv(),
5408
+ })
5409
+ process.stdout.write(stdout)
5410
+ } catch (error) {
5411
+ if (opts.json) {
5412
+ console.log(JSON.stringify({ success: false, code: 'CHAT_NOTES_FAILED', action: 'chat-notes', error: safeSubprocessError(error) }))
5413
+ } else {
5414
+ console.error(chalk.red(`\n✗ ${safeSubprocessError(error)}`))
5415
+ }
5416
+ process.exit(1)
5417
+ }
5418
+ })
5419
+
5420
+ // user-notes:读你自己写的笔记(第四条来源,也是唯一读"人写的东西"的一条)
5421
+ program
5422
+ .command('user-notes')
5423
+ .description('读你自己写的笔记,长成知识卡(之后用 wiki compile 聚成概念页)—— **你的笔记只读,绝不修改**')
5424
+ .option('--limit <n>', '最多处理几篇笔记', '20')
5425
+ .option('--vault <dir>', '库目录', './output/wechat-vault')
5426
+ .option('--refresh', '已有卡的也重做')
5427
+ .option('--dry-run', '仅预览:几篇笔记会发给模型(只读本地,零出境)')
5428
+ .option('--yes', '确认把笔记发给生成模型')
5429
+ .option('--json', '输出机器可读结果;执行仍需 --yes')
5430
+ .action(async (opts) => {
5431
+ const { execFile } = await import('child_process')
5432
+ const { promisify } = await import('util')
5433
+ const execFileAsync = promisify(execFile)
5434
+ const script = join(resolvePackageRoot(), 'scripts', 'user_notes.py')
5435
+ const limit = parseCliInteger(opts.limit, 'limit', 1, 500, opts.json)
5436
+ let confirmed = !!opts.yes
5437
+ if (!opts.dryRun && !confirmed) {
5438
+ const preview = {
5439
+ success: false, dryRun: false, action: 'user-notes',
5440
+ code: 'CONFIRMATION_REQUIRED', limit,
5441
+ readsLocalData: true, invokesAI: true, writesLocalFiles: true,
5442
+ modifiesYourNotes: false, sendsNothing: true,
5443
+ }
5444
+ if (opts.json) {
5445
+ console.log(JSON.stringify(preview))
5446
+ process.exit(1)
5447
+ }
5448
+ const answer = await inquirer.prompt([{
5449
+ type: 'confirm',
5450
+ name: 'confirmed',
5451
+ message: '确认把你自己写的这些笔记发给生成模型吗?(你的笔记只读,产出的卡另存;只写本地文件)',
5452
+ default: false,
5453
+ }])
5454
+ confirmed = !!answer.confirmed
5455
+ if (!confirmed) {
5456
+ console.log(chalk.gray('已取消'))
5457
+ return
5458
+ }
5459
+ }
5460
+ const args = [script, '--limit', String(limit), '--vault', String(opts.vault),
5461
+ ...(opts.refresh ? ['--refresh'] : []),
5462
+ ...(confirmed ? ['--yes'] : []),
5463
+ ...(opts.json ? ['--json'] : []),
5464
+ ...(opts.dryRun ? ['--dry-run'] : [])]
5465
+ try {
5466
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
5467
+ timeout: 1_800_000, maxBuffer: 50 * 1024 * 1024,
5468
+ env: pythonProcessEnv(),
5469
+ })
5470
+ process.stdout.write(stdout)
5471
+ } catch (error) {
5472
+ if (opts.json) {
5473
+ console.log(JSON.stringify({ success: false, code: 'USER_NOTES_FAILED', action: 'user-notes',
5474
+ error: safeSubprocessError(error), detail: String((error as any)?.stdout || '').slice(0, 2000) }))
5475
+ } else {
5476
+ console.error(chalk.red(`\n✗ ${safeSubprocessError(error)}`))
5477
+ }
5478
+ process.exit(1)
5479
+ }
5480
+ })
5481
+
5482
+ // fav-notes:把微信收藏里的文章变成知识卡(第三条来源)
5483
+ program
5484
+ .command('fav-notes')
5485
+ .description('把微信收藏里的文章整理成知识卡(之后用 wiki compile 聚成概念页)—— **只写本地文件,不发送**')
5486
+ .option('--limit <n>', '最多处理几条收藏(按收藏时间倒序)', '10')
5487
+ .option('--refresh', '已有卡的也重做(默认增量:只做还没卡的)')
5488
+ .option('--dry-run', '仅预览:几条、多少要联网抓(只读本地,零出境)')
5489
+ .option('--yes', '确认把收藏的正文发给生成模型')
5490
+ .option('--json', '输出机器可读结果;执行仍需 --yes')
5491
+ .action(async (opts) => {
5492
+ const { execFile } = await import('child_process')
5493
+ const { promisify } = await import('util')
5494
+ const execFileAsync = promisify(execFile)
5495
+ const script = join(resolvePackageRoot(), 'scripts', 'fav_notes.py')
5496
+ const limit = parseCliInteger(opts.limit, 'limit', 1, 500, opts.json)
5497
+ // 与另外两条线同一条纪律:**「确认过了」只用一个变量**,参数在确认之后才拼
5498
+ let confirmed = !!opts.yes
5499
+ if (!opts.dryRun && !confirmed) {
5500
+ const preview = {
5501
+ success: false, dryRun: false, action: 'fav-notes',
5502
+ code: 'CONFIRMATION_REQUIRED', limit,
5503
+ readsLocalData: true, fetchesArticleUrls: true, invokesAI: true,
5504
+ writesLocalFiles: true, sendsNothing: true,
5505
+ }
5506
+ if (opts.json) {
5507
+ console.log(JSON.stringify(preview))
5508
+ process.exit(1)
5509
+ }
5510
+ const answer = await inquirer.prompt([{
5511
+ type: 'confirm',
5512
+ name: 'confirmed',
5513
+ message: '确认把这些收藏的正文发给生成模型吗?(只写本地知识卡;正文取不到时会去抓文章链接)',
5514
+ default: false,
5515
+ }])
5516
+ confirmed = !!answer.confirmed
5517
+ if (!confirmed) {
5518
+ console.log(chalk.gray('已取消'))
5519
+ return
5520
+ }
5521
+ }
5522
+ const args = [script, '--limit', String(limit),
5523
+ ...(opts.refresh ? ['--refresh'] : []),
5524
+ ...(confirmed ? ['--yes'] : []),
5525
+ ...(opts.json ? ['--json'] : []),
5526
+ ...(opts.dryRun ? ['--dry-run'] : [])]
5527
+ try {
5528
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
5529
+ timeout: 1_800_000, maxBuffer: 50 * 1024 * 1024,
5530
+ env: pythonProcessEnv(),
5531
+ })
5532
+ process.stdout.write(stdout)
5533
+ } catch (error) {
5534
+ if (opts.json) {
5535
+ console.log(JSON.stringify({ success: false, code: 'FAV_NOTES_FAILED', action: 'fav-notes',
5536
+ error: safeSubprocessError(error), detail: String((error as any)?.stdout || '').slice(0, 2000) }))
5537
+ } else {
5538
+ console.error(chalk.red(`\n✗ ${safeSubprocessError(error)}`))
5539
+ }
5540
+ process.exit(1)
5541
+ }
5542
+ })
5543
+
5544
+ // article-notes:从文章笔记里提炼概念,喂给 wiki compile
5545
+ program
5546
+ .command('article-notes')
5547
+ .description('从 Vault 的文章笔记里提炼概念(之后用 wiki compile 聚成概念页)—— **只写本地文件,不发送**')
5548
+ .option('--source <dir>', '文章笔记目录', './output/wechat-vault/002_Literature')
5549
+ .option('--limit <n>', '最多处理几篇(按发布时间倒序)', '30')
5550
+ .option('--since <date>', '只看这个日期之后的(YYYY-MM-DD)—— 比 --limit 精确,例:"9 月的都跑一遍"')
5551
+ .option('--until <date>', '只看这个日期之前的(YYYY-MM-DD)')
5552
+ .option('--topic <name>', '只做这些主题,可重复或用逗号分隔(如 --topic AI);不传=全部',
5553
+ (value, previous: string[] = []) => [...previous, value], [])
5554
+ .option('--workers <n>', '并发问几篇(只并行"问",写仍是串行)', '6')
5555
+ .option('--dry-run', '仅预览:几篇、多少字会发给生成模型(只读本地,零出境)')
5556
+ .option('--refresh-summaries', '只按原笔记重算已有卡片的摘要(本地,不调用模型、不必 --yes)')
5557
+ .option('--yes', '确认把文章发给生成模型')
5558
+ .option('--json', '输出机器可读结果;执行仍需 --yes')
5559
+ .action(async (opts) => {
5560
+ const { execFile } = await import('child_process')
5561
+ const { promisify } = await import('util')
5562
+ const execFileAsync = promisify(execFile)
5563
+ const pkgRoot = resolvePackageRoot()
5564
+ const script = join(pkgRoot, 'scripts', 'article_notes.py')
5565
+ const limit = parseCliInteger(opts.limit, 'limit', 1, 5000, opts.json)
5566
+ // 主题筛选:这一步是**纯花钱的**(一篇一次调用),而实测主题分布是新闻 57% /
5567
+ // AI 28% / 学术 13%,所以"只做 AI"是省掉一半以上花费的那个开关。它必须出现在
5568
+ // 确认框里——不然用户看到的是"要花 1.6 万次调用",而不是"要花 4 千次"。
5569
+ const topics = (opts.topic as string[])
5570
+ .flatMap(value => String(value).split(','))
5571
+ .map(value => value.trim())
5572
+ .filter(Boolean)
5573
+ const workers = parseCliInteger(opts.workers, 'workers', 1, 64, opts.json)
5574
+ // 与 `chat-notes`/`draft` 同一条纪律:**"确认过了"只用一个变量**,参数在确认之后才拼
5575
+ let confirmed = !!opts.yes
5576
+ if (!opts.dryRun && !opts.refreshSummaries && !confirmed) {
5577
+ const preview = {
5578
+ success: false, dryRun: false, action: 'article-notes',
5579
+ code: 'CONFIRMATION_REQUIRED', source: opts.source, limit, topics,
5580
+ readsLocalData: true, invokesAI: true, writesLocalFiles: true, sendsNothing: true,
5581
+ }
5582
+ if (opts.json) {
5583
+ console.log(JSON.stringify(preview))
5584
+ process.exit(1)
5585
+ }
5586
+ const answer = await inquirer.prompt([{
5587
+ type: 'confirm',
5588
+ name: 'confirmed',
5589
+ message: '确认把这些文章(只问概念,摘要照抄)发给生成模型吗?(只写本地知识卡,不会发送任何消息)',
5590
+ default: false,
5591
+ }])
5592
+ confirmed = !!answer.confirmed
5593
+ if (!confirmed) {
5594
+ console.log(chalk.gray('已取消'))
5595
+ return
5596
+ }
5597
+ }
5598
+ const args = [script, '--source', String(opts.source), '--limit', String(limit),
5599
+ ...(opts.since ? ['--since', String(opts.since)] : []),
5600
+ ...(opts.until ? ['--until', String(opts.until)] : []),
5601
+ ...(topics.length ? ['--topic', topics.join(',')] : []),
5602
+ '--workers', String(workers),
5603
+ ...(opts.refreshSummaries ? ['--refresh-summaries'] : []),
5604
+ ...(confirmed ? ['--yes'] : []),
5605
+ ...(opts.json ? ['--json'] : []),
5606
+ ...(opts.dryRun ? ['--dry-run'] : [])]
5607
+ try {
5608
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
5609
+ timeout: 1_800_000, maxBuffer: 50 * 1024 * 1024,
5610
+ env: pythonProcessEnv(),
5611
+ })
5612
+ process.stdout.write(stdout)
5613
+ } catch (error) {
5614
+ if (opts.json) {
5615
+ console.log(JSON.stringify({ success: false, code: 'ARTICLE_NOTES_FAILED', action: 'article-notes', error: safeSubprocessError(error) }))
5616
+ } else {
5617
+ console.error(chalk.red(`\n✗ ${safeSubprocessError(error)}`))
5618
+ }
5619
+ process.exit(1)
5620
+ }
5621
+ })
5622
+
4946
5623
  // todos
4947
5624
  const todosCmd = program
4948
5625
  .command('todos')
@@ -5064,6 +5741,7 @@ program
5064
5741
  .option('--dry-run', '仅预览文章,不调用 AI 或写入日报')
5065
5742
  .option('--yes', '确认执行日报生成;JSON 模式需要此选项')
5066
5743
  .option('--no-ai', '关闭本次日报的所有 AI 处理')
5744
+ .option('--no-summary', '只判断不生成:不调 LLM 写摘要/标签/简报(主题与相关度仍由 Jev 判断),无需 DeepSeek key')
5067
5745
  .option('--json', '输出机器可读的最终结果;运行日志写入 stderr')
5068
5746
  .action(async (opts) => {
5069
5747
  const { spawn } = await import('child_process')
@@ -5096,6 +5774,8 @@ program
5096
5774
  const runPipeline = (targetDate: string): Promise<number> => new Promise((resolve) => {
5097
5775
  const args = [pipeline, '--date', targetDate, '--engine', noAi ? 'local' : 'deepseek', '--interest', 'AI', '--skip-wiki']
5098
5776
  if (noAi) args.push('--no-ai')
5777
+ // 与 --no-ai 不同:这一条保留判断(Jev),只关掉 LLM 的文字生成。
5778
+ if (opts.noSummary && !noAi) args.push('--no-summary')
5099
5779
  for (const source of opts.source || []) args.push('--source', source)
5100
5780
  if (opts.skipClassify) args.push('--skip-classify')
5101
5781
 
@@ -5300,7 +5980,7 @@ assistantCmd
5300
5980
  process.exit(1)
5301
5981
  }
5302
5982
  const { startDaemon } = await import('../src/services/assistantDaemon.js')
5303
- const r = startDaemon()
5983
+ const r = await startDaemon()
5304
5984
  if (opts.json) {
5305
5985
  console.log(JSON.stringify(r.started
5306
5986
  ? { success: true, started: true, pid: r.pid, ...preview }
@@ -5355,13 +6035,23 @@ assistantCmd
5355
6035
  const token = configService.get('wechatOcToken')
5356
6036
  const aiKey = configService.get('deepseekApiKey')
5357
6037
  const { privacyGate } = await import('../src/services/assistantPrivacy.js')
6038
+ const { readEndpoint } = await import('../src/panel/endpoint.js')
5358
6039
  const wl = String(configService.get('assistantWhitelist') || '').trim()
5359
6040
  const groups = String(configService.get('assistantGroupWhitelist') || '').trim()
6041
+ // 端点是**运行态**的权威来源:它由守护进程原子写出,而且读取端会探活(进程没了就当没有)。
6042
+ // 它能回答一个配置回答不了的问题:**通道到底接上了没有**。
6043
+ // `messageChannelLoggedIn` 说的只是"配了 token 吗"——token 配了而进程以本机模式跑着,
6044
+ // 这两个字段会一个 true 一个 false,今天完全没有别的办法区分。
6045
+ const endpoint = alive ? readEndpoint() : null
5360
6046
  if (opts.json) {
5361
6047
  console.log(JSON.stringify({
5362
6048
  success: true,
5363
6049
  daemonRunning: alive,
5364
6050
  messageChannelLoggedIn: !!token,
6051
+ channelActive: !!endpoint && endpoint.channel === 'wechat',
6052
+ mode: endpoint ? endpoint.channel : null,
6053
+ panelPort: endpoint ? endpoint.port : null,
6054
+ memoryBucket: endpoint ? endpoint.memoryBucket : null,
5365
6055
  aiConfigured: privacyGate.isLocalInference() || !!aiKey,
5366
6056
  localInference: privacyGate.isLocalInference(),
5367
6057
  privacyMode: privacyGate.mode(),
@@ -5373,6 +6063,12 @@ assistantCmd
5373
6063
  }
5374
6064
  console.log(`守护进程: ${alive ? chalk.green(`运行中 (pid ${pid})`) : chalk.gray('未运行')}`)
5375
6065
  console.log(`消息通道: ${token ? chalk.green('已登录') : chalk.red('未登录 (先 login-wechat)')}`)
6066
+ if (endpoint) {
6067
+ console.log(` └ 实际接入: ${endpoint.channel === 'wechat' ? chalk.green('微信') : chalk.yellow('仅本机入口')}`
6068
+ + `|本机入口 http://127.0.0.1:${endpoint.port}|记忆桶 ${endpoint.memoryBucket}`)
6069
+ } else if (alive) {
6070
+ console.log(chalk.gray(' └ 本机入口未启动(看 assistant log 里的“本机入口启动失败”)'))
6071
+ }
5376
6072
  console.log(`LLM 大脑: ${aiKey ? chalk.green('DeepSeek 已配置') : chalk.gray('未配置 (config set deepseekApiKey)')}`)
5377
6073
  console.log(`隐私模式: ${chalk.cyan(privacyGate.mode())}${privacyGate.isLocalInference() ? chalk.green(' (本地推理, 数据不出境)') : chalk.gray(' (工具结果脱敏后出境)')}`)
5378
6074
  console.log(`白名单: ${wl ? chalk.green(`${wl.split(/[,;\s]+/).filter(Boolean).length} 人`) : chalk.red('未设置 (默认拒绝所有人; config set assistantWhitelist "<@im.wechat ID>")')}`)
@@ -5406,6 +6102,30 @@ assistantCmd
5406
6102
  console.log(content)
5407
6103
  })
5408
6104
 
6105
+ assistantCmd
6106
+ .command('trace')
6107
+ .description('看助手最近几轮是怎么走的(判断层 → 工具 → 答复),含模型返回的推理内容')
6108
+ .option('-n, --last <number>', '看最近几轮', '3')
6109
+ .option('--json', '输出结构化轨迹')
6110
+ .action(async (opts) => {
6111
+ const { readTurns, describeTurn, traceFile } = await import('../src/services/assistantTrace.js')
6112
+ const count = parseCliInteger(opts.last, 'last', 1, 50, opts.json)
6113
+ const turns = readTurns(count)
6114
+ if (opts.json) {
6115
+ console.log(JSON.stringify({ success: true, file: traceFile(), count: turns.length, turns }))
6116
+ return
6117
+ }
6118
+ if (!turns.length) {
6119
+ console.log('还没有轨迹。助手收到消息之后才会有;文件:' + traceFile())
6120
+ return
6121
+ }
6122
+ console.log(traceFile())
6123
+ for (const turn of turns) {
6124
+ for (const line of describeTurn(turn)) console.log(line)
6125
+ console.log('')
6126
+ }
6127
+ })
6128
+
5409
6129
  assistantCmd
5410
6130
  .command('run')
5411
6131
  .description('前台运行 (调试用; 常驻请用 start)')
@@ -5448,6 +6168,193 @@ assistantCmd
5448
6168
  await assistant.start((line) => console.log(chalk.gray(line)))
5449
6169
  })
5450
6170
 
6171
+ /**
6172
+ * 本机面板:不登录微信也能跟助手说话的入口。
6173
+ *
6174
+ * **它只是客户端**:消息走守护进程的回环端点,于是两个入口共用一个大脑
6175
+ * (同一份记忆、同一条配额、同一条串行队列)。面板自己起一个 `AssistantService` 会变成
6176
+ * 第二个大脑——那是这个功能最容易走错的一步。
6177
+ */
6178
+ program
6179
+ .command('panel')
6180
+ .description('打开本机面板窗口 (Electron 悬浮球;没有 Electron 就降级用 Edge --app)')
6181
+ .option('--status', '只看状态,不打开窗口')
6182
+ .option('--ask <text>', '在命令行里问一句(面板减去像素)')
6183
+ .option('--dry-run', '仅预览,不启动窗口或守护进程')
6184
+ .option('--yes', '确认打开')
6185
+ .option('--json', '输出 JSON 格式')
6186
+ .action(async (opts) => {
6187
+ const { readEndpoint, endpointFile } = await import('../src/panel/endpoint.js')
6188
+ const { panelStatus, panelAsk, panelPair } = await import('../src/panel/client.js')
6189
+
6190
+ // ---------------------------------------------------------- 只读:状态
6191
+ if (opts.status) {
6192
+ const endpoint = readEndpoint()
6193
+ if (!endpoint) {
6194
+ if (opts.json) console.log(JSON.stringify({ success: false, code: 'PANEL_NOT_RUNNING', error: '本机入口没在运行(先 weflow-cli assistant start)' }))
6195
+ else console.log(chalk.gray('本机入口没在运行(先 weflow-cli assistant start)'))
6196
+ process.exit(1)
6197
+ }
6198
+ const r = await panelStatus(endpoint)
6199
+ if (opts.json) {
6200
+ console.log(JSON.stringify(r.ok
6201
+ ? { success: true, port: endpoint.port, ...r.data }
6202
+ : { success: false, code: r.code }))
6203
+ return
6204
+ }
6205
+ if (!r.ok) { console.log(chalk.yellow(`读状态失败: ${r.code}`)); return }
6206
+ const d = r.data
6207
+ console.log(`本机入口: ${chalk.green(`http://127.0.0.1:${endpoint.port}`)}`)
6208
+ console.log(`接入: ${d.channelActive ? '微信 + 本机' : '仅本机入口'}|今日 ${d.quota.used}/${d.quota.limit}|LLM ${d.aiConfigured ? '已配置' : '未配置'}`)
6209
+ console.log(chalk.gray(`记忆桶: ${d.memoryBucket}`))
6210
+ if (d.memoryNote) console.log(chalk.gray(` ${d.memoryNote}`))
6211
+ return
6212
+ }
6213
+
6214
+ // ---------------------------------------------------------- 在命令行里问一句
6215
+ if (opts.ask !== undefined) {
6216
+ const text = String(opts.ask || '').trim()
6217
+ if (!text) {
6218
+ if (opts.json) console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error: '--ask 不能为空' }))
6219
+ else console.log(chalk.red('--ask 不能为空'))
6220
+ process.exit(1)
6221
+ }
6222
+ if (opts.json && !opts.yes) {
6223
+ console.log(JSON.stringify({ success: false, code: 'CONFIRMATION_REQUIRED', error: '这会真的问助手(可能调用 AI 并占用配额),用 --yes 确认' }))
6224
+ process.exit(1)
6225
+ }
6226
+ const endpoint = readEndpoint()
6227
+ if (!endpoint) {
6228
+ if (opts.json) console.log(JSON.stringify({ success: false, code: 'PANEL_NOT_RUNNING', error: '本机入口没在运行(先 weflow-cli assistant start)' }))
6229
+ else console.log(chalk.yellow('本机入口没在运行(先 weflow-cli assistant start)'))
6230
+ process.exit(1)
6231
+ }
6232
+ const r = await panelAsk(endpoint, text)
6233
+ if (opts.json) {
6234
+ console.log(JSON.stringify(r.ok
6235
+ ? { success: true, status: r.data.status, reply: r.data.reply }
6236
+ : { success: false, code: r.code, error: r.error }))
6237
+ if (!r.ok) process.exit(1)
6238
+ return
6239
+ }
6240
+ console.log(r.ok ? r.data.reply : chalk.yellow(`没成: ${r.code}`))
6241
+ return
6242
+ }
6243
+
6244
+ // ---------------------------------------------------------- 打开窗口
6245
+ const { resolveElectronBinary, resolveBrowserBinary, electronLaunchArgs,
6246
+ browserLaunchArgs, browserProfileDir } = await import('../src/panel/launch.js')
6247
+ const { isDaemonAlive, startDaemon } = await import('../src/services/assistantDaemon.js')
6248
+ const { weflowHome } = await import('../src/panel/endpoint.js')
6249
+
6250
+ const electron = resolveElectronBinary({
6251
+ packageRoot: resolvePackageRoot(),
6252
+ localAppData: process.env.LOCALAPPDATA,
6253
+ programFiles: process.env.ProgramFiles,
6254
+ })
6255
+ const browser = electron.found ? { found: null } : resolveBrowserBinary({
6256
+ programFiles: process.env.ProgramFiles,
6257
+ programFilesX86: process.env['ProgramFiles(x86)'],
6258
+ localAppData: process.env.LOCALAPPDATA,
6259
+ })
6260
+ const shell: 'electron' | 'browser' | 'none' = electron.found ? 'electron' : browser.found ? 'browser' : 'none'
6261
+ const running = isDaemonAlive().alive
6262
+
6263
+ const preview = {
6264
+ action: 'panel.open',
6265
+ shell,
6266
+ daemonRunning: running,
6267
+ // 明确声明后果:面板会需要一个守护进程;关掉窗口**不会**顺手把它停掉
6268
+ willStartDaemon: !running,
6269
+ keepsRunningAfterClose: true,
6270
+ endpointFile: endpointFile(),
6271
+ }
6272
+ if (opts.dryRun) {
6273
+ if (opts.json) console.log(JSON.stringify({ success: true, dryRun: true, ...preview }))
6274
+ else console.log(chalk.cyan(`将打开面板(${shell === 'none' ? '没有可用的窗口程序,只会打印地址' : shell})`
6275
+ + `${running ? '' : ',并启动本机助手守护进程'}`))
6276
+ return
6277
+ }
6278
+ if (opts.json && !opts.yes) {
6279
+ console.log(JSON.stringify({ success: false, code: 'CONFIRMATION_REQUIRED', error: '使用 --yes 确认打开面板', ...preview }))
6280
+ process.exit(1)
6281
+ }
6282
+
6283
+ // 守护进程没起来就先起,并**轮询到真的就绪**——不要相信 spawn 的返回值
6284
+ // (`startDaemon` 里没有 `child.unref()`,stdio 又是 pipe;照抄 daily-reader 的 30×100ms 循环)
6285
+ if (!running) {
6286
+ const started = await startDaemon()
6287
+ if (!started.started && !started.error?.includes('已经运行')) {
6288
+ const msg = `本机助手启动失败: ${started.error ?? '未知原因'}`
6289
+ if (opts.json) console.log(JSON.stringify({ success: false, code: 'ASSISTANT_START_FAILED', error: msg, ...preview }))
6290
+ else console.log(chalk.red(msg))
6291
+ process.exit(1)
6292
+ }
6293
+ }
6294
+ let endpoint = readEndpoint()
6295
+ for (let attempt = 0; attempt < 30 && !endpoint; attempt++) {
6296
+ await new Promise((resolve) => setTimeout(resolve, 100))
6297
+ endpoint = readEndpoint()
6298
+ }
6299
+ if (!endpoint) {
6300
+ const msg = '本机入口还没就绪(看 weflow-cli assistant log 里的“本机入口启动失败”,端口可能被占)'
6301
+ if (opts.json) console.log(JSON.stringify({ success: false, code: 'PANEL_NOT_READY', error: msg, ...preview }))
6302
+ else console.log(chalk.red(msg))
6303
+ process.exit(1)
6304
+ }
6305
+
6306
+ const { spawn } = await import('child_process')
6307
+ let launched: string
6308
+ let url: string | null = null
6309
+ if (electron.found) {
6310
+ // argv 里**什么都没有**:不带 token、不带端口、不带 URL(命令行对同机任何进程可见)
6311
+ const child = spawn(electron.found, electronLaunchArgs(join(resolvePackageRoot(), 'resources', 'panel')), {
6312
+ detached: true, stdio: 'ignore', windowsHide: true,
6313
+ })
6314
+ child.unref()
6315
+ launched = 'electron'
6316
+ } else if (browser.found) {
6317
+ // 浏览器那条路只能靠一次性口令:它单次使用、60 秒过期,所以进命令行与浏览器历史都不算泄漏
6318
+ const pair = await panelPair(endpoint)
6319
+ if (!pair.ok) {
6320
+ const msg = `换一次性口令失败: ${pair.code}`
6321
+ if (opts.json) console.log(JSON.stringify({ success: false, code: 'PAIR_FAILED', error: msg, ...preview }))
6322
+ else console.log(chalk.red(msg))
6323
+ process.exit(1)
6324
+ }
6325
+ url = pair.data.url
6326
+ const child = spawn(browser.found, browserLaunchArgs({ url, profileDir: browserProfileDir(weflowHome()) }), {
6327
+ detached: true, stdio: 'ignore', windowsHide: true,
6328
+ })
6329
+ child.unref()
6330
+ launched = 'browser'
6331
+ } else {
6332
+ // 两条路都没有:**别静默什么都不做**,把地址交出去
6333
+ const pair = await panelPair(endpoint)
6334
+ url = pair.ok ? pair.data.url : `http://127.0.0.1:${endpoint.port}/panel`
6335
+ launched = 'none'
6336
+ }
6337
+
6338
+ if (opts.json) {
6339
+ // 展开在前、实际结果在后:`preview.shell` 是"打算用哪个",这里是"实际用了哪个",
6340
+ // 两者可能不同(例如打算用 Electron 但 spawn 之后没起来)——以后者为准
6341
+ console.log(JSON.stringify({ ...preview, success: true, shell: launched, port: endpoint.port, url }))
6342
+ return
6343
+ }
6344
+ if (launched === 'electron') {
6345
+ console.log(chalk.green(`✓ 面板已启动(记忆桶 ${endpoint.memoryBucket})`))
6346
+ console.log(chalk.gray(' 关掉窗口只是隐藏;助手仍在后台跑,要停它用 weflow-cli assistant stop'))
6347
+ } else if (launched === 'browser') {
6348
+ console.log(chalk.green('✓ 已用浏览器打开面板窗口'))
6349
+ console.log(chalk.yellow(' 注意:这条路给不了悬浮球——浏览器窗口不能无边框/置顶,也没有托盘与快捷键。'))
6350
+ console.log(chalk.gray(' 想要真·悬浮球:npm i -g electron 之后再跑一次'))
6351
+ } else {
6352
+ console.log(chalk.yellow('没找到 Electron,也没找到 Edge/Chrome。自己打开这个地址即可:'))
6353
+ console.log(chalk.cyan(` ${url}`))
6354
+ console.log(chalk.gray(' 想要悬浮球:npm i -g electron'))
6355
+ }
6356
+ })
6357
+
5451
6358
  program
5452
6359
  .command('daily-stats')
5453
6360
  .description('统计公众号推送频率与日报处理频率')
@@ -5633,10 +6540,10 @@ program
5633
6540
  ['wechat.search_public', '搜索全网公众号文章'],
5634
6541
  ['wechat.export_messages', '按稳定数据契约读取会话消息'],
5635
6542
  ]
5636
- const { MCP_READ_ONLY_TOOL_DEFS } = await import('../src/services/assistantTools.js')
6543
+ const { MCP_TOOL_DEFS } = await import('../src/services/assistantTools.js')
5637
6544
  const all = [
5638
6545
  ...STATIC_TOOLS,
5639
- ...MCP_READ_ONLY_TOOL_DEFS.filter(t => t.function.name !== 'get_stats')
6546
+ ...MCP_TOOL_DEFS.filter(t => t.function.name !== 'get_stats')
5640
6547
  .map(t => [`wechat.${t.function.name}`, t.function.description.split(/[。(]/)[0]] as [string, string]),
5641
6548
  ]
5642
6549
  for (const [n, d] of all) {