weflow-cli 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/ARCHITECTURE.md +62 -26
  2. package/CHANGELOG.md +948 -0
  3. package/OPERATIONS.md +65 -11
  4. package/README.en.md +14 -5
  5. package/README.md +21 -6
  6. package/bin/weflow-cli.ts +348 -39
  7. package/dist/bin/weflow-cli.js +363 -68
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.d.ts.map +1 -1
  10. package/dist/mcp-server/index.js +19 -1
  11. package/dist/mcp-server/index.js.map +1 -1
  12. package/dist/src/core/dbPathService.d.ts +12 -0
  13. package/dist/src/core/dbPathService.d.ts.map +1 -1
  14. package/dist/src/core/dbPathService.js +20 -3
  15. package/dist/src/core/dbPathService.js.map +1 -1
  16. package/dist/src/panel/server.d.ts.map +1 -1
  17. package/dist/src/panel/server.js +35 -0
  18. package/dist/src/panel/server.js.map +1 -1
  19. package/dist/src/services/assistantService.d.ts +19 -0
  20. package/dist/src/services/assistantService.d.ts.map +1 -1
  21. package/dist/src/services/assistantService.js +20 -0
  22. package/dist/src/services/assistantService.js.map +1 -1
  23. package/dist/src/services/assistantTools.d.ts +27 -1
  24. package/dist/src/services/assistantTools.d.ts.map +1 -1
  25. package/dist/src/services/assistantTools.js +169 -35
  26. package/dist/src/services/assistantTools.js.map +1 -1
  27. package/dist/src/services/configService.d.ts +2 -0
  28. package/dist/src/services/configService.d.ts.map +1 -1
  29. package/dist/src/services/configService.js +3 -2
  30. package/dist/src/services/configService.js.map +1 -1
  31. package/dist/src/services/messageQuery.d.ts +18 -0
  32. package/dist/src/services/messageQuery.d.ts.map +1 -1
  33. package/dist/src/services/messageQuery.js +29 -0
  34. package/dist/src/services/messageQuery.js.map +1 -1
  35. package/dist/src/services/wechatClient.d.ts +125 -0
  36. package/dist/src/services/wechatClient.d.ts.map +1 -0
  37. package/dist/src/services/wechatClient.js +344 -0
  38. package/dist/src/services/wechatClient.js.map +1 -0
  39. package/dist/src/services/wechatMedia.d.ts +44 -0
  40. package/dist/src/services/wechatMedia.d.ts.map +1 -0
  41. package/dist/src/services/wechatMedia.js +155 -0
  42. package/dist/src/services/wechatMedia.js.map +1 -0
  43. package/dist/src/services/wechatMessageService.d.ts +11 -0
  44. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  45. package/dist/src/services/wechatMessageService.js +55 -11
  46. package/dist/src/services/wechatMessageService.js.map +1 -1
  47. package/dist/src/types.d.ts +4 -0
  48. package/dist/src/types.d.ts.map +1 -1
  49. package/docs/CONTACT_DB_SCHEMA.md +1085 -0
  50. package/docs/DECISIONS.md +1274 -0
  51. package/docs/EXTENDING.md +69 -7
  52. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +6 -0
  53. package/docs/HEALTH-CHECK.md +12 -7
  54. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
  55. package/docs/MCP.md +2 -2
  56. package/docs/PROJECT_STATE.md +62 -5
  57. package/docs/RELEASING.md +115 -1
  58. package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
  59. package/logo.png +0 -0
  60. package/mcp-server/index.ts +18 -3
  61. package/package.json +5 -3
  62. package/resources/js/graph3d/NOTICE.txt +35 -0
  63. package/resources/js/graph3d/d3-binarytree.min.js +2 -0
  64. package/resources/js/graph3d/d3-dispatch.min.js +2 -0
  65. package/resources/js/graph3d/d3-force-3d.min.js +2 -0
  66. package/resources/js/graph3d/d3-octree.min.js +2 -0
  67. package/resources/js/graph3d/d3-quadtree.min.js +2 -0
  68. package/resources/js/graph3d/d3-timer.min.js +2 -0
  69. package/resources/js/graph3d/layout.mjs +63 -0
  70. package/resources/js/graph3d/three.min.js +6 -0
  71. package/resources/js/graph3d/viewer2d.js +448 -0
  72. package/resources/panel/index.html +12 -0
  73. package/resources/panel/mascot-blink-1.png +0 -0
  74. package/resources/panel/mascot-blink-2.png +0 -0
  75. package/resources/panel/mascot-lift-down-1.png +0 -0
  76. package/resources/panel/mascot-lift-down-2.png +0 -0
  77. package/resources/panel/mascot-lift-down-3.png +0 -0
  78. package/resources/panel/mascot-lift-down-4.png +0 -0
  79. package/resources/panel/mascot-lift-down-5.png +0 -0
  80. package/resources/panel/mascot-lift-down-6.png +0 -0
  81. package/resources/panel/mascot-lift-up-1.png +0 -0
  82. package/resources/panel/mascot-lift-up-2.png +0 -0
  83. package/resources/panel/mascot-lift-up-3.png +0 -0
  84. package/resources/panel/mascot-lift-up-4.png +0 -0
  85. package/resources/panel/mascot-lift-up-5.png +0 -0
  86. package/resources/panel/mascot-lift-up-6.png +0 -0
  87. package/resources/panel/mascot-tickle-1.png +0 -0
  88. package/resources/panel/mascot-tickle-2.png +0 -0
  89. package/resources/panel/mascot-tickle-3.png +0 -0
  90. package/resources/panel/mascot-tickle-4.png +0 -0
  91. package/resources/panel/panel.css +111 -0
  92. package/resources/panel/renderer.js +480 -8
  93. package/scripts/_utils.py +1203 -977
  94. package/scripts/biz_daily.py +25 -10
  95. package/scripts/chat_notes.py +15 -5
  96. package/scripts/check_readme_commands.py +426 -0
  97. package/scripts/compile_wiki.py +163 -57
  98. package/scripts/contact_schema.py +339 -0
  99. package/scripts/data/wechat_emoji.json +225 -0
  100. package/scripts/fav_notes.py +4 -1
  101. package/scripts/graph_2d.py +151 -0
  102. package/scripts/graph_3d.py +646 -0
  103. package/scripts/health_check.py +163 -26
  104. package/scripts/nt_decrypt.py +3 -3
  105. package/scripts/panel_frames.py +683 -0
  106. package/scripts/pipeline.py +21 -10
  107. package/scripts/quality_eval.py +5 -0
  108. package/scripts/rag_chat.py +33 -16
  109. package/scripts/reply_debt.py +26 -6
  110. package/scripts/semantic_search.py +130 -22
  111. package/scripts/vault_rag.py +42 -16
  112. package/scripts/vault_search.py +44 -8
  113. package/scripts/wechat_emoticon.py +6 -0
  114. package/scripts/wiki_lint.py +94 -17
  115. package/src/core/dbPathService.ts +22 -4
  116. package/src/panel/server.ts +33 -0
  117. package/src/services/assistantService.ts +26 -0
  118. package/src/services/assistantTools.ts +2452 -2319
  119. package/src/services/configService.ts +5 -2
  120. package/src/services/messageQuery.ts +29 -0
  121. package/src/services/wechatClient.ts +380 -0
  122. package/src/services/wechatMedia.ts +166 -0
  123. package/src/services/wechatMessageService.ts +62 -11
  124. package/src/types.ts +4 -0
  125. package/dist/src/utils/wechatEmoji.d.ts +0 -5
  126. package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
  127. package/dist/src/utils/wechatEmoji.js +0 -35
  128. package/dist/src/utils/wechatEmoji.js.map +0 -1
