weflow-cli 1.9.0 → 1.11.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 (134) hide show
  1. package/ARCHITECTURE.md +62 -26
  2. package/CHANGELOG.md +1051 -0
  3. package/OPERATIONS.md +99 -13
  4. package/README.en.md +14 -5
  5. package/README.md +23 -8
  6. package/bin/weflow-cli.ts +364 -40
  7. package/dist/bin/weflow-cli.js +381 -69
  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/exportService.d.ts +43 -0
  32. package/dist/src/services/exportService.d.ts.map +1 -1
  33. package/dist/src/services/exportService.js +116 -11
  34. package/dist/src/services/exportService.js.map +1 -1
  35. package/dist/src/services/messageQuery.d.ts +18 -0
  36. package/dist/src/services/messageQuery.d.ts.map +1 -1
  37. package/dist/src/services/messageQuery.js +29 -0
  38. package/dist/src/services/messageQuery.js.map +1 -1
  39. package/dist/src/services/wechatClient.d.ts +125 -0
  40. package/dist/src/services/wechatClient.d.ts.map +1 -0
  41. package/dist/src/services/wechatClient.js +344 -0
  42. package/dist/src/services/wechatClient.js.map +1 -0
  43. package/dist/src/services/wechatMedia.d.ts +44 -0
  44. package/dist/src/services/wechatMedia.d.ts.map +1 -0
  45. package/dist/src/services/wechatMedia.js +155 -0
  46. package/dist/src/services/wechatMedia.js.map +1 -0
  47. package/dist/src/services/wechatMessageService.d.ts +11 -0
  48. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  49. package/dist/src/services/wechatMessageService.js +55 -11
  50. package/dist/src/services/wechatMessageService.js.map +1 -1
  51. package/dist/src/types.d.ts +4 -0
  52. package/dist/src/types.d.ts.map +1 -1
  53. package/docs/CONTACT_DB_SCHEMA.md +1085 -0
  54. package/docs/DECISIONS.md +1274 -0
  55. package/docs/EXTENDING.md +69 -7
  56. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +27 -0
  57. package/docs/HEALTH-CHECK.md +12 -7
  58. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
  59. package/docs/MCP.md +2 -2
  60. package/docs/PROJECT_STATE.md +64 -5
  61. package/docs/RELEASING.md +128 -1
  62. package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
  63. package/logo.png +0 -0
  64. package/mcp-server/index.ts +18 -3
  65. package/package.json +5 -3
  66. package/resources/js/graph3d/NOTICE.txt +35 -0
  67. package/resources/js/graph3d/d3-binarytree.min.js +2 -0
  68. package/resources/js/graph3d/d3-dispatch.min.js +2 -0
  69. package/resources/js/graph3d/d3-force-3d.min.js +2 -0
  70. package/resources/js/graph3d/d3-octree.min.js +2 -0
  71. package/resources/js/graph3d/d3-quadtree.min.js +2 -0
  72. package/resources/js/graph3d/d3-timer.min.js +2 -0
  73. package/resources/js/graph3d/layout.mjs +63 -0
  74. package/resources/js/graph3d/three.min.js +6 -0
  75. package/resources/js/graph3d/viewer2d.js +448 -0
  76. package/resources/panel/index.html +12 -0
  77. package/resources/panel/mascot-blink-1.png +0 -0
  78. package/resources/panel/mascot-blink-2.png +0 -0
  79. package/resources/panel/mascot-lift-down-1.png +0 -0
  80. package/resources/panel/mascot-lift-down-2.png +0 -0
  81. package/resources/panel/mascot-lift-down-3.png +0 -0
  82. package/resources/panel/mascot-lift-down-4.png +0 -0
  83. package/resources/panel/mascot-lift-down-5.png +0 -0
  84. package/resources/panel/mascot-lift-down-6.png +0 -0
  85. package/resources/panel/mascot-lift-up-1.png +0 -0
  86. package/resources/panel/mascot-lift-up-2.png +0 -0
  87. package/resources/panel/mascot-lift-up-3.png +0 -0
  88. package/resources/panel/mascot-lift-up-4.png +0 -0
  89. package/resources/panel/mascot-lift-up-5.png +0 -0
  90. package/resources/panel/mascot-lift-up-6.png +0 -0
  91. package/resources/panel/mascot-tickle-1.png +0 -0
  92. package/resources/panel/mascot-tickle-2.png +0 -0
  93. package/resources/panel/mascot-tickle-3.png +0 -0
  94. package/resources/panel/mascot-tickle-4.png +0 -0
  95. package/resources/panel/panel.css +111 -0
  96. package/resources/panel/renderer.js +480 -8
  97. package/scripts/_utils.py +1203 -977
  98. package/scripts/biz_daily.py +25 -10
  99. package/scripts/chat_notes.py +15 -5
  100. package/scripts/check_readme_commands.py +426 -0
  101. package/scripts/compile_wiki.py +163 -57
  102. package/scripts/contact_schema.py +339 -0
  103. package/scripts/data/wechat_emoji.json +225 -0
  104. package/scripts/export_chat_html.py +346 -6
  105. package/scripts/fav_notes.py +4 -1
  106. package/scripts/graph_2d.py +151 -0
  107. package/scripts/graph_3d.py +646 -0
  108. package/scripts/health_check.py +163 -26
  109. package/scripts/nt_decrypt.py +3 -3
  110. package/scripts/panel_frames.py +683 -0
  111. package/scripts/pipeline.py +21 -10
  112. package/scripts/quality_eval.py +5 -0
  113. package/scripts/rag_chat.py +33 -16
  114. package/scripts/reply_debt.py +26 -6
  115. package/scripts/semantic_search.py +212 -58
  116. package/scripts/vault_rag.py +42 -16
  117. package/scripts/vault_search.py +44 -8
  118. package/scripts/wechat_emoticon.py +6 -0
  119. package/scripts/wiki_lint.py +94 -17
  120. package/src/core/dbPathService.ts +22 -4
  121. package/src/panel/server.ts +33 -0
  122. package/src/services/assistantService.ts +26 -0
  123. package/src/services/assistantTools.ts +2452 -2319
  124. package/src/services/configService.ts +5 -2
  125. package/src/services/exportService.ts +129 -12
  126. package/src/services/messageQuery.ts +29 -0
  127. package/src/services/wechatClient.ts +380 -0
  128. package/src/services/wechatMedia.ts +166 -0
  129. package/src/services/wechatMessageService.ts +62 -11
  130. package/src/types.ts +4 -0
  131. package/dist/src/utils/wechatEmoji.d.ts +0 -5
  132. package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
  133. package/dist/src/utils/wechatEmoji.js +0 -35
  134. 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('查看联系人列表')
