weflow-cli 1.12.0 → 1.13.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 (51) hide show
  1. package/CHANGELOG.md +217 -1
  2. package/OPERATIONS.md +254 -19
  3. package/README.md +4 -0
  4. package/bin/weflow-cli.ts +260 -51
  5. package/dist/bin/weflow-cli.js +255 -32
  6. package/dist/bin/weflow-cli.js.map +1 -1
  7. package/dist/src/core/dbPathService.d.ts.map +1 -1
  8. package/dist/src/core/dbPathService.js +5 -0
  9. package/dist/src/core/dbPathService.js.map +1 -1
  10. package/dist/src/core/ntCore.d.ts +12 -1
  11. package/dist/src/core/ntCore.d.ts.map +1 -1
  12. package/dist/src/core/ntCore.js +23 -3
  13. package/dist/src/core/ntCore.js.map +1 -1
  14. package/dist/src/core/sqlcipherCore.d.ts +8 -0
  15. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  16. package/dist/src/core/sqlcipherCore.js +46 -11
  17. package/dist/src/core/sqlcipherCore.js.map +1 -1
  18. package/dist/src/services/configService.d.ts +3 -0
  19. package/dist/src/services/configService.d.ts.map +1 -1
  20. package/dist/src/services/configService.js +6 -3
  21. package/dist/src/services/configService.js.map +1 -1
  22. package/dist/src/services/diagnosticReport.d.ts +80 -0
  23. package/dist/src/services/diagnosticReport.d.ts.map +1 -0
  24. package/dist/src/services/diagnosticReport.js +191 -0
  25. package/dist/src/services/diagnosticReport.js.map +1 -0
  26. package/dist/src/services/wechatClient.d.ts +9 -3
  27. package/dist/src/services/wechatClient.d.ts.map +1 -1
  28. package/dist/src/services/wechatClient.js +55 -22
  29. package/dist/src/services/wechatClient.js.map +1 -1
  30. package/docs/DECISIONS.md +347 -1
  31. package/docs/NEXT_ACTIONS.md +7 -1
  32. package/docs/POSITIONING.md +68 -0
  33. package/docs/PROJECT_STATE.md +29 -8
  34. package/package.json +88 -86
  35. package/scripts/_utils.py +120 -4
  36. package/scripts/biz_daily.py +118 -11
  37. package/scripts/daily_task.ps1 +132 -0
  38. package/scripts/export_chat_html.py +12 -1
  39. package/scripts/install_tasks.cmd +32 -0
  40. package/scripts/install_tasks.ps1 +91 -0
  41. package/scripts/jev_client.py +335 -3
  42. package/scripts/nt_decrypt.py +79 -14
  43. package/scripts/pipeline.py +21 -0
  44. package/scripts/repro_customer_init.cmd +86 -0
  45. package/scripts/semantic_search.py +286 -24
  46. package/src/core/dbPathService.ts +5 -0
  47. package/src/core/ntCore.ts +34 -3
  48. package/src/core/sqlcipherCore.ts +47 -11
  49. package/src/services/configService.ts +12 -3
  50. package/src/services/diagnosticReport.ts +237 -0
  51. package/src/services/wechatClient.ts +418 -380
package/bin/weflow-cli.ts CHANGED
@@ -11,6 +11,7 @@ import { dbPathService } from '../src/core/dbPathService.js'
11
11
  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
+ import { renderDiagnosticReport } from '../src/services/diagnosticReport.js'
14
15
  import { chatService } from '../src/services/chatService.js'
15
16
  import { emptyMessagesNote, looksLikeTalkerId } from '../src/services/messageQuery.js'
16
17
  import { exportService } from '../src/services/exportService.js'
@@ -341,7 +342,9 @@ program
341
342
  // 只读、纯本地、不调模型。**故意没有 mcp 那一栏**(D-062):它给的是关于第三方的推断
342
343
  // ——谁在被冷落、你会失去谁 —— 所以不进助手、不进 MCP、不喂模型。
343
344
  bonds: { cli: 'bonds --json', mcp: null, leavesTheMachine: false },
344
- diagnostics: { cli: 'check --json' },
345
+ // `check --report` 是同一份事实的**给人版**(已脱敏、可直接贴给 AI 或 issue),
346
+ // 所以它跟 `--json` 并列在这里:外部程序按这条能力去找"怎么拿诊断",不该只知道 JSON 那条。
347
+ diagnostics: { cli: 'check --json', report: 'check --report', leavesTheMachine: false },
345
348
  todos: { cli: 'todos list --json' },
346
349
  // 2026-09-29 三个界面对齐时新开的读取能力(D-058)。逐个写清它会不会出境,
347
350
  // 因为其中两个会——`fetch_article` 白名单 + 逐跳复检,`search_public` 没有任何守卫。
@@ -855,6 +858,26 @@ program
855
858
  // Step 3: 提取密钥
856
859
  console.log(chalk.yellow('\n步骤 3/3: 提取数据库解密密钥...'))
857
860
 