package/bin/weflow-cli.ts CHANGED
@@ -12,6 +12,7 @@ import { keyService } from '../src/core/keyService.js'
12
12
  import { NtCore } from '../src/core/ntCore.js'
13
13
  import { configService } from '../src/services/configService.js'
14
14
  import { chatService } from '../src/services/chatService.js'
15
+ import { emptyMessagesNote, looksLikeTalkerId } from '../src/services/messageQuery.js'
15
16
  import { exportService } from '../src/services/exportService.js'
16
17
  import { writeEvidencePackage } from '../src/services/evidenceService.js'
17
18
  import { resolveTalker as resolveTalkerCore } from '../src/utils/talkerUtils.js'
@@ -26,6 +27,7 @@ import { applyDerivedNtKeys, enableFavorites, detectFavDbPath } from '../src/ser
26
27
  import type { ChatSession } from '../src/types.js'
27
28
  import { disabledIds, describeSkill, readSkillBody, scanSkills, skillRoots, skillState } from '../src/services/assistantSkills.js'
28
29
  import { assistantScenes, validateSceneId, type Scene } from '../src/services/assistantScenes.js'
30
+ import { detectWechatClient, reportedDataDirs, samePath } from '../src/services/wechatClient.js'
29
31
 
30
32
  const program = new Command()
31
33
 
@@ -66,6 +68,22 @@ function parseCliInteger(value: unknown, field: string, minimum: number, maximum
66
68
  process.exit(1)
67
69
  }
68
70
 
71
+ /** 校验一个"只能是这几个值之一"的参数。与 `parseCliInteger` / `requireCliDate` 同一约定:
72
+ * `--json` 时错误也得是一份 JSON —— 否则机器调用方在 stdout 上拿到的是彩色散文,解析不了。
73
+ *
74
+ * 抽成一个是有理由的:`--line` 的校验原本在四个命令里各写了一遍,其中三处只打散文(`vault rag`、
75
+ * 顶层 `search`、`chat`)—— 同一份判断写四遍,就有三遍漏掉了 `--json` 分支。
76
+ * 错误文案由 choices 生成,保证与原来逐字一致('line 必须是 wiki、chat 或 all')。
77
+ */
78
+ function requireCliChoice(value: unknown, choices: readonly string[], field: string, json = false): string {
79
+ const text = String(value)
80
+ if (choices.includes(text)) return text
81
+ const error = `${field} 必须是 ${choices.slice(0, -1).join('、')} 或 ${choices[choices.length - 1]}`
82
+ if (json) console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', field, error }))
83
+ else console.log(chalk.red(error))
84
+ process.exit(1)
85
+ }
86
+
69
87
  function requireCliDate(value: string, json = false): string {
70
88
  try {
71
89
  parseLocalDateOrIso(value)
@@ -629,9 +647,11 @@ program
629
647
  // 读这一位的调用方按 false 走保守分支即可。
630
648
  mcpDefaultReadOnly: false,
631
649
  mcpSurface: {
632
- // 原文只写了 "minus save_memory" —— 而那张表也排除了 `look_at_image`(MCP 只取文本,
633
- // 它挂进侧信道的图没人接)。机器可读字段少说一句同样算不准,如实写全。
634
- derivedFrom: 'assistant tools minus save_memory and look_at_image',
650
+ // 这句话**手工补过两回**:先是只写 save_memory,后来补上 `look_at_image`,第三次加
651
+ // `set_todo_status` 时又漏了 —— 每次都是"表变了、这句话没变",而且没有任何东西会红。
652
+ // 现在名字**逐个与 `MCP_EXCLUDED` 对齐**,并由 `test/assistant-tools.test.ts` 从源码里核:
653
+ // 那张表再加一项而这里没跟,测试就红(同下面 requiresConfirm 的处理方式)。
654
+ derivedFrom: 'assistant tools minus save_memory, look_at_image and set_todo_status',
635
655
  writesFiles: ['export_chat'],
636
656
  // 逐个核实过(脚本里确实调云端模型的那些):
637
657
  // who_owes_reply/search_chats → Jev;search_semantic → 阿里云百炼嵌入 + Jev 重排;draft_reply → Jev + DeepSeek。
@@ -783,8 +803,13 @@ program
783
803
  console.log(chalk.gray(' 目录名未命中时将继续进行深度结构扫描,过程可能需要较长时间...'))
784
804
  }
785
805
  const detected = configuredDataRoot
786
- ? { success: true, path: configuredDataRoot }
787
- : await dbPathService.autoDetect({ searchDrives: opts.searchDrives, fullScan: opts.fullScan })
806
+ ? { success: true, path: configuredDataRoot, source: 'known' as const }
807
+ : await dbPathService.autoDetect({
808
+ searchDrives: opts.searchDrives, fullScan: opts.fullScan,
809
+ // **先问客户端自己**(它命令行上带着 `--wechat-files-path`):这是唯一"来自
810
+ // 客户端自己"的来源,其余都是按目录名猜。取不到就是空数组,走老路。
811
+ hintedRoots: await reportedDataDirs(),
812
+ })
788
813
  if (!detected.success || !detected.path) {
789
814
  console.log(chalk.red('✗ 未检测到微信数据目录'))
790
815
  if (!opts.searchDrives && !opts.fullScan) {
@@ -802,7 +827,10 @@ program
802
827
  }
803
828
  process.exit(1)
804
829
  }
805
- console.log(chalk.green(`✓ 检测到数据目录: ${detected.path}`))
830
+ // **说清是怎么找到的**:来源不同,可信度与后续排查都不同("客户端自己说的"最硬)
831
+ const howFound = detected.source === 'client' ? '(客户端自己报的)'
832
+ : detected.source === 'scan' ? '(全盘扫描找到的)' : ''
833
+ console.log(chalk.green(`✓ 检测到数据目录: ${detected.path}${howFound}`))
806
834
  configService.set('dbPath', detected.path)
807
835
  configService.set('dataVersion', '4.x')
808
836
 
@@ -1072,6 +1100,7 @@ const configurableKeys = [
1072
1100
  'skillDirs', 'skillDisabled',
1073
1101
  'dailySources', 'dailySourceCategories',
1074
1102
  'dailyExcludeTopics', 'dailyAiEnabled',
1103
+ 'wechatMediaDownload',
1075
1104
  'emoticonSeed',
1076
1105
  ] as const
1077
1106
 
@@ -1369,8 +1398,25 @@ program
1369
1398
  : await chatService.getMessages(talker, limit, offset)
1370
1399
 
1371
1400
  if (messages.length === 0) {
1372
- if (opts.json) { console.log(JSON.stringify({ success: true, talker, messages: [] })); return }
1401
+ // 拼错的 ID 会被 resolveTalker 原样放行 ⇒ 取不到消息。这里补一句话把两种情况分开,
1402
+ // 但**不改退出码、也不改 success**("没有消息"不是错误,脚本与 MCP 依赖这一点)。
1403
+ let note = ''
1404
+ if (looksLikeTalkerId(talkerInput)) {
1405
+ const [sessions, contacts] = await Promise.all([
1406
+ chatService.listSessions(talkerInput, 5),
1407
+ chatService.listContacts(talkerInput, 5),
1408
+ ])
1409
+ note = emptyMessagesNote(talkerInput, {
1410
+ sessionUsernames: sessions.map(s => s.username),
1411
+ contactUsernames: contacts.map(c => c.username),
1412
+ })
1413
+ }
1414
+ if (opts.json) {
1415
+ console.log(JSON.stringify({ success: true, talker, messages: [], ...(note ? { note } : {}) }))
1416
+ return
1417
+ }
1373
1418
  console.log(chalk.gray('未找到消息'))
1419
+ if (note) console.log(chalk.yellow(note))
1374
1420
  return
1375
1421
  }
1376
1422
 
@@ -1679,6 +1725,51 @@ syncCmd
1679
1725
  })