@@ -1730,8 +1821,9 @@ program
1730
1821
  .option('--json', '输出机器可读的导出结果,不改变导出文件格式')
1731
1822
  .option('--non-interactive', '禁止交互选择;匹配不唯一时返回错误')
1732
1823
  .option('--full-images', '使用原图而非微信缓存缩略图(更清晰,但导出慢数倍)')
1824
+ .option('--media', 'json/txt/excel 导出时一并抽出图片与文档(落盘到 <输出>/<会话>_assets,并在导出物里给出相对路径)')
1733
1825
  .action(async (talkerInput: string, format: string, opts) => {
1734
- const validFormats = ['json', 'txt', 'html', 'excel']
1826
+ const validFormats = ['json', 'txt', 'html', 'md', 'excel']
1735
1827
  if (!validFormats.includes(format)) {
1736
1828
  if (opts.json) console.log(JSON.stringify({ success: false, code: 'INVALID_FORMAT', error: '导出格式无效', supported: validFormats }))
1737
1829
  else {
@@ -1780,6 +1872,16 @@ program
1780
1872
  }
1781
1873
  process.exit(1)
1782
1874
  }
1875
+ // 图片要靠 Python 那条路匹配(缓存缩略图、资源 md5、原图那档)。`--media` 就是"先抽一次",
1876
+ // 抽完 json/txt/excel 各自消费同一份清单 —— 匹配只跑一遍,也不会在 TS 里重写一遍。
1877
+ if (opts.media && (format === 'json' || format === 'txt' || format === 'excel')) {
1878
+ const media = await exportService.extractMedia(talker, opts.output, !!opts.json, { fullImages: opts.fullImages === true }, limit, opts.date || '')
1879
+ if (!media.success) {
1880
+ if (opts.json) console.log(JSON.stringify({ success: false, code: 'MEDIA_EXPORT_FAILED', error: media.error || '媒体抽取失败' }))
1881
+ else console.log(chalk.red(`✗ 媒体抽取失败: ${media.error}`))
1882
+ process.exit(1)
1883
+ }
1884
+ }
1783
1885
  switch (format) {
1784
1886
  case 'json':
1785
1887
  result = await exportService.exportJson(talker, opts.output, limit, from, to, opts.contract)
@@ -1790,6 +1892,10 @@ program
1790
1892
  case 'html':
1791
1893
  result = await exportService.exportHtml(talker, opts.output, limit, opts.date || '', from, to, !!opts.json, { fullImages: opts.fullImages === true })
1792
1894
  break
1895
+ case 'md':
1896
+ // markdown + 图片落盘(`<输出>/<talker>_assets/`);json/txt/excel 之后会自动带上那些图。
1897
+ result = await exportService.exportMarkdown(talker, opts.output, limit, opts.date || '', from, to, !!opts.json, { fullImages: opts.fullImages === true })
1898
+ break
1793
1899
  case 'excel':
1794
1900
  result = await exportService.exportExcel(talker, opts.output, limit, from, to)
1795
1901
  break
@@ -2027,7 +2133,10 @@ program
2027
2133
  process.exit(1)
2028
2134
  }
2029
2135
  } else {
2030
- const timeout = parseCliInteger(opts.timeout, 'timeout', 1000, 600000)
2136
+ // `opts.json` 也要传:这一行在 `--json` 下**不可达**(上面就 `INTERACTIVE_REQUIRED` 退出了),
2137
+ // 但少传一个是安静的 —— 规则统一成"校验 helper 一律带上它",判据才写得出来
2138
+ // (`test/json-error-surface.test.ts` 末尾那条静态检查)。
2139
+ const timeout = parseCliInteger(opts.timeout, 'timeout', 1000, 600000, opts.json)
2031
2140
  const result = await keyService.autoGetDbKey(timeout, (msg) => {
2032
2141
  console.log(chalk.gray(` ${msg}`))
2033
2142
  })
@@ -2250,7 +2359,7 @@ whitelistCmd
2250
2359
  wxid = target
2251
2360
  } else {
2252
2361
  try {
2253
- wxid = await resolveTalker(target)
2362
+ wxid = await resolveTalker(target, !!opts.json, !!opts.json)
2254
2363
  const sessions = await chatService.listSessions(undefined, 50)
2255
2364
  const match = sessions.find(s => s.username === wxid)
2256
2365
  if (match) displayName = match.displayName || target
@@ -2437,7 +2546,7 @@ blacklistCmd
2437
2546
  wxid = target
2438
2547
  } else {
2439
2548
  try {
2440
- wxid = await resolveTalker(target)
2549
+ wxid = await resolveTalker(target, !!opts.json, !!opts.json)
2441
2550
  const sessions = await chatService.listSessions(undefined, 50)
2442
2551
  const match = sessions.find(s => s.username === wxid)
2443
2552
  if (match) displayName = match.displayName || target
@@ -3809,6 +3918,8 @@ program
3809
3918
  .option('--api-key <key>', 'DeepSeek API key')
3810
3919
  .option('--refresh-sources',
3811
3920
  '只把已有概念页的"来源"段补齐成当前全部来源(本地重写,**不调用模型**)')
3921
+ .option('--qualify-links',
3922
+ '把跨线同名的概念链接改成带路径的形式(本地重写,**不调用模型**)')
3812
3923
  .option('--cards <dir...>', '产卡目录,可写多个;默认所有 output/*-notes(与索引的引用数同一口径)')
3813
3924
  .option('--dry-run', '仅预览,不读取文章、调用 AI 或写入概念页')
3814
3925
  .option('--yes', '确认调用 AI 并生成概念页')
@@ -3834,6 +3945,22 @@ program
3834
3945
  return
3835
3946
  }
3836
3947
 
3948
+ if (opts.qualifyLinks) {
3949
+ // 本地消歧:两条线同名的那些链接按名字带路径(`Chat/Concepts/DeepSeek|DeepSeek`),
3950
+ // 免得 Obsidian 里那条裸链接命中另一条线的那张。**不调模型**,重跑幂等。
3951
+ await runConfirmedPythonMutation({
3952
+ action: 'wiki.qualifyLinks',
3953
+ script,
3954
+ args: ['--qualify-links', '--output', opts.output],
3955
+ cliOptions: opts,
3956
+ preview: { readsLocalKnowledge: true, rewritesExistingPages: true, usesAi: false },
3957
+ confirmationMessage: `确认把 ${opts.output} 里跨线同名的概念链接改成带路径的形式?`
3958
+ + '(本地重写,不调用 AI)',
3959
+ apiKey: opts.apiKey,
3960
+ timeout: 300_000,
3961
+ })
3962
+ return
3963
+ }
3837
3964
  const limit = parseCliInteger(opts.limit, 'limit', 1, 1000, opts.json)
3838
3965
  const minRefs = parseCliInteger(opts.minRefs ?? '1', 'min-refs', 1, 50, opts.json)
3839
3966
  await runConfirmedPythonMutation({
@@ -3864,8 +3991,9 @@ program
3864
3991
  .option('--wiki-limit <n>', '概念编译数', '20')
3865
3992
  .option('--source <name>', '仅处理指定公众号,可重复使用', (value: string, previous: string[]) => [...previous, value], [] as string[])
3866
3993
  .option('--skip-classify', '跳过 AI 后处理')
3867
- .option('--skip-wiki', '跳过概念编译')
3868
- .option('--skip-vault', '跳过 Vault 副本同步')
3994
+ // 这两个**默认不做**:Obsidian 自带的图谱视图是实时的,写一次 vault 图谱就重画一次。
3995
+ .option('--with-wiki', '把概念页写进 Obsidian Vault(默认不写:图谱只在你说了才变)')
3996
+ .option('--with-vault', '把当天内容副本同步进 Obsidian Vault(默认不写)')
3869
3997
  .option('--skip-html', '跳过 HTML 阅读器生成')
3870
3998
  .option('--skip-ai-report', '跳过 AI 深度阅读报告')
3871
3999
  .option('--no-ai', '关闭全部 AI 调用,保留抓取和本地输出')
@@ -3947,8 +4075,8 @@ program
3947
4075
  if (opts.date) args.push('--date', opts.date)
3948
4076
  for (const source of sources) args.push('--source', source)
3949
4077
  if (opts.skipClassify) args.push('--skip-classify')
3950
- if (opts.skipWiki) args.push('--skip-wiki')
3951
- if (opts.skipVault) args.push('--skip-vault')
4078
+ if (opts.withWiki) args.push('--with-wiki')
4079
+ if (opts.withVault) args.push('--with-vault')
3952
4080
  if (opts.skipHtml) args.push('--skip-html')
3953
4081
  if (opts.skipAiReport) args.push('--skip-ai-report')
3954
4082
  if (noAi) args.push('--no-ai')
@@ -4184,16 +4312,30 @@ program
4184
4312
  .description('Vault 搜索 — 文章+概念+笔记')
4185
4313
  .argument('<query>', '搜索关键词')
4186
4314
  .option('--type <type>', 'all / article / concept / note', 'all')
4315
+ .option('--line <line>', 'wiki=文章线(默认) / chat=聊天线 / all=两条都搜', 'wiki')
4187
4316
  .option('--top-k <n>', '返回数量', '10')
4317
+ // `vault_search.py` 一直有 `--days`(它的 docstring 自己就举 `--days 30`),CLI 从来没接 ——
4318
+ // 于是"只搜最近一周"这件事在这条路上做不到,窗口被脚本默认值(90)钉死。与 `search-index --days`
4319
+ // 同形:窗口的运行值由 CLI 说了算,默认值与脚本一致。
4320
+ .option('--days <n>', '日报文章取最近多少天(默认 90)', '90')
4321
+ // 这条 `--json` 曾经**不存在,而 JSON 又是唯一的输出**:参数里硬编码着 `'--json'`,
4322
+ // 人类模式虽然脚本里写着(`vault_search.py` 不带 `--json` 时打印带相关度的可读列表)
4323
+ // 却没有任何入口能到达,用户显式敲 `--json` 还会拿到 `unknown option` —— 与
4324
+ // `docs/EXTENDING.md` 那条"脚本或 AI 会驱动的命令都有 `--json` 模式"正相反。
4325
+ // 现在按兄弟命令(`vault rag`、顶层 `search`)的形状来:默认人类可读,`--json` 才转发。
4326
+ .option('--json', '输出 JSON 格式(含本地路径与标题,供本机脚本/AI 使用)')
4188
4327
  .action(async (query, opts) => {
4189
4328
  const pkgRoot = resolvePackageRoot()
4190
4329
  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'])
4330
+ // 校验分支也要认 `--json`(`requireCliChoice` 里就带着这条约定):
4331
+ // 否则 JSON 模式下 stdout 是一行彩色散文,脚本解析不了。
4332
+ requireCliChoice(opts.type, ['all', 'article', 'concept', 'note'], 'type', opts.json)
4333
+ requireCliChoice(opts.line, ['wiki', 'chat', 'all'], 'line', opts.json)
4334
+ const topK = parseCliInteger(opts.topK, 'top-k', 1, 100, opts.json)
4335
+ const days = parseCliInteger(opts.days, 'days', 1, 3650, opts.json)
4336
+ await runPythonCmd(script, [query, '--type', opts.type, '--line', opts.line,
4337
+ '--top-k', String(topK), '--days', String(days),
4338
+ ...(opts.json ? ['--json'] : [])])
4197
4339
  })
4198
4340
  )
4199
4341
 
@@ -4205,6 +4347,7 @@ program
4205
4347
  .description('Vault 问答 — 基于知识库的 AI 对话')
4206
4348
  .argument('<question>', '问题')
4207
4349
  .option('--top-k <n>', '检索条数', '8')
4350
+ .option('--line <line>', 'wiki=文章线(默认) / chat=聊天线 / all=两条都搜', 'wiki')
4208
4351
  .option('--api-key <key>', 'DeepSeek API key')
4209
4352
  .option('--dry-run', '仅预览,不读取知识库或调用 AI')
4210
4353
  .option('--yes', '确认读取本地知识并发送筛选后的上下文到 AI')
@@ -4213,11 +4356,13 @@ program
4213
4356
  const pkgRoot = resolvePackageRoot()
4214
4357
  const script = join(pkgRoot, 'scripts', 'vault_rag.py')
4215
4358
  const topK = parseCliInteger(opts.topK, 'top-k', 1, 100, opts.json)
4359
+ requireCliChoice(opts.line, ['wiki', 'chat', 'all'], 'line', opts.json)
4216
4360
  const preview = {
4217
4361
  success: true,
4218
4362
  dryRun: true,
4219
4363
  action: 'vault.rag',
4220
4364
  topK,
4365
+ line: opts.line,
4221
4366
  readsLocalKnowledge: true,
4222
4367
  usesAi: true,
4223
4368
  sendsSelectedContextToAi: true,
@@ -4243,7 +4388,7 @@ program
4243
4388
  return
4244
4389
  }
4245
4390
  }
4246
- const args = ['--top-k', String(topK)]
4391
+ const args = ['--top-k', String(topK), '--line', opts.line]
4247
4392
  if (opts.json) args.push('--json')
4248
4393
  const { execFile } = await import('child_process')
4249
4394
  const { promisify } = await import('util')
@@ -4548,6 +4693,25 @@ program
4548
4693
  })
4549
4694
 
4550
4695
  // ==================== weread ====================
4696
+
4697
+ /** 微信读书服务失败时的统一出口。
4698
+ *
4699
+ * 9 个调用点(shelf / stats / notes×2 / search / book / review / discover / profile)原来都是一行式
4700
+ * 分支:把彩色散文打在 stdout 上再 `process.exit(1)`,**没有** json 支,而各命令的 `if (opts.json)`
4701
+ * 分支在它后面 ⇒ `weread shelf --json` 失败时 stdout 上只有一行 `✗ …`,调用方解析不了,也不知道
4702
+ * 是哪个命令失败了。全库其余同类出口都是"机器支发 JSON + exit"。
4703
+ *
4704
+ * (这里刻意**不**把那行旧代码原样抄出来:`test/json-error-surface.test.ts` 有一条绊线在扫那个形状,
4705
+ * 而注释里留一份字面量会让判据读到它 —— 今晚已经在另一条判据上踩过这个坑。)
4706
+ *
4707
+ * 人看的那一支保持原样(`console.log`,不动它的落点):把 CLI 的**错误**统一改走 stderr 是另一件事,
4708
+ * 现在库里两种都有(多数 `console.error`、少数 `console.log`),不在这条修复里顺手改。
4709
+ */
4710
+ function failWeread(res: { error?: string }, json = false): never {
4711
+ if (json) console.log(JSON.stringify({ success: false, code: 'WEREAD_FAILED', error: res.error }))
4712
+ else console.log(chalk.red(`✗ ${res.error}`))
4713
+ process.exit(1)
4714
+ }
4551
4715
  const wereadCmd = program
4552
4716
  .command('weread')
4553
4717
  .description('微信读书助手 — 书架、统计、笔记、搜索、书评、推荐')
@@ -4559,7 +4723,7 @@ program
4559
4723
  .action(async (opts) => {
4560
4724
  const weread = await getWereadService()
4561
4725
  const res = await weread.shelf()
4562
- if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
4726
+ if (!res.ok) failWeread(res, !!opts.json)
4563
4727
  const d = res.data!
4564
4728
  if (opts.json) { console.log(JSON.stringify(d, null, 2)); return }
4565
4729
 
@@ -4588,7 +4752,7 @@ program
4588
4752
  .action(async (opts) => {
4589
4753
  const weread = await getWereadService()
4590
4754
  const res = await weread.readData(opts.mode)
4591
- if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
4755
+ if (!res.ok) failWeread(res, !!opts.json)
4592
4756
  const d = res.data!
4593
4757
  if (opts.json) { console.log(JSON.stringify(d, null, 2)); return }
4594
4758
 
@@ -4636,7 +4800,7 @@ program
4636
4800
  const limit = parseCliInteger(opts.limit, 'limit', 1, 100, opts.json)
4637
4801
  if (opts.bookId) {
4638
4802
  const res = await weread.bookmarks(opts.bookId, limit)
4639
- if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
4803
+ if (!res.ok) failWeread(res, !!opts.json)
4640
4804
  const d = res.data!
4641
4805
  if (opts.json) { console.log(JSON.stringify(d, null, 2)); return }
4642
4806
  console.log(chalk.cyan(`📝 划线笔记 (${d.updated?.length || 0} 条):\n`))
@@ -4649,7 +4813,7 @@ program
4649
4813
  }
4650
4814
  } else {
4651
4815
  const res = await weread.notebooks(50)
4652
- if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
4816
+ if (!res.ok) failWeread(res, !!opts.json)
4653
4817
  const d = res.data!
4654
4818
  if (opts.json) { console.log(JSON.stringify(d, null, 2)); return }
4655
4819
  console.log(chalk.cyan(`📝 笔记本 (${d.books?.length || 0} 本有笔记):\n`))
@@ -4672,7 +4836,7 @@ program
4672
4836
  const weread = await getWereadService()
4673
4837
  const limit = parseCliInteger(opts.limit, 'limit', 1, 100, opts.json)
4674
4838
  const res = await weread.search(keyword, limit)
4675
- if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
4839
+ if (!res.ok) failWeread(res, !!opts.json)
4676
4840
  const d = res.data!
4677
4841
  if (opts.json) { console.log(JSON.stringify(d, null, 2)); return }
4678
4842
  console.log(chalk.cyan(`🔍 搜索: "${keyword}" (${d.books?.length || 0} 条):\n`))
@@ -4696,7 +4860,7 @@ program
4696
4860
  weread.bookInfo(bookId),
4697
4861
  weread.getProgress(bookId),
4698
4862
  ])
4699
- if (!infoRes.ok) { console.log(chalk.red(`✗ ${infoRes.error}`)); process.exit(1) }
4863
+ if (!infoRes.ok) failWeread(infoRes, !!opts.json)
4700
4864
  const b = infoRes.data!
4701
4865
  if (opts.json) { console.log(JSON.stringify(b, null, 2)); return }
4702
4866
 
@@ -4722,7 +4886,7 @@ program
4722
4886
  const weread = await getWereadService()
4723
4887
  const limit = parseCliInteger(opts.limit, 'limit', 1, 100, opts.json)
4724
4888
  const res = await weread.reviews(bookId, limit)
4725
- if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
4889
+ if (!res.ok) failWeread(res, !!opts.json)
4726
4890
  const d = res.data!
4727
4891
  if (opts.json) { console.log(JSON.stringify(d, null, 2)); return }
4728
4892
  console.log(chalk.cyan(`💬 书评 (${d.reviews?.length || 0} 条):\n`))
@@ -4748,7 +4912,7 @@ program
4748
4912
  const res = opts.bookId
4749
4913
  ? await weread.similar(opts.bookId, limit)
4750
4914
  : await weread.recommend(limit)
4751
- if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
4915
+ if (!res.ok) failWeread(res, !!opts.json)
4752
4916
  const d = res.data!
4753
4917
  if (opts.json) { console.log(JSON.stringify(d, null, 2)); return }
4754
4918
  const label = opts.bookId ? '相似推荐' : '个性化推荐'
@@ -4769,7 +4933,7 @@ program
4769
4933
  .action(async (opts) => {
4770
4934
  const weread = await getWereadService()
4771
4935
  const res = await weread.profile()
4772
- if (!res.ok) { console.log(chalk.red(`✗ ${res.error}`)); process.exit(1) }
4936
+ if (!res.ok) failWeread(res, !!opts.json)
4773
4937
  const p = res.data!
4774
4938
  if (opts.json) { console.log(JSON.stringify(p, null, 2)); return }
4775
4939
 
@@ -4812,6 +4976,8 @@ program
4812
4976
  .description('语义搜索知识库(聊天记录 + 文章)')
4813
4977
  .argument('<query>', '搜索关键词')
4814
4978
  .option('--top-k <n>', '返回数量', '10')
4979
+ .option('--line <line>', 'wiki=文章线(默认) / chat=聊天记录 / all=两条都搜', 'wiki')
4980
+ .option('--no-rerank', '不调用决策模型重排,只按向量/关键词相似度返回(回退到引入重排之前)')
4815
4981
  .option('--api-key <key>', 'DashScope embedding API key')
4816
4982
  .option('--dry-run', '仅预览,不读取索引或调用向量服务')
4817
4983
  .option('--yes', '确认执行可能调用云端向量服务的搜索')
@@ -4823,11 +4989,14 @@ program
4823
4989
  const pkgRoot = resolvePackageRoot()
4824
4990
  const script = join(pkgRoot, 'scripts', 'semantic_search.py')
4825
4991
  const topK = parseCliInteger(opts.topK, 'top-k', 1, 100, opts.json)
4992
+ requireCliChoice(opts.line, ['wiki', 'chat', 'all'], 'line', opts.json)
4826
4993
  const preview = {
4827
4994
  success: true,
4828
4995
  dryRun: true,
4829
4996
  action: 'semantic-search.query',
4830
4997
  topK,
4998
+ line: opts.line,
4999
+ noRerank: opts.rerank === false,
4831
5000
  readsLocalIndex: true,
4832
5001
  mayUseCloudEmbedding: true,
4833
5002
  }
@@ -4852,7 +5021,11 @@ program
4852
5021
  return
4853
5022
  }
4854
5023
  }
4855
- const args: string[] = [script, 'search', '--top-k', String(topK)]
5024
+ // `--no-rerank` 是**旗标**(不是密钥)⇒ 可以走 argv;查询本身仍然只走 env(见 `privacy-paths` 那条规矩)。
5025
+ // 接这条通道之前,`OPERATIONS.md` 一直在教用户敲 `search --no-rerank`,而 CLI 会答 `unknown option`
5026
+ // —— 同一份文档里 `--line` 是通的,所以它看起来像"能用的旗标"(2026-10-11 由文档命令面那道检查发现)。
5027
+ const args: string[] = [script, 'search', '--top-k', String(topK), '--line', opts.line,
5028
+ ...(opts.rerank === false ? ['--no-rerank'] : [])]
4856
5029
  try {
4857
5030
  const { stdout } = await execFileAsync(getPythonCommand(), args, {
4858
5031
  timeout: 60_000, maxBuffer: 10 * 1024 * 1024,
@@ -4950,6 +5123,7 @@ program
4950
5123
  .description('RAG 智能助手 — 基于聊天记录和公众号的对话式问答')
4951
5124
  .argument('[question]', '要问的问题(不传则进入交互模式)')
4952
5125
  .option('--top-k <n>', '检索数量', '10')
5126
+ .option('--line <line>', 'wiki=文章/文章概念(默认) / chat=聊天记录 / all=两条都搜', 'wiki')
4953
5127
  .option('--talker <name>', '限定联系人/群聊')
4954
5128
  .option('--api-key <key>', 'AI API key')
4955
5129
  .option('--dry-run', '仅预览,不读取知识库或调用 AI')
@@ -4962,11 +5136,13 @@ program
4962
5136
  const pkgRoot = resolvePackageRoot()
4963
5137
  const script = join(pkgRoot, 'scripts', 'rag_chat.py')
4964
5138
  const topK = parseCliInteger(opts.topK, 'top-k', 1, 100, opts.json)
5139
+ requireCliChoice(opts.line, ['wiki', 'chat', 'all'], 'line', opts.json)
4965
5140
  const preview = {
4966
5141
  success: true,
4967
5142
  dryRun: true,
4968
5143
  action: 'rag-chat.query',
4969
5144
  topK,
5145
+ line: opts.line,
4970
5146
  interactiveRequired: !question,
4971
5147
  readsLocalKnowledge: true,
4972
5148
  usesCloudEmbedding: true,
@@ -5001,7 +5177,7 @@ program
5001
5177
  }
5002
5178
  const args: string[] = [script]
5003
5179
  if (question) {
5004
- args.push('--top-k', String(topK))
5180
+ args.push('--top-k', String(topK), '--line', opts.line)
5005
5181
  if (opts.json) args.push('--json')
5006
5182
  try {
5007
5183
  const { stdout } = await execFileAsync(getPythonCommand(), args, {
@@ -5019,7 +5195,7 @@ program
5019
5195
  }
5020
5196
  } else {
5021
5197
  // 交互模式:使用 spawn 保持终端交互
5022
- args.push('--interactive', '--top-k', String(topK))
5198
+ args.push('--interactive', '--top-k', String(topK), '--line', opts.line)
5023
5199
  const child = spawn(getPythonCommand(), args, {
5024
5200
  windowsHide: true,
5025
5201
  stdio: 'inherit',
@@ -5288,7 +5464,7 @@ program
5288
5464
  // draft:判断在前、起草在后、**绝不发送**
5289
5465
  program
5290
5466
  .command('draft <talker>')
5291
- .description('帮我起草回复:先判断(意图/风险/该不该给实质),再起草候选 —— **只产出文本,不发送**')
5467
+ .description('帮我起草回复:先判断(意图/风险/该不该给实质),再起草候选 —— **默认只产出文本;给了 --pick N --send 才会替你发送**')
5292
5468
  .option('--count <n>', '要几条候选', '3')
5293
5469
  .option('--pick <n>', '选第几条(从 1 数);不带 --send 时只把那条的完整原文打出来')
5294
5470
  .option('--send', '把选中的那条发出去 —— **必须同时给 --pick**(不允许"自己挑一条发"),仍需 --yes/--dry-run')
@@ -5510,18 +5686,86 @@ program
5510
5686
  }
5511
5687
  })
5512
5688
 
5689
+ // wiki graph:把概念图谱导成一张**自包含**的 3D 页面(全本地:只读 Vault、不联网、不调用模型)
5690
+ //
5691
+ // 自包含是硬要求,不是顺手:页面用 `file://` 双击打开,那时 `fetch` 会被 CORS 挡死,
5692
+ // 所以库、数据、坐标全部内联进去 —— 也因此三个库随 npm 包发(见 resources/js/graph3d/NOTICE.txt)。
5693
+ // 布局(250 tick 实测 55 秒)在构建期用 Node 算完并缓存,打开页面即用。
5694
+ program.commands.find(c => c.name() === 'wiki')?.addCommand(
5695
+ new Command('graph')
5696
+ .description('把概念图谱导成一张自包含的图谱页面(只读本地、不联网;生成的 HTML 双击就能逛)。--flat 出 2D 版')
5697
+ .option('--vault <dir>', 'Vault 根目录(默认 output/wechat-vault)')
5698
+ .option('-o, --out <file>', '页面写到哪(默认 output/knowledge-graph-3d.html)')
5699
+ .option('--ticks <n>', '力导向迭代次数(默认 250,越多越舒展也越慢)')
5700
+ .option('--cache <dir>', '布局缓存目录(派生数据;测试/多份图分开放时用)')
5701
+ .option('--flat', '2D 版:平面图(更像 Obsidian 那种),canvas 渲染,输出默认 knowledge-graph-2d.html')
5702
+ .option('--min-degree <n>', '只画在原图里连接数 ≥N 的概念(去掉细枝看骨架;0 = 全画)')
5703
+ .option('--line <which>', '只画一条线:all / wiki / chat')
5704
+ .option('--dry-run', '只报概念与链接数:不写文件、也不算布局(本地,零改动)')
5705
+ .option('--open', '生成后用默认浏览器打开它')
5706
+ .option('--json', '输出机器可读结果')
5707
+ .action(async (opts) => {
5708
+ const { execFile } = await import('child_process')
5709
+ const { promisify } = await import('util')
5710
+ const execFileAsync = promisify(execFile)
5711
+ // 2D 与 3D 是**两个入口脚本**:图怎么建共用(graph_2d 直接 import graph_3d),
5712
+ // 换的是布局维度与渲染器 —— 两套渲染器塞一个文件就没人看得完了。
5713
+ const script = join(resolvePackageRoot(), 'scripts', opts.flat ? 'graph_2d.py' : 'graph_3d.py')
5714
+ const ticks = opts.ticks === undefined ? undefined : parseCliInteger(opts.ticks, 'ticks', 1, 100000, !!opts.json)
5715
+ // `--json` 一律要:CLI 要拿到 out 路径与计数,才决定怎么印。开关只决定**给谁看**。
5716
+ const minDegree = opts.minDegree === undefined
5717
+ ? undefined : parseCliInteger(opts.minDegree, 'min-degree', 0, 100000, !!opts.json)
5718
+ const args = [script,
5719
+ ...(opts.vault ? ['--vault', opts.vault] : []),
5720
+ ...(opts.out ? ['--out', opts.out] : []),
5721
+ ...(opts.cache ? ['--cache', opts.cache] : []),
5722
+ ...(ticks === undefined ? [] : ['--ticks', String(ticks)]),
5723
+ ...(minDegree === undefined || minDegree === 0 ? [] : ['--min-degree', String(minDegree)]),
5724
+ ...(opts.line ? ['--line', opts.line] : []),
5725
+ ...(opts.dryRun ? ['--dry-run'] : []),
5726
+ '--json']
5727
+ try {
5728
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
5729
+ timeout: 900_000, maxBuffer: 64 * 1024 * 1024, env: pythonProcessEnv(),
5730
+ })
5731
+ const body = JSON.parse(stdout.slice(stdout.indexOf('{')))
5732
+ if (opts.json) {
5733
+ console.log(JSON.stringify(body))
5734
+ } else if (body.dryRun) {
5735
+ console.log(`概念 ${body.nodes} 个、链接 ${body.links} 条(--dry-run:没有写文件)`)
5736
+ } else {
5737
+ console.log(chalk.green(`✓ 已生成 ${body.out}`))
5738
+ console.log(chalk.gray(` 概念 ${body.nodes} / 链接 ${body.links} / ${body.mb} MB · ${opts.flat ? '2D' : '3D'} · 双击就能逛,不需要服务器`))
5739
+ }
5740
+ if (!opts.dryRun && opts.open && body.out) await openLocalUrl(body.out)
5741
+ } catch (error) {
5742
+ if (opts.json) {
5743
+ console.log(JSON.stringify({ success: false, code: 'GRAPH_FAILED', action: 'graph-3d', error: safeSubprocessError(error) }))
5744
+ } else {
5745
+ console.error(chalk.red(`\n✗ ${safeSubprocessError(error)}`))
5746
+ }
5747
+ process.exit(1)
5748
+ }
5749
+ })
5750
+ )
5751
+
5513
5752
  // wiki lint:知识库体检(全本地:不联网、不调用模型)
5514
5753
  program.commands.find(c => c.name() === 'wiki')?.addCommand(
5515
5754
  new Command('lint')
5516
5755
  .description('知识库体检:断链、孤儿、空页、同名(本地,不联网)')
5517
- .option('--dir <dir>', '概念页目录', './output/wechat-vault/Wiki/Concepts')
5756
+ // 不给默认值:不传 `--dir` 时体检脚本自己的默认(清单里的**全部线**)。
5757
+ // 这里曾经默认只给文章线,而脚本默认两条线都体检 ⇒"半开半合":命令行跑出来
5758
+ // 与脚本跑出来口径不同,而两边都不报错。
5759
+ .option('--dir <dirs...>', '概念页目录(可重复;不传则体检清单里的全部线)')
5518
5760
  .option('--json', '输出机器可读结果')
5519
5761
  .action(async (opts) => {
5520
5762
  const { execFile } = await import('child_process')
5521
5763
  const { promisify } = await import('util')
5522
5764
  const execFileAsync = promisify(execFile)
5523
5765
  const script = join(resolvePackageRoot(), 'scripts', 'wiki_lint.py')
5524
- const args = [script, '--dir', String(opts.dir), ...(opts.json ? ['--json'] : [])]
5766
+ const dirs = Array.isArray(opts.dir) ? opts.dir : (opts.dir ? [opts.dir] : [])
5767
+ const args = [script, ...dirs.flatMap((d: string) => ['--dir', String(d)]),
5768
+ ...(opts.json ? ['--json'] : [])]
5525
5769
  try {
5526
5770
  const { stdout } = await execFileAsync(getPythonCommand(), args, {
5527
5771
  timeout: 120_000, maxBuffer: 20 * 1024 * 1024, env: pythonProcessEnv(),
@@ -5543,6 +5787,8 @@ program
5543
5787
  .option('--limit <n>', '最多处理几个会话', '40')
5544
5788
  .option('--dry-run', '仅预览:几个会话、多少字符会发给生成模型(只读本地,零出境)')
5545
5789
  .option('--yes', '确认把对话发送给生成模型')
5790
+ .option('--transcribe-voice',
5791
+ '把缺转写的语音**本机**识别掉再产卡(本地 whisper,慢、吃 CPU、不出网;配合 --dry-run 就是只补转写、不调模型)')
5546
5792
  .option('--json', '输出机器可读结果;执行仍需 --yes')
5547
5793
  .action(async (opts) => {
5548
5794
  const { execFile } = await import('child_process')
@@ -5578,6 +5824,7 @@ program
5578
5824
  }
5579
5825
  const args = [script, '--days', String(days), '--limit', String(limit),
5580
5826
  ...(confirmed ? ['--yes'] : []),
5827
+ ...(opts.transcribeVoice ? ['--transcribe-voice'] : []),
5581
5828
  ...(opts.json ? ['--json'] : []),
5582
5829
  ...(opts.dryRun ? ['--dry-run'] : [])]
5583
5830
  try {
@@ -5951,7 +6198,7 @@ program
5951
6198
  const isComplete = (targetDate: string): boolean => Object.values(artifactStatus(targetDate)).every(Boolean)
5952
6199
 
5953
6200
  const runPipeline = (targetDate: string): Promise<number> => new Promise((resolve) => {
5954
- const args = [pipeline, '--date', targetDate, '--engine', noAi ? 'local' : 'deepseek', '--interest', 'AI', '--skip-wiki']
6201
+ const args = [pipeline, '--date', targetDate, '--engine', noAi ? 'local' : 'deepseek', '--interest', 'AI']
5955
6202
  if (noAi) args.push('--no-ai')
5956
6203
  // 与 --no-ai 不同:这一条保留判断(Jev),只关掉 LLM 的文字生成。
5957
6204
  if (opts.noSummary && !noAi) args.push('--no-summary')
@@ -7095,6 +7342,10 @@ program
7095
7342
  console.log(chalk.gray('AI 助手现在可以使用以下工具:'))
7096
7343
  } else {
7097
7344
  console.log(json)
7345
+ // 机器模式到这里就结束:下面那段工具清单与"复制以上 JSON…"是**给人看的**,原来无条件打在
7346
+ // stdout 上 ⇒ `mcp-config --json-result | jq` 断在第 14 行(2026-10-12 由只读驱动扫描发现;
7347
+ // 写路径本来就早退发 JSON,所以只有读路径这一处漏)。
7348
+ if (opts.jsonResult) return
7098
7349
  }
7099
7350
  console.log(chalk.cyan('\n可用的 MCP 工具:'))
7100
7351
  const STATIC_TOOLS: Array<[string, string]> = [
@@ -7133,6 +7384,28 @@ program
7133
7384
  .description('检查运行环境(Python、pip 依赖、数据库配置)')
7134
7385
  .option('--json', '输出 JSON 格式,不包含数据库路径或密钥')
7135
7386
  .action(async (opts) => {
7387
+ // 微信客户端的**版本**与它自报的**数据目录**:两个分支都要用,所以在这里算一次。
7388
+ // 判据见 `wechatClient.ts`(版本取活进程的 `--user-lib-dir`;取不到就是"未知",不是"没问题")。
7389
+ const client = await detectWechatClient()
7390
+ // 客户端自报的数据目录 vs 配置里那个数据库推出来的根:**只比是不是同一份**,
7391
+ // 路径本身不进机器可读输出(`wechat-check/v1` 一直是不含路径的)。
7392
+ //
7393
+ // **只在 4.x 上比**:这个根是从 `ntDbPath`(4.x 的库)推的,而客户端自报的那条来自 4.x 客户端
7394
+ // —— 只用 3.x 的用户(`dbPath3x`)比出来的"不是同一份"没有意义,而这句话**只在 false 时打印**
7395
+ // ⇒ 那就是一条假警告。判据不成立时给 null("不知道"),不是 false。
7396
+ const { dirname } = await import('node:path')
7397
+ // **四层**:`…/xwechat_files/<wxid>/db_storage/message/message_0.db`
7398
+ // → message → db_storage → <wxid> → xwechat_files。少一层就退到 `<wxid>` 目录,
7399
+ // 于是两个路径永远不相等、永远报"不是同一份"(一个看着很合理的假警告)。
7400
+ const dbPathConfigured = String(configService.get('dataVersion') || '') === '4.x'
7401
+ ? String(configService.get('ntDbPath') || '')
7402
+ : ''
7403
+ const configuredRoot = dbPathConfigured
7404
+ ? dirname(dirname(dirname(dirname(dbPathConfigured))))
7405
+ : ''
7406
+ const dataDirMatchesConfig = (client.dataDirs.length && configuredRoot)
7407
+ ? client.dataDirs.some(dir => samePath(dir, configuredRoot))
7408
+ : null
7136
7409
  if (opts.json) {
7137
7410
  const { execFileSync } = await import('child_process')
7138
7411
  const requiredDependencies = ['sqlcipher3', 'html2text', 'zstandard', 'cryptography']
@@ -7186,6 +7459,26 @@ program
7186
7459
  messageChannelLoggedIn: !!configService.get('wechatOcToken'),
7187
7460
  daemonRunning: daemonAlive,
7188
7461
  },
7462
+ // `version` 只从活进程取;取不到就是 null + 一句 `note`(**不是**"没问题")。
7463
+ // `unusableVersionDirs` 是磁盘上的残留版本目录(没有 `Weixin.dll`)—— 指向它们的加载会失败。
7464
+ wechatClient: {
7465
+ running: client.running,
7466
+ version: client.version,
7467
+ loadedDirs: client.loadedDirs,
7468
+ // 客户端自报的构建 token(并列的第二来源;**不拿它推版本号**,编码规则没解出来)
7469
+ versionTokens: client.versionTokens,
7470
+ note: client.note,
7471
+ unusableVersionDirs: client.unusableVersionDirs,
7472
+ // 客户端**自己说**数据目录在哪(与配置不一致时说明两边不是同一份库)
7473
+ dataDirs: client.dataDirs,
7474
+ // 只报结论(true/false/null),**不带路径** —— 这条 schema 一直是不含路径的
7475
+ dataDirMatchesConfig,
7476
+ // `update.data` 在场 ⇒ 加载路径可能不由命令行决定:这条不确定必须跟着版本一起出去
7477
+ updateData: client.updateData,
7478
+ // 只报条数与最近时间(不读崩溃内容)
7479
+ crashRecords: client.crashRecords,
7480
+ caveats: client.caveats,
7481
+ },
7189
7482
  }
7190
7483
  console.log(JSON.stringify(data, null, 2))
7191
7484
  return
@@ -7270,7 +7563,38 @@ program
7270
7563
  }
7271
7564
  }
7272
7565
 
7273
- // 6. Assistant (第二大脑)
7566
+ // 6. 微信客户端版本(`client` / `dataDirMatchesConfig` 在 action 开头已经算过)
7567
+ console.log(chalk.white('\n微信客户端:'))
7568
+ if (client.version) {
7569
+ // 并列报上客户端自报的构建 token:这是"哪个构建"的第二个来源(**不用它推版本号**)
7570
+ const token = client.versionTokens.length ? chalk.gray(` · 自报构建 ${client.versionTokens.join('、')}`) : ''
7571
+ console.log(chalk.green(` ✓ 运行时加载 ${client.version}`) + chalk.gray(` (${client.loadedDirs[0]})`) + token)
7572
+ } else {
7573
+ // **说"未知"而不是"没问题"**:取不到就说取不到,别给人一个假的安心
7574
+ console.log(chalk.gray(` ○ 版本未知 — ${client.note}`))
7575
+ }
7576
+ for (const dead of client.unusableVersionDirs) {
7577
+ console.log(chalk.yellow(` ! 残留版本目录 ${dead.dir}:只剩 ${dead.files} 个文件(没有 Weixin.dll)`))
7578
+ console.log(chalk.gray(' 指向它的加载会失败;如果客户端起不来,先看这里'))
7579
+ }
7580
+ // `update.data` 在场时上面那个版本**未必**是加载的那个 —— 这句话要跟着它一起出现
7581
+ for (const caveat of client.caveats) {
7582
+ console.log(chalk.yellow(` ⚠ ${caveat}`))
7583
+ }
7584
+ if (client.dataDirs.length) {
7585
+ console.log(chalk.gray(` · 客户端自报数据目录: ${client.dataDirs.join('、')}`))
7586
+ if (dataDirMatchesConfig === false) {
7587
+ // 不一致 = 两边可能不是同一份库(本机数据目录还有 D 盘软链,这条已经按真身比过)
7588
+ console.log(chalk.yellow(' ⚠ 与配置里那个数据库推出来的根**不是同一份** —— 先确认你在读的是客户端正在写的那份'))
7589
+ }
7590
+ }
7591
+ const crashes = client.crashRecords.reports + client.crashRecords.records
7592
+ if (crashes > 0) {
7593
+ const when = client.crashRecords.newest ? client.crashRecords.newest.slice(0, 16).replace('T', ' ') : '时间读不到'
7594
+ console.log(chalk.gray(` · 客户端崩溃记录 ${crashes} 份(最近 ${when})—— 客户端崩过时数据读不出来,先看这条`))
7595
+ }
7596
+
7597
+ // 7. Assistant (第二大脑)
7274
7598
  console.log(chalk.white('\n第二大脑 Agent:'))
7275
7599
  const aiKey = configService.get('deepseekApiKey')
7276
7600
  const aiBase = configService.get('aiBaseUrl')
@@ -7491,7 +7815,7 @@ async function showInteractiveMenu() {
7491
7815
  const { spawn } = await import('child_process')
7492
7816
  const pipeline = join(resolvePackageRoot(), 'scripts', 'pipeline.py')
7493
7817
  console.log(chalk.cyan(`\n📰 正在生成 ${dateStr} 公众号日报...\n`))
7494
- const child = spawn(getPythonCommand(), [pipeline, '--date', dateStr, '--interest', 'AI', '--skip-wiki'], {
7818
+ const child = spawn(getPythonCommand(), [pipeline, '--date', dateStr, '--interest', 'AI'], {
7495
7819
  windowsHide: true,
7496
7820
  stdio: 'inherit',
7497
7821
  env: pythonProcessEnv(apiKey),