861
+ // **先看 Python 在不在**(Windows):它决定"读库"这一整步能不能做。
862
+ // 以前不看,于是 Python 不可用时还要先等 120 秒 hook 超时,然后把用户引向
863
+ // "退出微信重试"——而真正的原因(商店占位程序 / 缺 sqlcipher3)一个字都没提。
864
+ // 2026-10-19 用一台"PATH 里没有 python"的机器实测,2 分钟换来一条误导性建议。
865
+ let pythonUsable = process.platform !== 'win32'
866
+ if (process.platform === 'win32' && !opts.testMissingKeys) {
867
+ try {
868
+ const { execFileSync } = await import('node:child_process')
869
+ execFileSync(getPythonCommand(), ['--version'], { windowsHide: true, stdio: 'ignore', timeout: 5000 })
870
+ pythonUsable = true
871
+ } catch {
872
+ pythonUsable = false
873
+ console.log(chalk.red(' ✗ 没有可用的 Python —— 读微信库这一步跑不起来'))
874
+ console.log(chalk.gray(' NT 数据库扫描与密钥派生都靠它(需要 Python 3.10+ 且能 import sqlcipher3)。'))
875
+ console.log(chalk.gray(' Windows 上最常见的原因是只装了应用商店的 python 占位程序:'))
876
+ console.log(chalk.gray(' 在终端里运行 `python --version`,能打出版本号才算装对。'))
877
+ console.log(chalk.gray(' 跑 `weflow-cli check --report` 可以看到完整判定(那段话可直接贴给 AI)。'))
878
+ }
879
+ }
880
+
858
881
  // 优先尝试从 WeFlow 配置读取
859
882
  console.log(chalk.gray(' 尝试从 WeFlow 桌面版配置读取...'))
860
883
  let extractedKey = opts.testMissingKeys ? '' : tryReadWeFlowKey()
@@ -922,6 +945,22 @@ program
922
945
  process.exit(1)
923
946
  }
924
947
  }
948
+ } else if (!pythonUsable) {
949
+ // Python 不可用时**不去等 hook**:就算 hook 抓到了密钥,Step 4 的扫描/派生照样跑不起来,
950
+ // 而那 120 秒的等待只会把用户引向"退出微信重试"这种无效动作。
951
+ const fallback = configService.get('favPassphrase') || configService.get('decryptKey') || ''
952
+ if (fallback) {
953
+ console.log(chalk.gray(' 跳过 hook 等待:已配置的密钥会原样保留,但要先修好 Python 才读得动'))
954
+ extractedKey = fallback
955
+ } else {
956
+ console.log(chalk.red('\n✗ 这台机器现在无法读取微信数据库(缺 Python),因此没有可用密钥'))
957
+ console.log(chalk.gray('\n按顺序做:'))
958
+ console.log(chalk.gray(' 1) 装一个真正的 Python 3.10+,确认 `python --version` 能打出版本号'))
959
+ console.log(chalk.gray(' 2) `pip install sqlcipher3`'))
960
+ console.log(chalk.gray(' 3) 重新 `weflow-cli init`'))
961
+ console.log(chalk.gray(' 卡住时跑 `weflow-cli check --report`,那段文字可直接贴给 AI 或贴到 issue'))
962
+ process.exit(1)
963
+ }
925
964
  } else if (process.platform !== 'win32') {
926
965
  // macOS:自动提取不可用,引导手动提供密钥
927
966
  console.log(chalk.yellow(' ⚠ 当前平台无法自动提取密钥(自动提取支持 Windows/Linux)'))
@@ -932,17 +971,23 @@ program
932
971
  console.log(chalk.gray(' 4. 数据目录与账号已完成配置,设置密钥后即可使用 sessions/messages 等命令'))
933
972
  process.exit(1)
934
973
  } else {
935
- // 如果微信未运行,提示启动
936
- if (!version) {
937
- console.log(chalk.cyan('\n ══════════════════════════════════════'))
938
- console.log(chalk.cyan(' 请现在启动微信 4.x 并登录!'))
939
- console.log(chalk.cyan(' ══════════════════════════════════════\n'))
974
+ // hook 只在**登录那一瞬**触发。所以要不要等,取决于微信现在在不在跑:
975
+ // · 已在运行 → 用户早就登录了,hook 永远不会再触发,等满 2 分钟纯属浪费
976
+ // (2026-10-19 用一个空配置实测:每次 init 都白等)
977
+ // · 没在运行 → 这正是 README「三分钟上手」第 2 步要求的姿势(关掉微信再 init,
978
+ // 然后按提示启动并登录)。人要打开客户端、等它起来、扫码/点确认,
979
+ // 30 秒根本不够,**必须留足**,否则按文档操作的人会被那句
980
+ // "hook 没抓到" 误导。所以就按文档给满 120 秒。
981
+ let keyResult: { success: boolean; key?: string; error?: string } = { success: false }
982
+ if (version) {
983
+ console.log(chalk.gray(' 微信已在运行:hook 只在登录那一瞬触发,不再等待,直接尝试扫描/派生'))
984
+ } else {
985
+ console.log(chalk.cyan('\n 请现在启动微信并登录(登录那一瞬会自动抓取密钥)'))
986
+ console.log(chalk.cyan(' 最多等 2 分钟;不启动也没关系,之后会继续尝试 NT 扫描 / passphrase 派生\n'))
987
+ keyResult = await keyService.waitForKey(120000, (msg) => {
988
+ console.log(chalk.gray(` ${msg}`))
989
+ })
940
990
  }
941
- console.log(chalk.gray(' 等待微信进程出现(启动后会自动 hook)...'))
942
-
943
- const keyResult = await keyService.waitForKey(120000, (msg) => {
944
- console.log(chalk.gray(` ${msg}`))
945
- })
946
991
 
947
992
  if (keyResult.success && keyResult.key) {
948
993
  extractedKey = keyResult.key
@@ -959,12 +1004,13 @@ program
959
1004
  console.log(chalk.gray(' 检测到已配置的密钥, 沿用它并重新验证各库...'))
960
1005
  extractedKey = fallback
961
1006
  } else {
962
- console.log(chalk.red(`\n✗ 密钥提取失败: ${keyResult.error}`))
963
- console.log(chalk.gray('\n解决方案:'))
964
- console.log(chalk.gray(' 1. 退出微信后重新运行 init (hook 需在微信登录瞬间触发, init 会自动等待)'))
965
- console.log(chalk.gray(' 2. 或用第三方工具提取全库 passphrase 后: weflow-cli fav set-key --passphrase <64位hex>, 再重新 init 自动派生'))
966
- console.log(chalk.gray(' 3. 或手动指定密钥: weflow-cli config set decryptKey <64位密钥>'))
967
- process.exit(1)
1007
+ // **不退出**:hook 只在"登录那一瞬"触发,而一个正常用户装完工具时微信早就登录了,
1008
+ // 于是这条 120 秒的等待**必然**超时。以前这里 process.exit(1),用户就被卡在
1009
+ // 「退出微信后重新运行 init」——而下一步(Step 4 的 NT 扫描 / 从 passphrase 派生)
1010
+ // 才是新版本真正能拿到密钥的地方,却永远不会被执行(2026-10-19 用空配置实测)。
1011
+ // 现在改成继续往下走:拿得到就成功,拿不到由结尾统一判"未完成"。
1012
+ console.log(chalk.yellow('\n Hook 没抓到密钥(它只在登录那一瞬触发,已登录时不会再有)'))
1013
+ console.log(chalk.gray(' 继续尝试 NT 扫描 / passphrase 派生 —— 这是新版本主要的取密钥方式...'))
968
1014
  }
969
1015
  }
970
1016
  }