1680
1726
 
1681
1727
  // ==================== contacts ====================
1728
+ // contact-schema:读 contact.db 里**群**的 ext_buffer(本地只读:不联网、不调模型)
1729
+ //
1730
+ // 只解**实测验证过**的那几个字段(成员 userName/displayName/status/inviter + `#5`);
1731
+ // `#5` 曾经叫"附加参与者 id",实测是**成员 status 位 11 置位的那批 id**(本机 77/77 集合相等),
1732
+ // 所以输出改用 `statusBit11Ids`、不再说"额外参与者"(见 D-070)。
1733
+ // 顶层 `#3`/`#4` 的语义在本机被三次证伪(不是成员 id / 房间 id / 消息侧 id),所以脚本把它们
1734
+ // 原样放进 `unrecognized`,**不猜名字** —— 拿猜出来的名字写逻辑,就是拿不确定当事实。
1735
+ // 结构笔记见 `docs/CONTACT_DB_SCHEMA.md`,取舍见 D-067 / D-068 / D-070。
1736
+ //
1737
+ // `--contacts`(2026-10-07 加):同一个命令改读**联系人**的 `contact.extra_buffer`,输出这一轮验证的
1738
+ // 三个字段 —— `#5` 国家/地区码(ISO 3166-1 alpha-2)、`#13` = 该行 `biz_info.type`、`#41` = 资料最近更新时间。
1739
+ // `#4`/`#9` 已定性为"账号自身资料文本的汇集",是**内容**不是字段名,**故意不打印**(见 D-088)。
1740
+ program
1741
+ .command('contact-schema')
1742
+ .description('读 contact.db 的 ext_buffer:默认读群(成员 + #5);--contacts 读联系人(#5 地区码 / #13 服务类型 / #41 更新时间)—— 只解已验证字段,未识别的原样标注(本地、只读)')
1743
+ .option('-n, --limit <n>', '最多看几个(群 / 联系人)', '20')
1744
+ .option('--room <which>', '只看某个(群 / 联系人 的 username 或 id)')
1745
+ .option('--contacts', '改读联系人的 extra_buffer,而不是群')
1746
+ .option('--json', '输出机器可读结果')
1747
+ .action(async (opts) => {
1748
+ const { execFile } = await import('child_process')
1749
+ const { promisify } = await import('util')
1750
+ const execFileAsync = promisify(execFile)
1751
+ const script = join(resolvePackageRoot(), 'scripts', 'contact_schema.py')
1752
+ const limit = parseCliInteger(opts.limit, 'limit', 1, 100000, !!opts.json)
1753
+ const args = [script, '--limit', String(limit),
1754
+ ...(opts.room ? ['--room', opts.room] : []),
1755
+ ...(opts.contacts ? ['--contacts'] : []),
1756
+ ...(opts.json ? ['--json'] : [])]
1757
+ try {
1758
+ const { stdout, stderr } = await execFileAsync(getPythonCommand(), args, {
1759
+ timeout: 300_000, maxBuffer: 64 * 1024 * 1024, env: pythonProcessEnv(),
1760
+ })
1761
+ process.stdout.write(stdout)
1762
+ if (stderr && !opts.json) process.stderr.write(stderr)
1763
+ } catch (error) {
1764
+ if (opts.json) {
1765
+ console.log(JSON.stringify({ success: false, code: 'CONTACT_SCHEMA_FAILED', action: 'contact-schema', error: safeSubprocessError(error) }))
1766
+ } else {
1767
+ console.error(chalk.red(`\n✗ ${safeSubprocessError(error)}`))
1768
+ }
1769
+ process.exit(1)
1770
+ }
1771
+ })
1772
+
1682
1773
  program
1683
1774
  .command('contacts')
1684
1775
  .description('查看联系人列表')
@@ -2027,7 +2118,10 @@ program
2027
2118
  process.exit(1)
2028
2119
  }
2029
2120
  } else {
2030
- const timeout = parseCliInteger(opts.timeout, 'timeout', 1000, 600000)
2121
+ // `opts.json` 也要传:这一行在 `--json` 下**不可达**(上面就 `INTERACTIVE_REQUIRED` 退出了),
2122
+ // 但少传一个是安静的 —— 规则统一成"校验 helper 一律带上它",判据才写得出来
2123
+ // (`test/json-error-surface.test.ts` 末尾那条静态检查)。
2124
+ const timeout = parseCliInteger(opts.timeout, 'timeout', 1000, 600000, opts.json)
2031
2125
  const result = await keyService.autoGetDbKey(timeout, (msg) => {
2032
2126
  console.log(chalk.gray(` ${msg}`))
2033
2127
  })
@@ -2250,7 +2344,7 @@ whitelistCmd
2250
2344
  wxid = target
2251
2345
  } else {
2252
2346
  try {
2253
- wxid = await resolveTalker(target)
2347
+ wxid = await resolveTalker(target, !!opts.json, !!opts.json)
2254
2348
  const sessions = await chatService.listSessions(undefined, 50)
2255
2349
  const match = sessions.find(s => s.username === wxid)
2256
2350
  if (match) displayName = match.displayName || target
@@ -2437,7 +2531,7 @@ blacklistCmd
2437
2531
  wxid = target
2438
2532
  } else {
2439
2533
  try {
2440
- wxid = await resolveTalker(target)
2534
+ wxid = await resolveTalker(target, !!opts.json, !!opts.json)
2441
2535
  const sessions = await chatService.listSessions(undefined, 50)
2442
2536
  const match = sessions.find(s => s.username === wxid)
2443
2537
  if (match) displayName = match.displayName || target
@@ -3809,6 +3903,8 @@ program
3809
3903
  .option('--api-key <key>', 'DeepSeek API key')
3810
3904
  .option('--refresh-sources',
3811
3905
  '只把已有概念页的"来源"段补齐成当前全部来源(本地重写,**不调用模型**)')
3906
+ .option('--qualify-links',
3907
+ '把跨线同名的概念链接改成带路径的形式(本地重写,**不调用模型**)')
3812
3908
  .option('--cards <dir...>', '产卡目录,可写多个;默认所有 output/*-notes(与索引的引用数同一口径)')
3813
3909
  .option('--dry-run', '仅预览,不读取文章、调用 AI 或写入概念页')
3814
3910
  .option('--yes', '确认调用 AI 并生成概念页')
@@ -3834,6 +3930,22 @@ program
3834
3930
  return
3835
3931
  }
3836
3932
 
3933
+ if (opts.qualifyLinks) {
3934
+ // 本地消歧:两条线同名的那些链接按名字带路径(`Chat/Concepts/DeepSeek|DeepSeek`),
3935
+ // 免得 Obsidian 里那条裸链接命中另一条线的那张。**不调模型**,重跑幂等。
3936
+ await runConfirmedPythonMutation({
3937
+ action: 'wiki.qualifyLinks',
3938
+ script,
3939
+ args: ['--qualify-links', '--output', opts.output],
3940
+ cliOptions: opts,
3941
+ preview: { readsLocalKnowledge: true, rewritesExistingPages: true, usesAi: false },
3942
+ confirmationMessage: `确认把 ${opts.output} 里跨线同名的概念链接改成带路径的形式?`
3943
+ + '(本地重写,不调用 AI)',
3944
+ apiKey: opts.apiKey,
3945
+ timeout: 300_000,
3946
+ })
3947
+ return
3948
+ }
3837
3949
  const limit = parseCliInteger(opts.limit, 'limit', 1, 1000, opts.json)
3838
3950
  const minRefs = parseCliInteger(opts.minRefs ?? '1', 'min-refs', 1, 50, opts.json)
3839
3951
  await runConfirmedPythonMutation({
@@ -3864,8 +3976,9 @@ program
3864
3976
  .option('--wiki-limit <n>', '概念编译数', '20')
3865
3977
  .option('--source <name>', '仅处理指定公众号,可重复使用', (value: string, previous: string[]) => [...previous, value], [] as string[])
3866
3978
  .option('--skip-classify', '跳过 AI 后处理')
3867
- .option('--skip-wiki', '跳过概念编译')
3868
- .option('--skip-vault', '跳过 Vault 副本同步')
3979
+ // 这两个**默认不做**:Obsidian 自带的图谱视图是实时的,写一次 vault 图谱就重画一次。
3980
+ .option('--with-wiki', '把概念页写进 Obsidian Vault(默认不写:图谱只在你说了才变)')
3981
+ .option('--with-vault', '把当天内容副本同步进 Obsidian Vault(默认不写)')
3869
3982
  .option('--skip-html', '跳过 HTML 阅读器生成')
3870
3983
  .option('--skip-ai-report', '跳过 AI 深度阅读报告')
3871
3984
  .option('--no-ai', '关闭全部 AI 调用,保留抓取和本地输出')
@@ -3947,8 +4060,8 @@ program
3947
4060
  if (opts.date) args.push('--date', opts.date)
3948
4061
  for (const source of sources) args.push('--source', source)
3949
4062
  if (opts.skipClassify) args.push('--skip-classify')
3950
- if (opts.skipWiki) args.push('--skip-wiki')
3951
- if (opts.skipVault) args.push('--skip-vault')
4063
+ if (opts.withWiki) args.push('--with-wiki')
4064
+ if (opts.withVault) args.push('--with-vault')
3952
4065
  if (opts.skipHtml) args.push('--skip-html')
3953
4066
  if (opts.skipAiReport) args.push('--skip-ai-report')
3954
4067
  if (noAi) args.push('--no-ai')
@@ -4184,16 +4297,30 @@ program
4184
4297
  .description('Vault 搜索 — 文章+概念+笔记')
4185
4298
  .argument('<query>', '搜索关键词')
4186
4299
  .option('--type <type>', 'all / article / concept / note', 'all')
4300
+ .option('--line <line>', 'wiki=文章线(默认) / chat=聊天线 / all=两条都搜', 'wiki')
4187
4301
  .option('--top-k <n>', '返回数量', '10')
4302
+ // `vault_search.py` 一直有 `--days`(它的 docstring 自己就举 `--days 30`),CLI 从来没接 ——
4303
+ // 于是"只搜最近一周"这件事在这条路上做不到,窗口被脚本默认值(90)钉死。与 `search-index --days`
4304
+ // 同形:窗口的运行值由 CLI 说了算,默认值与脚本一致。
4305
+ .option('--days <n>', '日报文章取最近多少天(默认 90)', '90')
4306
+ // 这条 `--json` 曾经**不存在,而 JSON 又是唯一的输出**:参数里硬编码着 `'--json'`,
4307
+ // 人类模式虽然脚本里写着(`vault_search.py` 不带 `--json` 时打印带相关度的可读列表)
4308
+ // 却没有任何入口能到达,用户显式敲 `--json` 还会拿到 `unknown option` —— 与
4309
+ // `docs/EXTENDING.md` 那条"脚本或 AI 会驱动的命令都有 `--json` 模式"正相反。
4310
+ // 现在按兄弟命令(`vault rag`、顶层 `search`)的形状来:默认人类可读,`--json` 才转发。
4311
+ .option('--json', '输出 JSON 格式(含本地路径与标题,供本机脚本/AI 使用)')
4188
4312
  .action(async (query, opts) => {
4189
4313
  const pkgRoot = resolvePackageRoot()
4190
4314
  const script = join(pkgRoot, 'scripts', 'vault_search.py')
4191
- if (!['all', 'article', 'concept', 'note'].includes(opts.type)) {
4192
- console.log(chalk.red('type 必须是 all、article、concept 或 note'))
4193
- process.exit(1)
4194
- }
4195
- const topK = parseCliInteger(opts.topK, 'top-k', 1, 100)
4196
- await runPythonCmd(script, [query, '--type', opts.type, '--top-k', String(topK), '--json'])
4315
+ // 校验分支也要认 `--json`(`requireCliChoice` 里就带着这条约定):
4316
+ // 否则 JSON 模式下 stdout 是一行彩色散文,脚本解析不了。
4317
+ requireCliChoice(opts.type, ['all', 'article', 'concept', 'note'], 'type', opts.json)
4318
+ requireCliChoice(opts.line, ['wiki', 'chat', 'all'], 'line', opts.json)
4319
+ const topK = parseCliInteger(opts.topK, 'top-k', 1, 100, opts.json)
4320
+ const days = parseCliInteger(opts.days, 'days', 1, 3650, opts.json)
4321
+ await runPythonCmd(script, [query, '--type', opts.type, '--line', opts.line,
4322
+ '--top-k', String(topK), '--days', String(days),
4323
+ ...(opts.json ? ['--json'] : [])])
4197
4324
  })
4198
4325
  )
4199
4326
 
@@ -4205,6 +4332,7 @@ program
4205
4332
  .description('Vault 问答 — 基于知识库的 AI 对话')
4206
4333
  .argument('<question>', '问题')
4207
4334
  .option('--top-k <n>', '检索条数', '8')
4335
+ .option('--line <line>', 'wiki=文章线(默认) / chat=聊天线 / all=两条都搜', 'wiki')
4208
4336
  .option('--api-key <key>', 'DeepSeek API key')
4209
4337
  .option('--dry-run', '仅预览,不读取知识库或调用 AI')
4210
4338
  .option('--yes', '确认读取本地知识并发送筛选后的上下文到 AI')
@@ -4213,11 +4341,13 @@ program
4213
4341
  const pkgRoot = resolvePackageRoot()
4214
4342
  const script = join(pkgRoot, 'scripts', 'vault_rag.py')
4215
4343
  const topK = parseCliInteger(opts.topK, 'top-k', 1, 100, opts.json)
4344
+ requireCliChoice(opts.line, ['wiki', 'chat', 'all'], 'line', opts.json)
4216
4345
  const preview = {
4217
4346
  success: true,
4218
4347
  dryRun: true,
4219
4348
  action: 'vault.rag',
4220
4349
  topK,
4350
+ line: opts.line,
4221
4351
  readsLocalKnowledge: true,
4222
4352
  usesAi: true,
4223
4353
  sendsSelectedContextToAi: true,
@@ -4243,7 +4373,7 @@ program
4243
4373
  return
4244
4374
  }
4245
4375
  }
4246
- const args = ['--top-k', String(topK)]
4376
+ const args = ['--top-k', String(topK), '--line', opts.line]
4247
4377
  if (opts.json) args.push('--json')
4248
4378
  const { execFile } = await import('child_process')
4249
4379
  const { promisify } = await import('util')
@@ -4548,6 +4678,25 @@ program
4548
4678
  })