@@ -972,9 +1018,13 @@ program
972
1018
  if (extractedKey) {
973
1019
  configService.set('decryptKey', extractedKey)
974
1020
  if (keyCaptured) console.log(chalk.green('\n✓ 密钥获取成功!'))
975
- else console.log(chalk.green('\n✓ 已沿用配置中的密钥 (本次未重新捕获)'))
1021
+ else if (pythonUsable) console.log(chalk.green('\n✓ 已沿用配置中的密钥 (本次未重新捕获)'))
1022
+ // 缺 Python 时**不能说"已沿用"**:那句话在这里会被读成"密钥没问题",
1023
+ // 而下面紧接着就会说"密钥未就绪",两句自相矛盾(2026-10-19 实跑看到)。
1024
+ else console.log(chalk.yellow('\n· 配置里的密钥原样保留,但**这次验证不了**(缺 Python)——它可能仍然可用'))
976
1025
  } else {
977
- console.log(chalk.green('\n✓ NT 密钥配置完成!'))
1026
+ // 空密钥时不能说"配置完成":下一句就是"密钥未就绪",两句打架。
1027
+ console.log(chalk.yellow('\n· 本次没有拿到密钥,继续尝试 NT 扫描 / passphrase 派生'))
978
1028
  }
979
1029
 
980
1030
  // Step 4: 尝试扫描 NT 格式数据库 (xwechat_files)
@@ -982,6 +1032,11 @@ program
982
1032
  // 进入这里已经确认是 4.x 初始化流程。不要依赖步骤 0 的 version:
983
1033
  // 微信可能是在 init 等待期间才启动,初始 version 会保持为 null。
984
1034
  // Linux 分支已在步骤 3 完成配置,跳过。
1035
+ // 这次 init 到底有没有**取得可用的密钥** —— 只有它决定结尾能不能说"初始化完成"。
1036
+ // 扫描失败 + 沿用旧密钥 = 一次看着成功、实际什么也读不到的初始化:2026-10-18 一位用户
1037
+ // 的 init 打在 "NT scan failed (exit 9009)" 上(Python 是商店占位程序),结尾照样报
1038
+ // "初始化完成!"、"密钥: 已安全保存",然后 sessions 一条都没有。
1039
+ let ntReady = process.platform === 'linux' // Linux 分支已在步骤 3 配好
985
1040
  if (process.platform !== 'linux') {
986
1041
  console.log(chalk.yellow('\n步骤 4/4: 扫描 NT 格式数据库...'))
987
1042
  console.log(chalk.gray(' 正在从微信内存中匹配数据库密钥...'))
@@ -999,6 +1054,7 @@ program
999
1054
  }
1000
1055
 
1001
1056
  if (primaryDb) {
1057
+ ntReady = true
1002
1058
  configService.set('ntDbPath', primaryDb.path)
1003
1059
  configService.set('ntKey', primaryDb.key)
1004
1060
  configService.set('ntSalt', primaryDb.salt)
@@ -1048,6 +1104,7 @@ program
1048
1104
  }
1049
1105
 
1050
1106
  if (derivedMessage) {
1107
+ ntReady = true
1051
1108
  console.log(chalk.green(' ✓ 派生模式初始化成功 (聊天/联系人/朋友圈库已配置)'))
1052
1109
  } else {
1053
1110
  // 即使密钥未匹配,仍尝试自动发现 sns.db 路径和盐值
@@ -1066,23 +1123,40 @@ program
1066
1123
  }