4549
4679
 
4550
4680
  // ==================== weread ====================
4681
+
4682
+ /** 微信读书服务失败时的统一出口。
4683
+ *
4684
+ * 9 个调用点(shelf / stats / notes×2 / search / book / review / discover / profile)原来都是一行式
4685
+ * 分支:把彩色散文打在 stdout 上再 `process.exit(1)`,**没有** json 支,而各命令的 `if (opts.json)`
4686
+ * 分支在它后面 ⇒ `weread shelf --json` 失败时 stdout 上只有一行 `✗ …`,调用方解析不了,也不知道
4687
+ * 是哪个命令失败了。全库其余同类出口都是"机器支发 JSON + exit"。
4688
+ *
4689
+ * (这里刻意**不**把那行旧代码原样抄出来:`test/json-error-surface.test.ts` 有一条绊线在扫那个形状,
4690
+ * 而注释里留一份字面量会让判据读到它 —— 今晚已经在另一条判据上踩过这个坑。)
4691
+ *
4692
+ * 人看的那一支保持原样(`console.log`,不动它的落点):把 CLI 的**错误**统一改走 stderr 是另一件事,
4693
+ * 现在库里两种都有(多数 `console.error`、少数 `console.log`),不在这条修复里顺手改。
4694
+ */
4695
+ function failWeread(res: { error?: string }, json = false): never {
4696
+ if (json) console.log(JSON.stringify({ success: false, code: 'WEREAD_FAILED', error: res.error }))
4697
+ else console.log(chalk.red(`✗ ${res.error}`))
4698
+ process.exit(1)
4699
+ }
4551
4700
  const wereadCmd = program
4552
4701
  .command('weread')
4553
4702
  .description('微信读书助手 — 书架、统计、笔记、搜索、书评、推荐')