1067
1124
 
1068
1125
  console.log(chalk.cyan('\n=============================='))
1069
- console.log(chalk.cyan('初始化完成! (4.x 模式)'))
1126
+ // 只有**这次真的拿到可用密钥**才叫"完成"。否则说清楚"没就绪"以及下一步做什么 ——
1127
+ // 报"完成"再让 sessions 空着,是最费用户时间的失败方式(他会以为是自己微信的问题)。
1128
+ console.log(chalk[ntReady ? 'cyan' : 'yellow'](
1129
+ ntReady ? '初始化完成! (4.x 模式)' : '初始化未完成:数据库密钥没有就绪'))
1070
1130
  console.log(chalk.cyan('=============================='))
1071
1131
  console.log(chalk.white(`数据目录: ${detected.path}`))
1072
1132
  console.log(chalk.white(`账号: ${selectedWxid.wxid}${selectedWxid.nickname ? ` (${selectedWxid.nickname})` : ''}`))
1073
- const displayKey = extractedKey || linuxNtKey
1074
- console.log(chalk.white('密钥: 已安全保存'))
1133
+ console.log(chalk.white(`密钥: ${ntReady ? '已安全保存' : '未就绪(配置里的旧密钥未必能解开这个库)'}`))
1075
1134
  const ntDbPath = configService.get('ntDbPath')
1076
1135
  if (ntDbPath) {
1077
1136
  console.log(chalk.white(`NT 数据库: ${ntDbPath}`))
1078
1137
  }
1138
+ if (!ntReady) {
1139
+ console.log(chalk.yellow('\n现在 sessions / messages 会读不到数据。按顺序做这三步:'))
1140
+ console.log(chalk.yellow(' 1) 在这个终端里运行 `python --version`,要能打出版本号'))
1141
+ console.log(chalk.yellow(' (Windows 上最常见的问题是只装了商店的 python 占位程序)'))
1142
+ console.log(chalk.yellow(' 2) `pip install sqlcipher3`'))
1143
+ console.log(chalk.yellow(' 3) `weflow-cli config forget-keys`,然后重新 `weflow-cli init`'))
1144
+ // 这三步是"最常见的原因",不是诊断结论。真卡住时让用户拿到一份**已经判定过**的事实,
1145
+ // 而不是再让他把现象口头描述一遍(那正是 issue #3/#9/#10 每次都要重来一遍的原因)。
1146
+ console.log(chalk.yellow('\n想先看清到底卡在哪一步,运行:'))
1147
+ console.log(chalk.cyan(' weflow-cli check --report'))
1148
+ console.log(chalk.gray(' 它会给出结论(Python / sqlcipher3 / 密钥 / 数据目录),那段文字可以直接复制给 AI 或贴到 issue。'))
1149
+ }
1150
+
1151
+ // 未就绪时说"现在可以使用"是骗人的:那几条命令都会返回空。
1152
+ console.log(chalk.gray(ntReady
1153
+ ? '\n现在可以使用以下命令:'
1154
+ : '\n修好上面那几步之后,这些命令才会返回数据:'))
1155
+ console.log(chalk.gray(' weflow-cli sessions 查看会话列表'))
1156
+ console.log(chalk.gray(' weflow-cli messages <talker> 查看消息'))
1157
+ console.log(chalk.gray(' weflow-cli contacts 查看联系人'))
1158
+ console.log(chalk.gray(' weflow-cli export <talker> <format> 导出聊天记录'))
1079
1159
  }
1080
-
1081
- console.log(chalk.gray('\n现在可以使用以下命令:'))
1082
- console.log(chalk.gray(' weflow-cli sessions 查看会话列表'))
1083
- console.log(chalk.gray(' weflow-cli messages <talker> 查看消息'))
1084
- console.log(chalk.gray(' weflow-cli contacts 查看联系人'))
1085
- console.log(chalk.gray(' weflow-cli export <talker> <format> 导出聊天记录'))
1086
1160
  })
1087
1161
 
1088
1162
  // ==================== config ====================
@@ -1095,6 +1169,7 @@ const configurableKeys = [
1095
1169
  'ntDbPath', 'ntKey', 'ntSalt', 'contactDbPath', 'contactKey', 'contactSalt',
1096
1170
  'vaultRepo', 'aiEngine', 'aiBaseUrl', 'aiModel', 'deepseekApiKey', 'typesafeApiKey',
1097
1171
  'dashscopeApiKey', 'favPassphrase',
1172
+ 'judgeProvider', 'openrouterApiKey', 'openrouterJudgeModel',
1098
1173
  'wereadApiKey',
1099
1174
  'assistantPrivacy', 'assistantWhitelist', 'assistantGroupWhitelist',
1100
1175
  'assistantGroupRequireMention', 'assistantFastRoute', 'assistantPanelUser', 'quickReplyContacts',
@@ -1344,6 +1419,12 @@ program
1344
1419
  if (sessions.length === 0) {
1345
1420
  if (opts.json) { console.log(JSON.stringify({ success: true, sessions: [] })); return }
1346
1421
  console.log(chalk.gray('未找到会话'))
1422
+ // "0 条"有两种完全不同的原因:真的没有会话,和**初始化/密钥坏了读不出来**。
1423
+ // 后者以前只显示这一句,用户就去猜"是不是我微信坏了 / 版本太新"(issue #3/#9/#10 的老路:
1424
+ // 同一个根因被描述成三种现象)。给一条能立刻分清的动作,比让他猜有用。
1425
+ console.log(chalk.yellow('\n如果微信里明明有聊天记录,那多半是读取这一步没成功。运行这条看结论:'))
1426
+ console.log(chalk.cyan(' weflow-cli check --report'))
1427
+ console.log(chalk.gray(' 它会指出卡在哪一步(Python / sqlcipher3 / 密钥 / 数据目录),那段文字可直接复制给 AI 或贴到 issue。'))
1347
1428
  return
1348
1429
  }
1349
1430
 
@@ -5001,11 +5082,12 @@ program
5001
5082
  .option('--no-rerank', '不调用决策模型重排,只按向量/关键词相似度返回(回退到引入重排之前)')
5002
5083
  .option('--no-hybrid', '不融合本地 BM25 名次(默认融合;门控后实测只增不减)')
5003
5084
  .option('--rerank-pool <n>', '重排看多少条候选(默认 100;越大越准也越贵)')
5004
- .option('--no-dedup', '不折叠同文本的多号转载(默认折叠,结果里带 duplicateCount)')
5005
- .option('--with-text [n]', '给每条命中挂上本地正文(fullText / sourceFile / url);n 为每条最多多少字')
5006
- .option('--fetch-missing [n]', '正文太薄时按 url 抓一次(最多 n 条,默认 3;复用日报缓存)')
5007
- .option('--mmr', '对结果做 MMR 去冗余(压掉近似的同领域邻居,可能牺牲单点召回)')
5085
+ .option('--no-dedup', '不折叠同文本的多号转载(默认折叠,结果里带 duplicateCount)')
5086
+ .option('--with-text [n]', '给每条命中挂上本地正文(fullText / sourceFile / url);n 为每条最多多少字')
5087
+ .option('--fetch-missing [n]', '正文太薄时按 url 抓一次(最多 n 条,默认 3;复用日报缓存)')
5088
+ .option('--mmr', '对结果做 MMR 去冗余(压掉近似的同领域邻居,可能牺牲单点召回)')
5008
5089
  .option('--from <account>', '只查这个公众号的文章(按文件名匹配,不走语义)')
5090
+ .option('--phrase', '把这串当**原样短语**在本地定位(FTS5/trigram,不走向量、不出境、不花钱):回答"哪个号在哪天原样写过它"')
5009
5091
  .option('--api-key <key>', 'DashScope embedding API key')
5010
5092
  .option('--dry-run', '仅预览,不读取索引或调用向量服务')
5011
5093
  .option('--yes', '确认执行可能调用云端向量服务的搜索')
@@ -5017,34 +5099,59 @@ program
5017
5099
  const pkgRoot = resolvePackageRoot()
5018
5100
  const script = join(pkgRoot, 'scripts', 'semantic_search.py')
5019
5101
  const topK = parseCliInteger(opts.topK, 'top-k', 1, 100, opts.json)
5020
- // 没传就**不转发**(默认值只在 Python 侧定义一处):commander 对未提供的选项给 undefined,
5021
- // 直接 parseCliInteger 会被自己的校验判成非法参数 —— 实测就是这么红的。
5022
- const rerankPool = opts.rerankPool
5102
+ // 没传就**不转发**(默认值只在 Python 侧定义一处):commander 对未提供的选项给 undefined,
5103
+ // 直接 parseCliInteger 会被自己的校验判成非法参数 —— 实测就是这么红的。
5104
+ const rerankPool = opts.rerankPool
5023
5105
  ? parseCliInteger(opts.rerankPool, 'rerank-pool', 5, 500, opts.json) : null
5024
5106
  requireCliChoice(opts.line, ['wiki', 'chat', 'all'], 'line', opts.json)
5107
+ // `--phrase` 走的是**纯本地**那条路(FTS5):不出境、不花钱,所以它既不需要向量 key,
5108
+ // 也不需要确认门。把它当成"向量搜索的降级"会让人以为它也花钱、也发数据。
5109
+ const phrase = opts.phrase === true
5110
+ // 下面这些旗标只属于**向量检索**那条路;`--phrase`(FTS5 原样定位)一个都用不上。
5111
+ // 以前它们会被原样转发,然后在 Python 的 phrase 分支里被**静默忽略** —— 用户以为自己在调参,
5112
+ // 实际上什么都没发生。这类"接受但不生效"的开关比报错更贵:它会让人把默认行为当成调过的结果。
5113
+ if (phrase) {
5114
+ const irrelevant: string[] = []
5115
+ if (opts.rerank === false) irrelevant.push('--no-rerank')
5116
+ if (opts.hybrid === false) irrelevant.push('--no-hybrid')
5117
+ if (rerankPool != null) irrelevant.push('--rerank-pool')
5118
+ if (opts.dedup === false) irrelevant.push('--no-dedup')
5119
+ if (opts.withText) irrelevant.push('--with-text')
5120
+ if (opts.fetchMissing) irrelevant.push('--fetch-missing')
5121
+ if (opts.mmr) irrelevant.push('--mmr')
5122
+ if (irrelevant.length) {
5123
+ const error = `--phrase 只做原样定位,不接受 ${irrelevant.join('、')}(它们只对向量检索有意义)`
5124
+ if (opts.json) console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error }))
5125
+ else console.log(chalk.red(error))
5126
+ process.exit(1)
5127
+ }
5128
+ }
5025
5129
  const preview = {
5026
5130
  success: true,
5027
5131
  dryRun: true,
5028
5132
  action: 'semantic-search.query',
5133
+ phrase,
5029
5134
  topK,
5030
5135
  line: opts.line,
5031
5136
  noRerank: opts.rerank === false,
5032
5137
  noHybrid: opts.hybrid === false,
5033
5138
  rerankPool,
5034
- noDedup: opts.dedup === false,
5035
- withText: opts.withText || null,
5036
- fetchMissing: opts.fetchMissing || null,
5037
- mmr: opts.mmr === true,
5139
+ noDedup: opts.dedup === false,
5140
+ withText: opts.withText || null,
5141
+ fetchMissing: opts.fetchMissing || null,
5142
+ mmr: opts.mmr === true,
5038
5143
  from: opts.from || null,
5039
5144
  readsLocalIndex: true,
5040
- mayUseCloudEmbedding: true,
5145
+ mayUseCloudEmbedding: !phrase,
5041
5146
  }