@@ -4559,7 +4708,7 @@ program
4559
4708
  .action(async (opts) => {
4560
4709
  const weread = await getWereadService()
4561
4710
  const res = await weread.shelf()
4562
- if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
4711
+ if (!res.ok) failWeread(res, !!opts.json)
4563
4712
  const d = res.data!
4564
4713
  if (opts.json) { console.log(JSON.stringify(d, null, 2)); return }
4565
4714
 
@@ -4588,7 +4737,7 @@ program
4588
4737
  .action(async (opts) => {
4589
4738
  const weread = await getWereadService()
4590
4739
  const res = await weread.readData(opts.mode)
4591
- if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
4740
+ if (!res.ok) failWeread(res, !!opts.json)
4592
4741
  const d = res.data!
4593
4742
  if (opts.json) { console.log(JSON.stringify(d, null, 2)); return }
4594
4743
 
@@ -4636,7 +4785,7 @@ program
4636
4785
  const limit = parseCliInteger(opts.limit, 'limit', 1, 100, opts.json)
4637
4786
  if (opts.bookId) {
4638
4787
  const res = await weread.bookmarks(opts.bookId, limit)
4639
- if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
4788
+ if (!res.ok) failWeread(res, !!opts.json)
4640
4789
  const d = res.data!
4641
4790
  if (opts.json) { console.log(JSON.stringify(d, null, 2)); return }
4642
4791
  console.log(chalk.cyan(`📝 划线笔记 (${d.updated?.length || 0} 条):\n`))
@@ -4649,7 +4798,7 @@ program
4649
4798
  }
4650
4799
  } else {
4651
4800
  const res = await weread.notebooks(50)
4652
- if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
4801
+ if (!res.ok) failWeread(res, !!opts.json)
4653
4802
  const d = res.data!
4654
4803
  if (opts.json) { console.log(JSON.stringify(d, null, 2)); return }
4655
4804
  console.log(chalk.cyan(`📝 笔记本 (${d.books?.length || 0} 本有笔记):\n`))
@@ -4672,7 +4821,7 @@ program
4672
4821
  const weread = await getWereadService()
4673
4822
  const limit = parseCliInteger(opts.limit, 'limit', 1, 100, opts.json)
4674
4823
  const res = await weread.search(keyword, limit)
4675
- if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
4824
+ if (!res.ok) failWeread(res, !!opts.json)
4676
4825
  const d = res.data!
4677
4826
  if (opts.json) { console.log(JSON.stringify(d, null, 2)); return }
4678
4827
  console.log(chalk.cyan(`🔍 搜索: "${keyword}" (${d.books?.length || 0} 条):\n`))
@@ -4696,7 +4845,7 @@ program
4696
4845
  weread.bookInfo(bookId),
4697
4846
  weread.getProgress(bookId),
4698
4847
  ])
4699
- if (!infoRes.ok) { console.log(chalk.red(`✗ ${infoRes.error}`)); process.exit(1) }
4848
+ if (!infoRes.ok) failWeread(infoRes, !!opts.json)
4700
4849
  const b = infoRes.data!
4701
4850
  if (opts.json) { console.log(JSON.stringify(b, null, 2)); return }
4702
4851
 
@@ -4722,7 +4871,7 @@ program
4722
4871
  const weread = await getWereadService()
4723
4872
  const limit = parseCliInteger(opts.limit, 'limit', 1, 100, opts.json)
4724
4873
  const res = await weread.reviews(bookId, limit)
4725
- if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
4874
+ if (!res.ok) failWeread(res, !!opts.json)
4726
4875
  const d = res.data!
4727
4876
  if (opts.json) { console.log(JSON.stringify(d, null, 2)); return }
4728
4877
  console.log(chalk.cyan(`💬 书评 (${d.reviews?.length || 0} 条):\n`))
@@ -4748,7 +4897,7 @@ program
4748
4897
  const res = opts.bookId
4749
4898
  ? await weread.similar(opts.bookId, limit)
4750
4899
  : await weread.recommend(limit)
4751
- if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
4900
+ if (!res.ok) failWeread(res, !!opts.json)
4752
4901
  const d = res.data!
4753
4902
  if (opts.json) { console.log(JSON.stringify(d, null, 2)); return }
4754
4903
  const label = opts.bookId ? '相似推荐' : '个性化推荐'
@@ -4769,7 +4918,7 @@ program
4769
4918
  .action(async (opts) => {
4770
4919
  const weread = await getWereadService()
4771
4920
  const res = await weread.profile()
4772
- if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
4921
+ if (!res.ok) failWeread(res, !!opts.json)
4773
4922
  const p = res.data!
4774
4923
  if (opts.json) { console.log(JSON.stringify(p, null, 2)); return }
4775
4924
 
@@ -4812,6 +4961,8 @@ program
4812
4961
  .description('语义搜索知识库(聊天记录 + 文章)')
4813
4962
  .argument('<query>', '搜索关键词')
4814
4963
  .option('--top-k <n>', '返回数量', '10')
4964
+ .option('--line <line>', 'wiki=文章线(默认) / chat=聊天记录 / all=两条都搜', 'wiki')
4965
+ .option('--no-rerank', '不调用决策模型重排,只按向量/关键词相似度返回(回退到引入重排之前)')
4815
4966
  .option('--api-key <key>', 'DashScope embedding API key')
4816
4967
  .option('--dry-run', '仅预览,不读取索引或调用向量服务')
4817
4968
  .option('--yes', '确认执行可能调用云端向量服务的搜索')
@@ -4823,11 +4974,14 @@ program
4823
4974
  const pkgRoot = resolvePackageRoot()
4824
4975
  const script = join(pkgRoot, 'scripts', 'semantic_search.py')
4825
4976
  const topK = parseCliInteger(opts.topK, 'top-k', 1, 100, opts.json)
4977
+ requireCliChoice(opts.line, ['wiki', 'chat', 'all'], 'line', opts.json)
4826
4978
  const preview = {
4827
4979
  success: true,
4828
4980
  dryRun: true,
4829
4981
  action: 'semantic-search.query',
4830
4982
  topK,
4983
+ line: opts.line,
4984
+ noRerank: opts.rerank === false,
4831
4985
  readsLocalIndex: true,
4832
4986
  mayUseCloudEmbedding: true,
4833
4987
  }
@@ -4852,7 +5006,11 @@ program
4852
5006
  return
4853
5007
  }
4854
5008
  }
4855
- const args: string[] = [script, 'search', '--top-k', String(topK)]
5009
+ // `--no-rerank` 是**旗标**(不是密钥)⇒ 可以走 argv;查询本身仍然只走 env(见 `privacy-paths` 那条规矩)。
5010
+ // 接这条通道之前,`OPERATIONS.md` 一直在教用户敲 `search --no-rerank`,而 CLI 会答 `unknown option`
5011
+ // —— 同一份文档里 `--line` 是通的,所以它看起来像"能用的旗标"(2026-10-11 由文档命令面那道检查发现)。
5012
+ const args: string[] = [script, 'search', '--top-k', String(topK), '--line', opts.line,
5013
+ ...(opts.rerank === false ? ['--no-rerank'] : [])]
4856
5014
  try {
4857
5015
  const { stdout } = await execFileAsync(getPythonCommand(), args, {
4858
5016
  timeout: 60_000, maxBuffer: 10 * 1024 * 1024,
@@ -4950,6 +5108,7 @@ program
4950
5108
  .description('RAG 智能助手 — 基于聊天记录和公众号的对话式问答')
4951
5109
  .argument('[question]', '要问的问题(不传则进入交互模式)')
4952
5110
  .option('--top-k <n>', '检索数量', '10')
5111
+ .option('--line <line>', 'wiki=文章/文章概念(默认) / chat=聊天记录 / all=两条都搜', 'wiki')
4953
5112
  .option('--talker <name>', '限定联系人/群聊')
4954
5113
  .option('--api-key <key>', 'AI API key')
4955
5114
  .option('--dry-run', '仅预览,不读取知识库或调用 AI')
@@ -4962,11 +5121,13 @@ program
4962
5121
  const pkgRoot = resolvePackageRoot()
4963
5122
  const script = join(pkgRoot, 'scripts', 'rag_chat.py')
4964
5123
  const topK = parseCliInteger(opts.topK, 'top-k', 1, 100, opts.json)
5124
+ requireCliChoice(opts.line, ['wiki', 'chat', 'all'], 'line', opts.json)
4965
5125
  const preview = {
4966
5126
  success: true,
4967
5127
  dryRun: true,
4968
5128
  action: 'rag-chat.query',
4969
5129
  topK,
5130
+ line: opts.line,
4970
5131
  interactiveRequired: !question,
4971
5132
  readsLocalKnowledge: true,
4972
5133
  usesCloudEmbedding: true,
@@ -5001,7 +5162,7 @@ program
5001
5162
  }
5002
5163
  const args: string[] = [script]
5003
5164
  if (question) {
5004
- args.push('--top-k', String(topK))
5165
+ args.push('--top-k', String(topK), '--line', opts.line)
5005
5166
  if (opts.json) args.push('--json')
5006
5167
  try {
5007
5168
  const { stdout } = await execFileAsync(getPythonCommand(), args, {
@@ -5019,7 +5180,7 @@ program
5019
5180
  }
5020
5181
  } else {
5021
5182
  // 交互模式:使用 spawn 保持终端交互
5022
- args.push('--interactive', '--top-k', String(topK))
5183
+ args.push('--interactive', '--top-k', String(topK), '--line', opts.line)
5023
5184
  const child = spawn(getPythonCommand(), args, {
5024
5185
  windowsHide: true,
5025
5186
  stdio: 'inherit',
@@ -5288,7 +5449,7 @@ program
5288
5449
  // draft:判断在前、起草在后、**绝不发送**
5289
5450
  program
5290
5451
  .command('draft <talker>')
5291
- .description('帮我起草回复:先判断(意图/风险/该不该给实质),再起草候选 —— **只产出文本,不发送**')
5452
+ .description('帮我起草回复:先判断(意图/风险/该不该给实质),再起草候选 —— **默认只产出文本;给了 --pick N --send 才会替你发送**')
5292
5453
  .option('--count <n>', '要几条候选', '3')
5293
5454
  .option('--pick <n>', '选第几条(从 1 数);不带 --send 时只把那条的完整原文打出来')
5294
5455
  .option('--send', '把选中的那条发出去 —— **必须同时给 --pick**(不允许"自己挑一条发"),仍需 --yes/--dry-run')
@@ -5510,18 +5671,86 @@ program
5510
5671
  }
5511
5672
  })
5512
5673
 
5674
+ // wiki graph:把概念图谱导成一张**自包含**的 3D 页面(全本地:只读 Vault、不联网、不调用模型)
5675
+ //
5676
+ // 自包含是硬要求,不是顺手:页面用 `file://` 双击打开,那时 `fetch` 会被 CORS 挡死,
5677
+ // 所以库、数据、坐标全部内联进去 —— 也因此三个库随 npm 包发(见 resources/js/graph3d/NOTICE.txt)。
5678
+ // 布局(250 tick 实测 55 秒)在构建期用 Node 算完并缓存,打开页面即用。
5679
+ program.commands.find(c => c.name() === 'wiki')?.addCommand(
5680
+ new Command('graph')
5681
+ .description('把概念图谱导成一张自包含的图谱页面(只读本地、不联网;生成的 HTML 双击就能逛)。--flat 出 2D 版')
5682
+ .option('--vault <dir>', 'Vault 根目录(默认 output/wechat-vault)')
5683
+ .option('-o, --out <file>', '页面写到哪(默认 output/knowledge-graph-3d.html)')
5684
+ .option('--ticks <n>', '力导向迭代次数(默认 250,越多越舒展也越慢)')
5685
+ .option('--cache <dir>', '布局缓存目录(派生数据;测试/多份图分开放时用)')
5686
+ .option('--flat', '2D 版:平面图(更像 Obsidian 那种),canvas 渲染,输出默认 knowledge-graph-2d.html')
5687
+ .option('--min-degree <n>', '只画在原图里连接数 ≥N 的概念(去掉细枝看骨架;0 = 全画)')
5688
+ .option('--line <which>', '只画一条线:all / wiki / chat')
5689
+ .option('--dry-run', '只报概念与链接数:不写文件、也不算布局(本地,零改动)')
5690
+ .option('--open', '生成后用默认浏览器打开它')
5691
+ .option('--json', '输出机器可读结果')
5692
+ .action(async (opts) => {
5693
+ const { execFile } = await import('child_process')
5694
+ const { promisify } = await import('util')
5695
+ const execFileAsync = promisify(execFile)
5696
+ // 2D 与 3D 是**两个入口脚本**:图怎么建共用(graph_2d 直接 import graph_3d),
5697
+ // 换的是布局维度与渲染器 —— 两套渲染器塞一个文件就没人看得完了。
5698
+ const script = join(resolvePackageRoot(), 'scripts', opts.flat ? 'graph_2d.py' : 'graph_3d.py')
5699
+ const ticks = opts.ticks === undefined ? undefined : parseCliInteger(opts.ticks, 'ticks', 1, 100000, !!opts.json)
5700
+ // `--json` 一律要:CLI 要拿到 out 路径与计数,才决定怎么印。开关只决定**给谁看**。
5701
+ const minDegree = opts.minDegree === undefined
5702
+ ? undefined : parseCliInteger(opts.minDegree, 'min-degree', 0, 100000, !!opts.json)
5703
+ const args = [script,
5704
+ ...(opts.vault ? ['--vault', opts.vault] : []),
5705
+ ...(opts.out ? ['--out', opts.out] : []),
5706
+ ...(opts.cache ? ['--cache', opts.cache] : []),
5707
+ ...(ticks === undefined ? [] : ['--ticks', String(ticks)]),
5708
+ ...(minDegree === undefined || minDegree === 0 ? [] : ['--min-degree', String(minDegree)]),
5709
+ ...(opts.line ? ['--line', opts.line] : []),
5710
+ ...(opts.dryRun ? ['--dry-run'] : []),
5711
+ '--json']
5712
+ try {
5713
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
5714
+ timeout: 900_000, maxBuffer: 64 * 1024 * 1024, env: pythonProcessEnv(),
5715
+ })
5716
+ const body = JSON.parse(stdout.slice(stdout.indexOf('{')))
5717
+ if (opts.json) {
5718
+ console.log(JSON.stringify(body))
5719
+ } else if (body.dryRun) {
5720
+ console.log(`概念 ${body.nodes} 个、链接 ${body.links} 条(--dry-run:没有写文件)`)
5721
+ } else {
5722
+ console.log(chalk.green(`✓ 已生成 ${body.out}`))
5723
+ console.log(chalk.gray(` 概念 ${body.nodes} / 链接 ${body.links} / ${body.mb} MB · ${opts.flat ? '2D' : '3D'} · 双击就能逛,不需要服务器`))
5724
+ }
5725
+ if (!opts.dryRun && opts.open && body.out) await openLocalUrl(body.out)
5726
+ } catch (error) {
5727
+ if (opts.json) {
5728
+ console.log(JSON.stringify({ success: false, code: 'GRAPH_FAILED', action: 'graph-3d', error: safeSubprocessError(error) }))
5729
+ } else {
5730
+ console.error(chalk.red(`\n✗ ${safeSubprocessError(error)}`))
5731
+ }
5732
+ process.exit(1)
5733
+ }
5734
+ })
5735
+ )
5736
+
5513
5737
  // wiki lint:知识库体检(全本地:不联网、不调用模型)
5514
5738
  program.commands.find(c => c.name() === 'wiki')?.addCommand(
5515
5739
  new Command('lint')
5516
5740
  .description('知识库体检:断链、孤儿、空页、同名(本地,不联网)')
5517
- .option('--dir <dir>', '概念页目录', './output/wechat-vault/Wiki/Concepts')
5741
+ // 不给默认值:不传 `--dir` 时体检脚本自己的默认(清单里的**全部线**)。
5742
+ // 这里曾经默认只给文章线,而脚本默认两条线都体检 ⇒"半开半合":命令行跑出来
5743
+ // 与脚本跑出来口径不同,而两边都不报错。
5744
+ .option('--dir <dirs...>', '概念页目录(可重复;不传则体检清单里的全部线)')
5518
5745
  .option('--json', '输出机器可读结果')
5519
5746
  .action(async (opts) => {
5520
5747
  const { execFile } = await import('child_process')
5521
5748
  const { promisify } = await import('util')
5522
5749
  const execFileAsync = promisify(execFile)
5523
5750
  const script = join(resolvePackageRoot(), 'scripts', 'wiki_lint.py')
5524
- const args = [script, '--dir', String(opts.dir), ...(opts.json ? ['--json'] : [])]
5751
+ const dirs = Array.isArray(opts.dir) ? opts.dir : (opts.dir ? [opts.dir] : [])
5752
+ const args = [script, ...dirs.flatMap((d: string) => ['--dir', String(d)]),
5753
+ ...(opts.json ? ['--json'] : [])]
5525
5754
  try {
5526
5755
  const { stdout } = await execFileAsync(getPythonCommand(), args, {
5527
5756
  timeout: 120_000, maxBuffer: 20 * 1024 * 1024, env: pythonProcessEnv(),
@@ -5543,6 +5772,8 @@ program
5543
5772
  .option('--limit <n>', '最多处理几个会话', '40')
5544
5773
  .option('--dry-run', '仅预览:几个会话、多少字符会发给生成模型(只读本地,零出境)')
5545
5774
  .option('--yes', '确认把对话发送给生成模型')
5775
+ .option('--transcribe-voice',
5776
+ '把缺转写的语音**本机**识别掉再产卡(本地 whisper,慢、吃 CPU、不出网;配合 --dry-run 就是只补转写、不调模型)')
5546
5777
  .option('--json', '输出机器可读结果;执行仍需 --yes')
5547
5778
  .action(async (opts) => {
5548
5779
  const { execFile } = await import('child_process')
@@ -5578,6 +5809,7 @@ program
5578
5809
  }
5579
5810
  const args = [script, '--days', String(days), '--limit', String(limit),
5580
5811
  ...(confirmed ? ['--yes'] : []),
5812
+ ...(opts.transcribeVoice ? ['--transcribe-voice'] : []),
5581
5813
  ...(opts.json ? ['--json'] : []),
5582
5814
  ...(opts.dryRun ? ['--dry-run'] : [])]
5583
5815
  try {
@@ -5951,7 +6183,7 @@ program
5951
6183
  const isComplete = (targetDate: string): boolean => Object.values(artifactStatus(targetDate)).every(Boolean)
5952
6184
 
5953
6185
  const runPipeline = (targetDate: string): Promise<number> => new Promise((resolve) => {
5954
- const args = [pipeline, '--date', targetDate, '--engine', noAi ? 'local' : 'deepseek', '--interest', 'AI', '--skip-wiki']
6186
+ const args = [pipeline, '--date', targetDate, '--engine', noAi ? 'local' : 'deepseek', '--interest', 'AI']
5955
6187
  if (noAi) args.push('--no-ai')
5956
6188
  // 与 --no-ai 不同:这一条保留判断(Jev),只关掉 LLM 的文字生成。
5957
6189
  if (opts.noSummary && !noAi) args.push('--no-summary')
@@ -7095,6 +7327,10 @@ program
7095
7327
  console.log(chalk.gray('AI 助手现在可以使用以下工具:'))
7096
7328
  } else {
7097
7329
  console.log(json)
7330
+ // 机器模式到这里就结束:下面那段工具清单与"复制以上 JSON…"是**给人看的**,原来无条件打在
7331
+ // stdout 上 ⇒ `mcp-config --json-result | jq` 断在第 14 行(2026-10-12 由只读驱动扫描发现;
7332
+ // 写路径本来就早退发 JSON,所以只有读路径这一处漏)。
7333
+ if (opts.jsonResult) return
7098
7334
  }
7099
7335
  console.log(chalk.cyan('\n可用的 MCP 工具:'))
7100
7336
  const STATIC_TOOLS: Array<[string, string]> = [
@@ -7133,6 +7369,28 @@ program
7133
7369
  .description('检查运行环境(Python、pip 依赖、数据库配置)')
7134
7370
  .option('--json', '输出 JSON 格式,不包含数据库路径或密钥')
7135
7371
  .action(async (opts) => {
7372
+ // 微信客户端的**版本**与它自报的**数据目录**:两个分支都要用,所以在这里算一次。
7373
+ // 判据见 `wechatClient.ts`(版本取活进程的 `--user-lib-dir`;取不到就是"未知",不是"没问题")。
7374
+ const client = await detectWechatClient()
7375
+ // 客户端自报的数据目录 vs 配置里那个数据库推出来的根:**只比是不是同一份**,
7376
+ // 路径本身不进机器可读输出(`wechat-check/v1` 一直是不含路径的)。
7377
+ //
7378
+ // **只在 4.x 上比**:这个根是从 `ntDbPath`(4.x 的库)推的,而客户端自报的那条来自 4.x 客户端
7379
+ // —— 只用 3.x 的用户(`dbPath3x`)比出来的"不是同一份"没有意义,而这句话**只在 false 时打印**
7380
+ // ⇒ 那就是一条假警告。判据不成立时给 null("不知道"),不是 false。
7381
+ const { dirname } = await import('node:path')
7382
+ // **四层**:`…/xwechat_files/<wxid>/db_storage/message/message_0.db`
7383
+ // → message → db_storage → <wxid> → xwechat_files。少一层就退到 `<wxid>` 目录,
7384
+ // 于是两个路径永远不相等、永远报"不是同一份"(一个看着很合理的假警告)。
7385
+ const dbPathConfigured = String(configService.get('dataVersion') || '') === '4.x'
7386
+ ? String(configService.get('ntDbPath') || '')
7387
+ : ''
7388
+ const configuredRoot = dbPathConfigured
7389
+ ? dirname(dirname(dirname(dirname(dbPathConfigured))))
7390
+ : ''
7391
+ const dataDirMatchesConfig = (client.dataDirs.length && configuredRoot)
7392
+ ? client.dataDirs.some(dir => samePath(dir, configuredRoot))
7393
+ : null
7136
7394
  if (opts.json) {
7137
7395
  const { execFileSync } = await import('child_process')
7138
7396
  const requiredDependencies = ['sqlcipher3', 'html2text', 'zstandard', 'cryptography']
@@ -7186,6 +7444,26 @@ program
7186
7444
  messageChannelLoggedIn: !!configService.get('wechatOcToken'),
7187
7445
  daemonRunning: daemonAlive,
7188
7446
  },
7447
+ // `version` 只从活进程取;取不到就是 null + 一句 `note`(**不是**"没问题")。
7448
+ // `unusableVersionDirs` 是磁盘上的残留版本目录(没有 `Weixin.dll`)—— 指向它们的加载会失败。
7449
+ wechatClient: {
7450
+ running: client.running,
7451
+ version: client.version,
7452
+ loadedDirs: client.loadedDirs,
7453
+ // 客户端自报的构建 token(并列的第二来源;**不拿它推版本号**,编码规则没解出来)
7454
+ versionTokens: client.versionTokens,
7455
+ note: client.note,
7456
+ unusableVersionDirs: client.unusableVersionDirs,
7457
+ // 客户端**自己说**数据目录在哪(与配置不一致时说明两边不是同一份库)
7458
+ dataDirs: client.dataDirs,
7459
+ // 只报结论(true/false/null),**不带路径** —— 这条 schema 一直是不含路径的
7460
+ dataDirMatchesConfig,
7461
+ // `update.data` 在场 ⇒ 加载路径可能不由命令行决定:这条不确定必须跟着版本一起出去
7462
+ updateData: client.updateData,
7463
+ // 只报条数与最近时间(不读崩溃内容)
7464
+ crashRecords: client.crashRecords,
7465
+ caveats: client.caveats,
7466
+ },
7189
7467
  }
7190
7468
  console.log(JSON.stringify(data, null, 2))
7191
7469
  return
@@ -7270,7 +7548,38 @@ program
7270
7548
  }
7271
7549
  }
7272
7550
 
7273
- // 6. Assistant (第二大脑)
7551
+ // 6. 微信客户端版本(`client` / `dataDirMatchesConfig` 在 action 开头已经算过)
7552
+ console.log(chalk.white('\n微信客户端:'))
7553
+ if (client.version) {
7554
+ // 并列报上客户端自报的构建 token:这是"哪个构建"的第二个来源(**不用它推版本号**)
7555
+ const token = client.versionTokens.length ? chalk.gray(` · 自报构建 ${client.versionTokens.join('、')}`) : ''
7556
+ console.log(chalk.green(` ✓ 运行时加载 ${client.version}`) + chalk.gray(` (${client.loadedDirs[0]})`) + token)
7557
+ } else {
7558
+ // **说"未知"而不是"没问题"**:取不到就说取不到,别给人一个假的安心
7559
+ console.log(chalk.gray(` ○ 版本未知 — ${client.note}`))
7560
+ }
7561
+ for (const dead of client.unusableVersionDirs) {
7562
+ console.log(chalk.yellow(` ! 残留版本目录 ${dead.dir}:只剩 ${dead.files} 个文件(没有 Weixin.dll)`))
7563
+ console.log(chalk.gray(' 指向它的加载会失败;如果客户端起不来,先看这里'))
7564
+ }
7565
+ // `update.data` 在场时上面那个版本**未必**是加载的那个 —— 这句话要跟着它一起出现
7566
+ for (const caveat of client.caveats) {
7567
+ console.log(chalk.yellow(` ⚠ ${caveat}`))
7568
+ }
7569
+ if (client.dataDirs.length) {
7570
+ console.log(chalk.gray(` · 客户端自报数据目录: ${client.dataDirs.join('、')}`))
7571
+ if (dataDirMatchesConfig === false) {
7572
+ // 不一致 = 两边可能不是同一份库(本机数据目录还有 D 盘软链,这条已经按真身比过)
7573
+ console.log(chalk.yellow(' ⚠ 与配置里那个数据库推出来的根**不是同一份** —— 先确认你在读的是客户端正在写的那份'))
7574
+ }
7575
+ }
7576
+ const crashes = client.crashRecords.reports + client.crashRecords.records
7577
+ if (crashes > 0) {
7578
+ const when = client.crashRecords.newest ? client.crashRecords.newest.slice(0, 16).replace('T', ' ') : '时间读不到'
7579
+ console.log(chalk.gray(` · 客户端崩溃记录 ${crashes} 份(最近 ${when})—— 客户端崩过时数据读不出来,先看这条`))
7580
+ }
7581
+
7582
+ // 7. Assistant (第二大脑)
7274
7583
  console.log(chalk.white('\n第二大脑 Agent:'))
7275
7584
  const aiKey = configService.get('deepseekApiKey')
7276
7585
  const aiBase = configService.get('aiBaseUrl')
@@ -7491,7 +7800,7 @@ async function showInteractiveMenu() {
7491
7800
  const { spawn } = await import('child_process')
7492
7801
  const pipeline = join(resolvePackageRoot(), 'scripts', 'pipeline.py')
7493
7802
  console.log(chalk.cyan(`\n📰 正在生成 ${dateStr} 公众号日报...\n`))
7494
- const child = spawn(getPythonCommand(), [pipeline, '--date', dateStr, '--interest', 'AI', '--skip-wiki'], {
7803
+ const child = spawn(getPythonCommand(), [pipeline, '--date', dateStr, '--interest', 'AI'], {
7495
7804
  windowsHide: true,
7496
7805
  stdio: 'inherit',
7497
7806
  env: pythonProcessEnv(apiKey),