5042
5147
  if (opts.dryRun) {
5043
5148
  if (opts.json) console.log(JSON.stringify(preview))
5044
- else console.log(chalk.cyan('预览:将读取本地语义索引,并可能把查询发送到已配置的向量服务。'))
5149
+ else console.log(chalk.cyan(phrase
5150
+ ? '预览:原样短语定位 —— 只读本地短语索引,不联网、不花钱。'
5151
+ : '预览:将读取本地语义索引,并可能把查询发送到已配置的向量服务。'))
5045
5152
  return
5046
5153
  }
5047
- if (!opts.yes) {
5154
+ if (!phrase && !opts.yes) {
5048
5155
  if (opts.json) {
5049
5156
  console.log(JSON.stringify({ ...preview, success: false, dryRun: false, code: 'CONFIRMATION_REQUIRED' }))
5050
5157
  process.exit(1)
@@ -5064,15 +5171,16 @@ program
5064
5171
  // 接这条通道之前,`OPERATIONS.md` 一直在教用户敲 `search --no-rerank`,而 CLI 会答 `unknown option`
5065
5172
  // —— 同一份文档里 `--line` 是通的,所以它看起来像"能用的旗标"(2026-10-11 由文档命令面那道检查发现)。
5066
5173
  const args: string[] = [script, 'search', '--top-k', String(topK), '--line', opts.line,
5174
+ ...(phrase ? ['--phrase'] : []),
5067
5175
  ...(opts.rerank === false ? ['--no-rerank'] : []),
5068
5176
  ...(opts.hybrid === false ? ['--no-hybrid'] : []),
5069
5177
  ...(opts.from ? ['--from', opts.from] : []),
5070
- ...(rerankPool ? ['--rerank-pool', String(rerankPool)] : []),
5071
- ...(opts.dedup === false ? ['--no-dedup'] : []),
5072
- ...(opts.withText ? ['--with-text', opts.withText === true ? '4000' : String(opts.withText)] : []),
5073
- ...(opts.fetchMissing ? ['--fetch-missing',
5074
- opts.fetchMissing === true ? '3' : String(opts.fetchMissing)] : []),
5075
- ...(opts.mmr ? ['--mmr'] : [])]
5178
+ ...(rerankPool ? ['--rerank-pool', String(rerankPool)] : []),
5179
+ ...(opts.dedup === false ? ['--no-dedup'] : []),
5180
+ ...(opts.withText ? ['--with-text', opts.withText === true ? '4000' : String(opts.withText)] : []),
5181
+ ...(opts.fetchMissing ? ['--fetch-missing',
5182
+ opts.fetchMissing === true ? '3' : String(opts.fetchMissing)] : []),
5183
+ ...(opts.mmr ? ['--mmr'] : [])]
5076
5184
  try {
5077
5185
  const { stdout } = await execFileAsync(getPythonCommand(), args, {
5078
5186
  timeout: 60_000, maxBuffer: 10 * 1024 * 1024,
@@ -5092,6 +5200,7 @@ program
5092
5200
  .command('search-index')
5093
5201
  .description('构建语义搜索索引')
5094
5202
  .option('--full', '全量重建')
5203
+ .option('--phrase', '只建**精确短语索引**(本地 FTS5/trigram,不调用向量服务、不花钱、不出境)')
5095
5204
  .option('--api-key <key>', 'DashScope embedding API key')
5096
5205
  .option('--days <n>', '聊天记录收最近多少天(默认 90)')
5097
5206
  .option('--article-days <n>', '日报文章收最近多少个日期目录(默认 30)')
@@ -5099,6 +5208,35 @@ program
5099
5208
  .option('--yes', '确认构建或重建索引')
5100
5209
  .option('--json', '输出 JSON 格式')
5101
5210
  .action(async (opts) => {
5211
+ // `--phrase`:短语索引那条路完全不碰向量服务(也不碰聊天库——它从语义索引的 meta.json
5212
+ // 派生),所以它不走窗口参数、不需要确认门,也不校验 key。混进下面那条路会让用户以为
5213
+ // "建个本地的检索也要花 vector 的钱"。
5214
+ if (opts.phrase) {
5215
+ const phrasePreview = {
5216
+ success: true, dryRun: true, action: 'search-index.build-phrase',
5217
+ readsLocalData: true, usesCloudEmbedding: false, rebuildsPhraseIndex: true,
5218
+ }
5219
+ if (opts.dryRun) {
5220
+ if (opts.json) console.log(JSON.stringify(phrasePreview))
5221
+ else console.log(chalk.cyan('短语索引预览:全量重建本地 FTS5 索引(含两条线),不联网、不花钱。'))
5222
+ return
5223
+ }
5224
+ const { execFile } = await import('child_process')
5225
+ const { promisify } = await import('util')
5226
+ const execFileAsync = promisify(execFile)
5227
+ const pkgRoot = resolvePackageRoot()
5228
+ const script = join(pkgRoot, 'scripts', 'semantic_search.py')
5229
+ try {
5230
+ const { stdout } = await execFileAsync(getPythonCommand(),
5231
+ [script, 'phrase-build', '--line', 'all'],
5232
+ { timeout: 600_000, maxBuffer: 10 * 1024 * 1024, env: pythonProcessEnv() })
5233
+ console.log(opts.json ? stdout.trim() : stdout)
5234
+ } catch (e: any) {
5235
+ console.error(chalk.red(`\n✗ ${safeSubprocessError(e, '建短语索引失败')}`))
5236
+ process.exit(1)
5237
+ }
5238
+ return
5239
+ }
5102
5240
  // 窗口的**运行值由这里说了算**:默认值 + 校验 + 透传都在这一处,预览报的就是真跑的那些
5103
5241
  // (脚本文档里另有自己的默认值,只供手动直接跑脚本时用;CLI 这条路永远显式传参,
5104
5242
  // 所以两边万一漂了也不会影响实际行为)。
@@ -6212,12 +6350,14 @@ program
6212
6350
  .option('--source <name>', '仅处理指定公众号,可重复或用逗号分隔', (value, previous: string[] = []) => [...previous, value], [])
6213
6351
  .description('一键生成公众号日报(抓取 + AI 摘要 + HTML 阅读器 + 行动建议)')
6214
6352
  .option('-d, --date <YYYY-MM-DD>', '日期')
6353
+ .option('--yesterday', '跑昨天(等价于 --date 昨天)。公众号发到 24:00 才停,所以当晚跑的那一天永远不全;"跑昨天"与时刻无关,计划任务用这个')
6215
6354
  .option('--api-key <key>', 'DeepSeek API key(或设环境变量 DEEPSEEK_API_KEY)')
6216
6355
  .option('--skip-classify', '跳过后处理')
6217
6356
  .option('--dry-run', '仅预览文章,不调用 AI 或写入日报')
6218
6357
  .option('--yes', '确认执行日报生成;JSON 模式需要此选项')
6219
6358
  .option('--no-ai', '关闭本次日报的所有 AI 处理')
6220
6359
  .option('--no-summary', '只判断不生成:不调 LLM 写摘要/标签/简报(主题与相关度仍由 Jev 判断),无需 DeepSeek key')
6360
+ .option('--prefetch-bodies [n]', '抓取之前先并发把当天正文抓进缓存(n=并发数,默认 8):抓取阶段于是全部命中缓存、不再逐篇 sleep 8-12 秒(450 篇约 75 分钟 → 秒级)。对上游的请求次数不变,只是压进更短的时间窗')
6221
6361
  .option('--json', '输出机器可读的最终结果;运行日志写入 stderr')
6222
6362
  .action(async (opts) => {
6223
6363
  const { spawn } = await import('child_process')
@@ -6228,7 +6368,16 @@ program
6228
6368
 
6229
6369
  const now = new Date()
6230
6370
  const localDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
6231
- const date = opts.date || localDate
6371
+ const isoDate = (when: Date) => `${when.getFullYear()}-${String(when.getMonth() + 1).padStart(2, '0')}-${String(when.getDate()).padStart(2, '0')}`
6372
+ if (opts.date && opts.yesterday) {
6373
+ const error = '--date 与 --yesterday 不能同时给(前者是明确的一天,后者是"昨天")'
6374
+ if (opts.json) console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', field: 'date', error }))
6375
+ else console.log(chalk.red(error))
6376
+ process.exit(1)
6377
+ }
6378
+ const date = opts.date || (opts.yesterday
6379
+ ? isoDate(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
6380
+ : localDate)
6232
6381
  const apiKey = opts.apiKey || process.env.DEEPSEEK_API_KEY || ''
6233
6382
  const noAi = opts.ai === false || configService.get('dailyAiEnabled') === 'false'
6234
6383
  const dailyLog = (...values: any[]) => opts.json ? console.error(...values) : console.log(...values)
@@ -6254,6 +6403,14 @@ program
6254
6403
  if (opts.noSummary && !noAi) args.push('--no-summary')
6255
6404
  for (const source of opts.source || []) args.push('--source', source)
6256
6405
  if (opts.skipClassify) args.push('--skip-classify')
6406
+ // 只在**给了**这个旗标时才解析数字:`--prefetch-bodies`(不带数字)是合法的,
6407
+ // 而默认并发数只在 python 侧写一份(这里再写一份迟早漂移)。
6408
+ if (opts.prefetchBodies !== undefined) {
6409
+ args.push('--prefetch-bodies')
6410
+ if (opts.prefetchBodies !== true) {
6411
+ args.push(String(parseCliInteger(opts.prefetchBodies, 'prefetch-bodies', 1, 64, !!opts.json)))
6412
+ }
6413
+ }
6257
6414
 
6258
6415
  dailyLog(chalk.cyan(`\n📰 正在生成 ${targetDate} 公众号日报${noAi ? '(AI 已关闭)' : ''}...\n`))
6259
6416
  const child = spawn(getPythonCommand(), args, {
@@ -7433,6 +7590,7 @@ program
7433
7590
  .command('check')
7434
7591
  .description('检查运行环境(Python、pip 依赖、数据库配置)')
7435
7592
  .option('--json', '输出 JSON 格式,不包含数据库路径或密钥')
7593
+ .option('--report', '输出一段可直接粘贴给 AI / 贴进 issue 的诊断结论(已脱敏:用户名与 wxid 换占位符,不含密钥与聊天内容)')
7436
7594
  .action(async (opts) => {
7437
7595
  // 微信客户端的**版本**与它自报的**数据目录**:两个分支都要用,所以在这里算一次。
7438
7596
  // 判据见 `wechatClient.ts`(版本取活进程的 `--user-lib-dir`;取不到就是"未知",不是"没问题")。
@@ -7456,7 +7614,9 @@ program
7456
7614
  const dataDirMatchesConfig = (client.dataDirs.length && configuredRoot)
7457
7615
  ? client.dataDirs.some(dir => samePath(dir, configuredRoot))
7458
7616
  : null
7459
- if (opts.json) {
7617
+ // `--json`(给机器)与 `--report`(给人/AI)用的是**同一份事实**,所以只探测一次:
7618
+ // 两个分支各写一份探测,迟早会出现"JSON 说 Python 可用、报告说不可用"这种自相矛盾。
7619
+ const collectCheckData = async () => {
7460
7620
  const { execFileSync } = await import('child_process')
7461
7621
  const requiredDependencies = ['sqlcipher3', 'html2text', 'zstandard', 'cryptography']
7462
7622
  const optionalDependencies = ['scrapling']
@@ -7530,9 +7690,48 @@ program
7530
7690
  caveats: client.caveats,
7531
7691
  },
7532
7692
  }
7533
- console.log(JSON.stringify(data, null, 2))
7693
+ return data
7694
+ }
7695
+
7696
+ if (opts.json) {
7697
+ console.log(JSON.stringify(await collectCheckData(), null, 2))
7698
+ return
7699
+ }
7700
+
7701
+ // `--report`:一段**可整段复制**的诊断块(脱敏后)。这是"用户说不清、我们又猜不准"
7702
+ // 那个循环的出口 —— issue #3/#9/#10 与 2026-10-18 那封邮件都是同一个根因被描述成四种现象。
7703
+ if (opts.report) {
7704
+ const data = await collectCheckData()
7705
+ console.log(renderDiagnosticReport({
7706
+ cliVersion: CLI_VERSION,
7707
+ // 本地时间:用户对照日志/截图时要能直接对上,UTC 会多一步换算
7708
+ generatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
7709
+ platform: process.platform,
7710
+ arch: process.arch,
7711
+ nodeVersion: process.versions.node,
7712
+ python: {
7713
+ command: getPythonCommand(),
7714
+ available: !!data.runtime.python.available,
7715
+ version: String(data.runtime.python.version || ''),
7716
+ },
7717
+ dependencies: data.dependencies,
7718
+ configuration: data.configuration,
7719
+ keys: {
7720
+ messageKeyConfigured: !!configService.get('ntKey') || !!configService.get('decryptKey'),
7721
+ passphraseConfigured: !!configService.get('favPassphrase'),
7722
+ },
7723
+ wechatClient: {
7724
+ running: data.wechatClient.running,
7725
+ version: data.wechatClient.version,
7726
+ note: data.wechatClient.note,
7727
+ dataDirMatchesConfig: data.wechatClient.dataDirMatchesConfig,
7728
+ dataDirs: data.wechatClient.dataDirs,
7729
+ unusableVersionDirs: data.wechatClient.unusableVersionDirs,
7730
+ },
7731
+ }))
7534
7732
  return
7535
7733
  }
7734
+
7536
7735
  console.log(chalk.cyan('🔍 WeFlow CLI 环境检查\n'))
7537
7736
 
7538
7737
  // 1. Node.js
@@ -7607,6 +7806,16 @@ program
7607
7806
  const stat = (await import('fs')).statSync(path)
7608
7807
  const mb = (stat.size / 1024 / 1024).toFixed(0)
7609
7808
  console.log(chalk.white(` ${label}: ${path} (${mb}MB)`))
7809
+ // 微信"存储位置"设到别的盘时,用户目录下那个是**junction**:显示的是 C 盘路径,
7810
+ // 数据其实在 D 盘。不点出来的话,用户会以为配置指错了地方(2026-10-19 实测:
7811
+ // `C:\Users\…\xwechat_files` → `D:\微信\xwechat_files`)。
7812
+ try {
7813
+ const { realpathSync } = await import('fs')
7814
+ const real = realpathSync(path)
7815
+ if (real.toLowerCase() !== path.toLowerCase()) {
7816
+ console.log(chalk.gray(` ↳ 软链指向 ${real}`))
7817
+ }
7818
+ } catch { /* 解析不了就不说,别拿它当错误 */ }
7610
7819
  } else {
7611
7820
  const hint = keyRequired ? ' (可选, init 或 sns/fav 命令配置)' : ''
7612
7821
  console.log(chalk.gray(` ${label}: 未配置${hint}`))