weflow-cli 1.11.1 → 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 (68) hide show
  1. package/CHANGELOG.md +378 -0
  2. package/OPERATIONS.md +359 -11
  3. package/README.md +4 -0
  4. package/bin/weflow-cli.ts +289 -37
  5. package/dist/bin/weflow-cli.js +300 -35
  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/assistantEval.d.ts.map +1 -1
  19. package/dist/src/services/assistantEval.js +3 -1
  20. package/dist/src/services/assistantEval.js.map +1 -1
  21. package/dist/src/services/assistantTools.d.ts.map +1 -1
  22. package/dist/src/services/assistantTools.js +25 -7
  23. package/dist/src/services/assistantTools.js.map +1 -1
  24. package/dist/src/services/configService.d.ts +3 -0
  25. package/dist/src/services/configService.d.ts.map +1 -1
  26. package/dist/src/services/configService.js +6 -3
  27. package/dist/src/services/configService.js.map +1 -1
  28. package/dist/src/services/diagnosticReport.d.ts +80 -0
  29. package/dist/src/services/diagnosticReport.d.ts.map +1 -0
  30. package/dist/src/services/diagnosticReport.js +191 -0
  31. package/dist/src/services/diagnosticReport.js.map +1 -0
  32. package/dist/src/services/exportService.d.ts +7 -0
  33. package/dist/src/services/exportService.d.ts.map +1 -1
  34. package/dist/src/services/exportService.js +1 -1
  35. package/dist/src/services/exportService.js.map +1 -1
  36. package/dist/src/services/wechatClient.d.ts +9 -3
  37. package/dist/src/services/wechatClient.d.ts.map +1 -1
  38. package/dist/src/services/wechatClient.js +55 -22
  39. package/dist/src/services/wechatClient.js.map +1 -1
  40. package/docs/DECISIONS.md +662 -1
  41. package/docs/MCP.md +1 -1
  42. package/docs/NEXT_ACTIONS.md +236 -0
  43. package/docs/POSITIONING.md +68 -0
  44. package/docs/PROJECT_STATE.md +119 -17
  45. package/docs/RELEASING.md +14 -0
  46. package/package.json +88 -86
  47. package/scripts/_utils.py +120 -4
  48. package/scripts/backfill_articles.py +64 -14
  49. package/scripts/biz_daily.py +118 -11
  50. package/scripts/daily_task.ps1 +132 -0
  51. package/scripts/export_chat_html.py +79 -31
  52. package/scripts/install_tasks.cmd +32 -0
  53. package/scripts/install_tasks.ps1 +91 -0
  54. package/scripts/jev_client.py +335 -3
  55. package/scripts/nt_decrypt.py +79 -14
  56. package/scripts/pipeline.py +21 -0
  57. package/scripts/recall_eval.py +247 -0
  58. package/scripts/repro_customer_init.cmd +86 -0
  59. package/scripts/semantic_search.py +926 -51
  60. package/src/core/dbPathService.ts +5 -0
  61. package/src/core/ntCore.ts +34 -3
  62. package/src/core/sqlcipherCore.ts +47 -11
  63. package/src/services/assistantEval.ts +3 -1
  64. package/src/services/assistantTools.ts +2471 -2452
  65. package/src/services/configService.ts +12 -3
  66. package/src/services/diagnosticReport.ts +237 -0
  67. package/src/services/exportService.ts +14 -2
  68. package/src/services/wechatClient.ts +418 -380
@@ -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';
@@ -324,7 +325,9 @@ program
324
325
  // 只读、纯本地、不调模型。**故意没有 mcp 那一栏**(D-062):它给的是关于第三方的推断
325
326
  // ——谁在被冷落、你会失去谁 —— 所以不进助手、不进 MCP、不喂模型。
326
327
  bonds: { cli: 'bonds --json', mcp: null, leavesTheMachine: false },
327
- diagnostics: { cli: 'check --json' },
328
+ // `check --report` 是同一份事实的**给人版**(已脱敏、可直接贴给 AI 或 issue),
329
+ // 所以它跟 `--json` 并列在这里:外部程序按这条能力去找"怎么拿诊断",不该只知道 JSON 那条。
330
+ diagnostics: { cli: 'check --json', report: 'check --report', leavesTheMachine: false },
328
331
  todos: { cli: 'todos list --json' },
329
332
  // 2026-09-29 三个界面对齐时新开的读取能力(D-058)。逐个写清它会不会出境,
330
333
  // 因为其中两个会——`fetch_article` 白名单 + 逐跳复检,`search_public` 没有任何守卫。
@@ -829,6 +832,26 @@ program
829
832
  configService.set('wxid', selectedWxid.wxid);
830
833
  // Step 3: 提取密钥
831
834
  console.log(chalk.yellow('\n步骤 3/3: 提取数据库解密密钥...'));
835
+ // **先看 Python 在不在**(Windows):它决定"读库"这一整步能不能做。
836
+ // 以前不看,于是 Python 不可用时还要先等 120 秒 hook 超时,然后把用户引向
837
+ // "退出微信重试"——而真正的原因(商店占位程序 / 缺 sqlcipher3)一个字都没提。
838
+ // 2026-10-19 用一台"PATH 里没有 python"的机器实测,2 分钟换来一条误导性建议。
839
+ let pythonUsable = process.platform !== 'win32';
840
+ if (process.platform === 'win32' && !opts.testMissingKeys) {
841
+ try {
842
+ const { execFileSync } = await import('node:child_process');
843
+ execFileSync(getPythonCommand(), ['--version'], { windowsHide: true, stdio: 'ignore', timeout: 5000 });
844
+ pythonUsable = true;
845
+ }
846
+ catch {
847
+ pythonUsable = false;
848
+ console.log(chalk.red(' ✗ 没有可用的 Python —— 读微信库这一步跑不起来'));
849
+ console.log(chalk.gray(' NT 数据库扫描与密钥派生都靠它(需要 Python 3.10+ 且能 import sqlcipher3)。'));
850
+ console.log(chalk.gray(' Windows 上最常见的原因是只装了应用商店的 python 占位程序:'));
851
+ console.log(chalk.gray(' 在终端里运行 `python --version`,能打出版本号才算装对。'));
852
+ console.log(chalk.gray(' 跑 `weflow-cli check --report` 可以看到完整判定(那段话可直接贴给 AI)。'));
853
+ }
854
+ }
832
855
  // 优先尝试从 WeFlow 配置读取
833
856
  console.log(chalk.gray(' 尝试从 WeFlow 桌面版配置读取...'));
834
857
  let extractedKey = opts.testMissingKeys ? '' : tryReadWeFlowKey();
@@ -898,6 +921,24 @@ program
898
921
  }
899
922
  }
900
923
  }
924
+ else if (!pythonUsable) {
925
+ // Python 不可用时**不去等 hook**:就算 hook 抓到了密钥,Step 4 的扫描/派生照样跑不起来,
926
+ // 而那 120 秒的等待只会把用户引向"退出微信重试"这种无效动作。
927
+ const fallback = configService.get('favPassphrase') || configService.get('decryptKey') || '';
928
+ if (fallback) {
929
+ console.log(chalk.gray(' 跳过 hook 等待:已配置的密钥会原样保留,但要先修好 Python 才读得动'));
930
+ extractedKey = fallback;
931
+ }
932
+ else {
933
+ console.log(chalk.red('\n✗ 这台机器现在无法读取微信数据库(缺 Python),因此没有可用密钥'));
934
+ console.log(chalk.gray('\n按顺序做:'));
935
+ console.log(chalk.gray(' 1) 装一个真正的 Python 3.10+,确认 `python --version` 能打出版本号'));
936
+ console.log(chalk.gray(' 2) `pip install sqlcipher3`'));
937
+ console.log(chalk.gray(' 3) 重新 `weflow-cli init`'));
938
+ console.log(chalk.gray(' 卡住时跑 `weflow-cli check --report`,那段文字可直接贴给 AI 或贴到 issue'));
939
+ process.exit(1);
940
+ }
941
+ }
901
942
  else if (process.platform !== 'win32') {
902
943
  // macOS:自动提取不可用,引导手动提供密钥
903
944
  console.log(chalk.yellow(' ⚠ 当前平台无法自动提取密钥(自动提取支持 Windows/Linux)'));
@@ -909,16 +950,24 @@ program
909
950
  process.exit(1);
910
951
  }
911
952
  else {
912
- // 如果微信未运行,提示启动
913
- if (!version) {
914
- console.log(chalk.cyan('\n ══════════════════════════════════════'));
915
- console.log(chalk.cyan(' 请现在启动微信 4.x 并登录!'));
916
- console.log(chalk.cyan(' ══════════════════════════════════════\n'));
953
+ // hook 只在**登录那一瞬**触发。所以要不要等,取决于微信现在在不在跑:
954
+ // · 已在运行 → 用户早就登录了,hook 永远不会再触发,等满 2 分钟纯属浪费
955
+ // (2026-10-19 用一个空配置实测:每次 init 都白等)
956
+ // · 没在运行 → 这正是 README「三分钟上手」第 2 步要求的姿势(关掉微信再 init,
957
+ // 然后按提示启动并登录)。人要打开客户端、等它起来、扫码/点确认,
958
+ // 30 秒根本不够,**必须留足**,否则按文档操作的人会被那句
959
+ // "hook 没抓到" 误导。所以就按文档给满 120 秒。
960
+ let keyResult = { success: false };
961
+ if (version) {
962
+ console.log(chalk.gray(' 微信已在运行:hook 只在登录那一瞬触发,不再等待,直接尝试扫描/派生'));
963
+ }
964
+ else {
965
+ console.log(chalk.cyan('\n 请现在启动微信并登录(登录那一瞬会自动抓取密钥)'));
966
+ console.log(chalk.cyan(' 最多等 2 分钟;不启动也没关系,之后会继续尝试 NT 扫描 / passphrase 派生\n'));
967
+ keyResult = await keyService.waitForKey(120000, (msg) => {
968
+ console.log(chalk.gray(` ${msg}`));
969
+ });
917
970
  }
918
- console.log(chalk.gray(' 等待微信进程出现(启动后会自动 hook)...'));
919
- const keyResult = await keyService.waitForKey(120000, (msg) => {
920
- console.log(chalk.gray(` ${msg}`));
921
- });
922
971
  if (keyResult.success && keyResult.key) {
923
972
  extractedKey = keyResult.key;
924
973
  keyCaptured = true;
@@ -936,12 +985,13 @@ program
936
985
  extractedKey = fallback;
937
986
  }
938
987
  else {
939
- console.log(chalk.red(`\n✗ 密钥提取失败: ${keyResult.error}`));
940
- console.log(chalk.gray('\n解决方案:'));
941
- console.log(chalk.gray(' 1. 退出微信后重新运行 init (hook 需在微信登录瞬间触发, init 会自动等待)'));
942
- console.log(chalk.gray(' 2. 或用第三方工具提取全库 passphrase 后: weflow-cli fav set-key --passphrase <64位hex>, 再重新 init 自动派生'));
943
- console.log(chalk.gray(' 3. 或手动指定密钥: weflow-cli config set decryptKey <64位密钥>'));
944
- process.exit(1);
988
+ // **不退出**:hook 只在"登录那一瞬"触发,而一个正常用户装完工具时微信早就登录了,
989
+ // 于是这条 120 秒的等待**必然**超时。以前这里 process.exit(1),用户就被卡在
990
+ // 「退出微信后重新运行 init」——而下一步(Step 4 的 NT 扫描 / 从 passphrase 派生)
991
+ // 才是新版本真正能拿到密钥的地方,却永远不会被执行(2026-10-19 用空配置实测)。
992
+ // 现在改成继续往下走:拿得到就成功,拿不到由结尾统一判"未完成"。
993
+ console.log(chalk.yellow('\n Hook 没抓到密钥(它只在登录那一瞬触发,已登录时不会再有)'));
994
+ console.log(chalk.gray(' 继续尝试 NT 扫描 / passphrase 派生 —— 这是新版本主要的取密钥方式...'));
945
995
  }
946
996
  }
947
997
  }
@@ -949,17 +999,27 @@ program
949
999
  configService.set('decryptKey', extractedKey);
950
1000
  if (keyCaptured)
951
1001
  console.log(chalk.green('\n✓ 密钥获取成功!'));
952
- else
1002
+ else if (pythonUsable)
953
1003
  console.log(chalk.green('\n✓ 已沿用配置中的密钥 (本次未重新捕获)'));
1004
+ // 缺 Python 时**不能说"已沿用"**:那句话在这里会被读成"密钥没问题",
1005
+ // 而下面紧接着就会说"密钥未就绪",两句自相矛盾(2026-10-19 实跑看到)。
1006
+ else
1007
+ console.log(chalk.yellow('\n· 配置里的密钥原样保留,但**这次验证不了**(缺 Python)——它可能仍然可用'));
954
1008
  }
955
1009
  else {
956
- console.log(chalk.green('\n✓ NT 密钥配置完成!'));
1010
+ // 空密钥时不能说"配置完成":下一句就是"密钥未就绪",两句打架。
1011
+ console.log(chalk.yellow('\n· 本次没有拿到密钥,继续尝试 NT 扫描 / passphrase 派生'));
957
1012
  }
958
1013
  // Step 4: 尝试扫描 NT 格式数据库 (xwechat_files)
959
1014
  // 即使检测到传统路径,也可能存在 NT 格式数据库
960
1015
  // 进入这里已经确认是 4.x 初始化流程。不要依赖步骤 0 的 version:
961
1016
  // 微信可能是在 init 等待期间才启动,初始 version 会保持为 null。
962
1017
  // Linux 分支已在步骤 3 完成配置,跳过。
1018
+ // 这次 init 到底有没有**取得可用的密钥** —— 只有它决定结尾能不能说"初始化完成"。
1019
+ // 扫描失败 + 沿用旧密钥 = 一次看着成功、实际什么也读不到的初始化:2026-10-18 一位用户
1020
+ // 的 init 打在 "NT scan failed (exit 9009)" 上(Python 是商店占位程序),结尾照样报
1021
+ // "初始化完成!"、"密钥: 已安全保存",然后 sessions 一条都没有。
1022
+ let ntReady = process.platform === 'linux'; // Linux 分支已在步骤 3 配好
963
1023
  if (process.platform !== 'linux') {
964
1024
  console.log(chalk.yellow('\n步骤 4/4: 扫描 NT 格式数据库...'));
965
1025
  console.log(chalk.gray(' 正在从微信内存中匹配数据库密钥...'));
@@ -974,6 +1034,7 @@ program
974
1034
  primaryDb = ntResult.matched.sort((a, b) => b.size - a.size)[0];
975
1035
  }
976
1036
  if (primaryDb) {
1037
+ ntReady = true;
977
1038
  configService.set('ntDbPath', primaryDb.path);
978
1039
  configService.set('ntKey', primaryDb.key);
979
1040
  configService.set('ntSalt', primaryDb.salt);
@@ -1019,6 +1080,7 @@ program
1019
1080
  derivedMessage = await applyDerivedNtKeys(passphrase, deriveDbs, (l) => console.log(l));
1020
1081
  }
1021
1082
  if (derivedMessage) {
1083
+ ntReady = true;
1022
1084
  console.log(chalk.green(' ✓ 派生模式初始化成功 (聊天/联系人/朋友圈库已配置)'));
1023
1085
  }
1024
1086
  else {
@@ -1037,22 +1099,38 @@ program
1037
1099
  }
1038
1100
  }
1039
1101
  console.log(chalk.cyan('\n=============================='));
1040
- console.log(chalk.cyan('初始化完成! (4.x 模式)'));
1102
+ // 只有**这次真的拿到可用密钥**才叫"完成"。否则说清楚"没就绪"以及下一步做什么 ——
1103
+ // 报"完成"再让 sessions 空着,是最费用户时间的失败方式(他会以为是自己微信的问题)。
1104
+ console.log(chalk[ntReady ? 'cyan' : 'yellow'](ntReady ? '初始化完成! (4.x 模式)' : '初始化未完成:数据库密钥没有就绪'));
1041
1105
  console.log(chalk.cyan('=============================='));
1042
1106
  console.log(chalk.white(`数据目录: ${detected.path}`));
1043
1107
  console.log(chalk.white(`账号: ${selectedWxid.wxid}${selectedWxid.nickname ? ` (${selectedWxid.nickname})` : ''}`));
1044
- const displayKey = extractedKey || linuxNtKey;
1045
- console.log(chalk.white('密钥: 已安全保存'));
1108
+ console.log(chalk.white(`密钥: ${ntReady ? '已安全保存' : '未就绪(配置里的旧密钥未必能解开这个库)'}`));
1046
1109
  const ntDbPath = configService.get('ntDbPath');
1047
1110
  if (ntDbPath) {
1048
1111
  console.log(chalk.white(`NT 数据库: ${ntDbPath}`));
1049
1112
  }
1113
+ if (!ntReady) {
1114
+ console.log(chalk.yellow('\n现在 sessions / messages 会读不到数据。按顺序做这三步:'));
1115
+ console.log(chalk.yellow(' 1) 在这个终端里运行 `python --version`,要能打出版本号'));
1116
+ console.log(chalk.yellow(' (Windows 上最常见的问题是只装了商店的 python 占位程序)'));
1117
+ console.log(chalk.yellow(' 2) `pip install sqlcipher3`'));
1118
+ console.log(chalk.yellow(' 3) `weflow-cli config forget-keys`,然后重新 `weflow-cli init`'));
1119
+ // 这三步是"最常见的原因",不是诊断结论。真卡住时让用户拿到一份**已经判定过**的事实,
1120
+ // 而不是再让他把现象口头描述一遍(那正是 issue #3/#9/#10 每次都要重来一遍的原因)。
1121
+ console.log(chalk.yellow('\n想先看清到底卡在哪一步,运行:'));
1122
+ console.log(chalk.cyan(' weflow-cli check --report'));
1123
+ console.log(chalk.gray(' 它会给出结论(Python / sqlcipher3 / 密钥 / 数据目录),那段文字可以直接复制给 AI 或贴到 issue。'));
1124
+ }
1125
+ // 未就绪时说"现在可以使用"是骗人的:那几条命令都会返回空。
1126
+ console.log(chalk.gray(ntReady
1127
+ ? '\n现在可以使用以下命令:'
1128
+ : '\n修好上面那几步之后,这些命令才会返回数据:'));
1129
+ console.log(chalk.gray(' weflow-cli sessions 查看会话列表'));
1130
+ console.log(chalk.gray(' weflow-cli messages <talker> 查看消息'));
1131
+ console.log(chalk.gray(' weflow-cli contacts 查看联系人'));
1132
+ console.log(chalk.gray(' weflow-cli export <talker> <format> 导出聊天记录'));
1050
1133
  }
1051
- console.log(chalk.gray('\n现在可以使用以下命令:'));
1052
- console.log(chalk.gray(' weflow-cli sessions 查看会话列表'));
1053
- console.log(chalk.gray(' weflow-cli messages <talker> 查看消息'));
1054
- console.log(chalk.gray(' weflow-cli contacts 查看联系人'));
1055
- console.log(chalk.gray(' weflow-cli export <talker> <format> 导出聊天记录'));
1056
1134
  });
1057
1135
  // ==================== config ====================
1058
1136
  const configCmd = program
@@ -1063,6 +1141,7 @@ const configurableKeys = [
1063
1141
  'ntDbPath', 'ntKey', 'ntSalt', 'contactDbPath', 'contactKey', 'contactSalt',
1064
1142
  'vaultRepo', 'aiEngine', 'aiBaseUrl', 'aiModel', 'deepseekApiKey', 'typesafeApiKey',
1065
1143
  'dashscopeApiKey', 'favPassphrase',
1144
+ 'judgeProvider', 'openrouterApiKey', 'openrouterJudgeModel',
1066
1145
  'wereadApiKey',
1067
1146
  'assistantPrivacy', 'assistantWhitelist', 'assistantGroupWhitelist',
1068
1147
  'assistantGroupRequireMention', 'assistantFastRoute', 'assistantPanelUser', 'quickReplyContacts',
@@ -1326,6 +1405,12 @@ program
1326
1405
  return;
1327
1406
  }
1328
1407
  console.log(chalk.gray('未找到会话'));
1408
+ // "0 条"有两种完全不同的原因:真的没有会话,和**初始化/密钥坏了读不出来**。
1409
+ // 后者以前只显示这一句,用户就去猜"是不是我微信坏了 / 版本太新"(issue #3/#9/#10 的老路:
1410
+ // 同一个根因被描述成三种现象)。给一条能立刻分清的动作,比让他猜有用。
1411
+ console.log(chalk.yellow('\n如果微信里明明有聊天记录,那多半是读取这一步没成功。运行这条看结论:'));
1412
+ console.log(chalk.cyan(' weflow-cli check --report'));
1413
+ console.log(chalk.gray(' 它会指出卡在哪一步(Python / sqlcipher3 / 密钥 / 数据目录),那段文字可直接复制给 AI 或贴到 issue。'));
1329
1414
  return;
1330
1415
  }
1331
1416
  if (opts.json) {
@@ -1902,8 +1987,12 @@ program
1902
1987
  }
1903
1988
  // 图片要靠 Python 那条路匹配(缓存缩略图、资源 md5、原图那档)。`--media` 就是"先抽一次",
1904
1989
  // 抽完 json/txt/excel 各自消费同一份清单 —— 匹配只跑一遍,也不会在 TS 里重写一遍。
1990
+ // 抽取那一步的媒体摘要留在这里:`--media` 的调用者(机器调用)应当能从结果里直接读到
1991
+ // "各状态多少条、报告叫什么",而不必去翻 Python 的 stdout。
1992
+ let extracted;
1905
1993
  if (opts.media && (format === 'json' || format === 'txt' || format === 'excel')) {
1906
1994
  const media = await exportService.extractMedia(talker, opts.output, !!opts.json, { fullImages: opts.fullImages === true }, limit, opts.date || '');
1995
+ extracted = media;
1907
1996
  if (!media.success) {
1908
1997
  if (opts.json)
1909
1998
  console.log(JSON.stringify({ success: false, code: 'MEDIA_EXPORT_FAILED', error: media.error || '媒体抽取失败' }));
@@ -1940,7 +2029,16 @@ program
1940
2029
  }
1941
2030
  if (result?.success) {
1942
2031
  if (opts.json)
1943
- console.log(JSON.stringify({ success: true, format, contract: format === 'json' ? opts.contract : null, path: result.path, count: result.count ?? null }, null, 2));
2032
+ console.log(JSON.stringify({
2033
+ success: true,
2034
+ format,
2035
+ contract: format === 'json' ? opts.contract : null,
2036
+ path: result.path,
2037
+ count: result.count ?? null,
2038
+ // 媒体摘要:html/md 那条由导出自己产出(Python 的 media_json_summary),
2039
+ // json/txt/excel 的 `--media` 则由上面那次抽取产出 —— 两条都是同一份词汇。
2040
+ media: result.media ?? extracted?.media ?? null,
2041
+ }, null, 2));
1944
2042
  else
1945
2043
  console.log(chalk.green(`✓ 导出成功: ${result.path}`));
1946
2044
  }
@@ -5200,6 +5298,14 @@ program
5200
5298
  .option('--top-k <n>', '返回数量', '10')
5201
5299
  .option('--line <line>', 'wiki=文章线(默认) / chat=聊天记录 / all=两条都搜', 'wiki')
5202
5300
  .option('--no-rerank', '不调用决策模型重排,只按向量/关键词相似度返回(回退到引入重排之前)')
5301
+ .option('--no-hybrid', '不融合本地 BM25 名次(默认融合;门控后实测只增不减)')
5302
+ .option('--rerank-pool <n>', '重排看多少条候选(默认 100;越大越准也越贵)')
5303
+ .option('--no-dedup', '不折叠同文本的多号转载(默认折叠,结果里带 duplicateCount)')
5304
+ .option('--with-text [n]', '给每条命中挂上本地正文(fullText / sourceFile / url);n 为每条最多多少字')
5305
+ .option('--fetch-missing [n]', '正文太薄时按 url 抓一次(最多 n 条,默认 3;复用日报缓存)')
5306
+ .option('--mmr', '对结果做 MMR 去冗余(压掉近似的同领域邻居,可能牺牲单点召回)')
5307
+ .option('--from <account>', '只查这个公众号的文章(按文件名匹配,不走语义)')
5308
+ .option('--phrase', '把这串当**原样短语**在本地定位(FTS5/trigram,不走向量、不出境、不花钱):回答"哪个号在哪天原样写过它"')
5203
5309
  .option('--api-key <key>', 'DashScope embedding API key')
5204
5310
  .option('--dry-run', '仅预览,不读取索引或调用向量服务')
5205
5311
  .option('--yes', '确认执行可能调用云端向量服务的搜索')
@@ -5211,25 +5317,70 @@ program
5211
5317
  const pkgRoot = resolvePackageRoot();
5212
5318
  const script = join(pkgRoot, 'scripts', 'semantic_search.py');
5213
5319
  const topK = parseCliInteger(opts.topK, 'top-k', 1, 100, opts.json);
5320
+ // 没传就**不转发**(默认值只在 Python 侧定义一处):commander 对未提供的选项给 undefined,
5321
+ // 直接 parseCliInteger 会被自己的校验判成非法参数 —— 实测就是这么红的。
5322
+ const rerankPool = opts.rerankPool
5323
+ ? parseCliInteger(opts.rerankPool, 'rerank-pool', 5, 500, opts.json) : null;
5214
5324
  requireCliChoice(opts.line, ['wiki', 'chat', 'all'], 'line', opts.json);
5325
+ // `--phrase` 走的是**纯本地**那条路(FTS5):不出境、不花钱,所以它既不需要向量 key,
5326
+ // 也不需要确认门。把它当成"向量搜索的降级"会让人以为它也花钱、也发数据。
5327
+ const phrase = opts.phrase === true;
5328
+ // 下面这些旗标只属于**向量检索**那条路;`--phrase`(FTS5 原样定位)一个都用不上。
5329
+ // 以前它们会被原样转发,然后在 Python 的 phrase 分支里被**静默忽略** —— 用户以为自己在调参,
5330
+ // 实际上什么都没发生。这类"接受但不生效"的开关比报错更贵:它会让人把默认行为当成调过的结果。
5331
+ if (phrase) {
5332
+ const irrelevant = [];
5333
+ if (opts.rerank === false)
5334
+ irrelevant.push('--no-rerank');
5335
+ if (opts.hybrid === false)
5336
+ irrelevant.push('--no-hybrid');
5337
+ if (rerankPool != null)
5338
+ irrelevant.push('--rerank-pool');
5339
+ if (opts.dedup === false)
5340
+ irrelevant.push('--no-dedup');
5341
+ if (opts.withText)
5342
+ irrelevant.push('--with-text');
5343
+ if (opts.fetchMissing)
5344
+ irrelevant.push('--fetch-missing');
5345
+ if (opts.mmr)
5346
+ irrelevant.push('--mmr');
5347
+ if (irrelevant.length) {
5348
+ const error = `--phrase 只做原样定位,不接受 ${irrelevant.join('、')}(它们只对向量检索有意义)`;
5349
+ if (opts.json)
5350
+ console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error }));
5351
+ else
5352
+ console.log(chalk.red(error));
5353
+ process.exit(1);
5354
+ }
5355
+ }
5215
5356
  const preview = {
5216
5357
  success: true,
5217
5358
  dryRun: true,
5218
5359
  action: 'semantic-search.query',
5360
+ phrase,
5219
5361
  topK,
5220
5362
  line: opts.line,
5221
5363
  noRerank: opts.rerank === false,
5364
+ noHybrid: opts.hybrid === false,
5365
+ rerankPool,
5366
+ noDedup: opts.dedup === false,
5367
+ withText: opts.withText || null,
5368
+ fetchMissing: opts.fetchMissing || null,
5369
+ mmr: opts.mmr === true,
5370
+ from: opts.from || null,
5222
5371
  readsLocalIndex: true,
5223
- mayUseCloudEmbedding: true,
5372
+ mayUseCloudEmbedding: !phrase,
5224
5373
  };
5225
5374
  if (opts.dryRun) {
5226
5375
  if (opts.json)
5227
5376
  console.log(JSON.stringify(preview));
5228
5377
  else
5229
- console.log(chalk.cyan('预览:将读取本地语义索引,并可能把查询发送到已配置的向量服务。'));
5378
+ console.log(chalk.cyan(phrase
5379
+ ? '预览:原样短语定位 —— 只读本地短语索引,不联网、不花钱。'
5380
+ : '预览:将读取本地语义索引,并可能把查询发送到已配置的向量服务。'));
5230
5381
  return;
5231
5382
  }
5232
- if (!opts.yes) {
5383
+ if (!phrase && !opts.yes) {
5233
5384
  if (opts.json) {
5234
5385
  console.log(JSON.stringify({ ...preview, success: false, dryRun: false, code: 'CONFIRMATION_REQUIRED' }));
5235
5386
  process.exit(1);
@@ -5249,7 +5400,16 @@ program
5249
5400
  // 接这条通道之前,`OPERATIONS.md` 一直在教用户敲 `search --no-rerank`,而 CLI 会答 `unknown option`
5250
5401
  // —— 同一份文档里 `--line` 是通的,所以它看起来像"能用的旗标"(2026-10-11 由文档命令面那道检查发现)。
5251
5402
  const args = [script, 'search', '--top-k', String(topK), '--line', opts.line,
5252
- ...(opts.rerank === false ? ['--no-rerank'] : [])];
5403
+ ...(phrase ? ['--phrase'] : []),
5404
+ ...(opts.rerank === false ? ['--no-rerank'] : []),
5405
+ ...(opts.hybrid === false ? ['--no-hybrid'] : []),
5406
+ ...(opts.from ? ['--from', opts.from] : []),
5407
+ ...(rerankPool ? ['--rerank-pool', String(rerankPool)] : []),
5408
+ ...(opts.dedup === false ? ['--no-dedup'] : []),
5409
+ ...(opts.withText ? ['--with-text', opts.withText === true ? '4000' : String(opts.withText)] : []),
5410
+ ...(opts.fetchMissing ? ['--fetch-missing',
5411
+ opts.fetchMissing === true ? '3' : String(opts.fetchMissing)] : []),
5412
+ ...(opts.mmr ? ['--mmr'] : [])];
5253
5413
  try {
5254
5414
  const { stdout } = await execFileAsync(getPythonCommand(), args, {
5255
5415
  timeout: 60_000, maxBuffer: 10 * 1024 * 1024,
@@ -5269,6 +5429,7 @@ program
5269
5429
  .command('search-index')
5270
5430
  .description('构建语义搜索索引')
5271
5431
  .option('--full', '全量重建')
5432
+ .option('--phrase', '只建**精确短语索引**(本地 FTS5/trigram,不调用向量服务、不花钱、不出境)')
5272
5433
  .option('--api-key <key>', 'DashScope embedding API key')
5273
5434
  .option('--days <n>', '聊天记录收最近多少天(默认 90)')
5274
5435
  .option('--article-days <n>', '日报文章收最近多少个日期目录(默认 30)')
@@ -5276,6 +5437,36 @@ program
5276
5437
  .option('--yes', '确认构建或重建索引')
5277
5438
  .option('--json', '输出 JSON 格式')
5278
5439
  .action(async (opts) => {
5440
+ // `--phrase`:短语索引那条路完全不碰向量服务(也不碰聊天库——它从语义索引的 meta.json
5441
+ // 派生),所以它不走窗口参数、不需要确认门,也不校验 key。混进下面那条路会让用户以为
5442
+ // "建个本地的检索也要花 vector 的钱"。
5443
+ if (opts.phrase) {
5444
+ const phrasePreview = {
5445
+ success: true, dryRun: true, action: 'search-index.build-phrase',
5446
+ readsLocalData: true, usesCloudEmbedding: false, rebuildsPhraseIndex: true,
5447
+ };
5448
+ if (opts.dryRun) {
5449
+ if (opts.json)
5450
+ console.log(JSON.stringify(phrasePreview));
5451
+ else
5452
+ console.log(chalk.cyan('短语索引预览:全量重建本地 FTS5 索引(含两条线),不联网、不花钱。'));
5453
+ return;
5454
+ }
5455
+ const { execFile } = await import('child_process');
5456
+ const { promisify } = await import('util');
5457
+ const execFileAsync = promisify(execFile);
5458
+ const pkgRoot = resolvePackageRoot();
5459
+ const script = join(pkgRoot, 'scripts', 'semantic_search.py');
5460
+ try {
5461
+ const { stdout } = await execFileAsync(getPythonCommand(), [script, 'phrase-build', '--line', 'all'], { timeout: 600_000, maxBuffer: 10 * 1024 * 1024, env: pythonProcessEnv() });
5462
+ console.log(opts.json ? stdout.trim() : stdout);
5463
+ }
5464
+ catch (e) {
5465
+ console.error(chalk.red(`\n✗ ${safeSubprocessError(e, '建短语索引失败')}`));
5466
+ process.exit(1);
5467
+ }
5468
+ return;
5469
+ }
5279
5470
  // 窗口的**运行值由这里说了算**:默认值 + 校验 + 透传都在这一处,预览报的就是真跑的那些
5280
5471
  // (脚本文档里另有自己的默认值,只供手动直接跑脚本时用;CLI 这条路永远显式传参,
5281
5472
  // 所以两边万一漂了也不会影响实际行为)。
@@ -5333,7 +5524,10 @@ program
5333
5524
  args.push('--full');
5334
5525
  try {
5335
5526
  const { stdout } = await execFileAsync(getPythonCommand(), args, {
5336
- timeout: 300_000, maxBuffer: 10 * 1024 * 1024,
5527
+ // 30 分钟:全量重建实测要跑二十分钟以上(16k 条 / 1500+ 批),而这里原来给的是 5 分钟 --
5528
+ // 那个值让 `--full` 在真实索引上**从来没跑完过**(超时被杀;且脚本改成了边嵌边落盘之前,
5529
+ // 中途被杀等于连已经嵌好的那部分也一起丢)。
5530
+ timeout: 1_800_000, maxBuffer: 10 * 1024 * 1024,
5337
5531
  env: pythonProcessEnv(opts.apiKey, 'DASHSCOPE_API_KEY'),
5338
5532
  });
5339
5533
  if (opts.json)
@@ -6424,12 +6618,14 @@ program
6424
6618
  .option('--source <name>', '仅处理指定公众号,可重复或用逗号分隔', (value, previous = []) => [...previous, value], [])
6425
6619
  .description('一键生成公众号日报(抓取 + AI 摘要 + HTML 阅读器 + 行动建议)')
6426
6620
  .option('-d, --date <YYYY-MM-DD>', '日期')
6621
+ .option('--yesterday', '跑昨天(等价于 --date 昨天)。公众号发到 24:00 才停,所以当晚跑的那一天永远不全;"跑昨天"与时刻无关,计划任务用这个')
6427
6622
  .option('--api-key <key>', 'DeepSeek API key(或设环境变量 DEEPSEEK_API_KEY)')
6428
6623
  .option('--skip-classify', '跳过后处理')
6429
6624
  .option('--dry-run', '仅预览文章,不调用 AI 或写入日报')
6430
6625
  .option('--yes', '确认执行日报生成;JSON 模式需要此选项')
6431
6626
  .option('--no-ai', '关闭本次日报的所有 AI 处理')
6432
6627
  .option('--no-summary', '只判断不生成:不调 LLM 写摘要/标签/简报(主题与相关度仍由 Jev 判断),无需 DeepSeek key')
6628
+ .option('--prefetch-bodies [n]', '抓取之前先并发把当天正文抓进缓存(n=并发数,默认 8):抓取阶段于是全部命中缓存、不再逐篇 sleep 8-12 秒(450 篇约 75 分钟 → 秒级)。对上游的请求次数不变,只是压进更短的时间窗')
6433
6629
  .option('--json', '输出机器可读的最终结果;运行日志写入 stderr')
6434
6630
  .action(async (opts) => {
6435
6631
  const { spawn } = await import('child_process');
@@ -6439,7 +6635,18 @@ program
6439
6635
  const bizDaily = join(pkgRoot, 'scripts', 'biz_daily.py');
6440
6636
  const now = new Date();
6441
6637
  const localDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
6442
- const date = opts.date || localDate;
6638
+ const isoDate = (when) => `${when.getFullYear()}-${String(when.getMonth() + 1).padStart(2, '0')}-${String(when.getDate()).padStart(2, '0')}`;
6639
+ if (opts.date && opts.yesterday) {
6640
+ const error = '--date 与 --yesterday 不能同时给(前者是明确的一天,后者是"昨天")';
6641
+ if (opts.json)
6642
+ console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', field: 'date', error }));
6643
+ else
6644
+ console.log(chalk.red(error));
6645
+ process.exit(1);
6646
+ }
6647
+ const date = opts.date || (opts.yesterday
6648
+ ? isoDate(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
6649
+ : localDate);
6443
6650
  const apiKey = opts.apiKey || process.env.DEEPSEEK_API_KEY || '';
6444
6651
  const noAi = opts.ai === false || configService.get('dailyAiEnabled') === 'false';
6445
6652
  const dailyLog = (...values) => opts.json ? console.error(...values) : console.log(...values);
@@ -6472,6 +6679,14 @@ program
6472
6679
  args.push('--source', source);
6473
6680
  if (opts.skipClassify)
6474
6681
  args.push('--skip-classify');
6682
+ // 只在**给了**这个旗标时才解析数字:`--prefetch-bodies`(不带数字)是合法的,
6683
+ // 而默认并发数只在 python 侧写一份(这里再写一份迟早漂移)。
6684
+ if (opts.prefetchBodies !== undefined) {
6685
+ args.push('--prefetch-bodies');
6686
+ if (opts.prefetchBodies !== true) {
6687
+ args.push(String(parseCliInteger(opts.prefetchBodies, 'prefetch-bodies', 1, 64, !!opts.json)));
6688
+ }
6689
+ }
6475
6690
  dailyLog(chalk.cyan(`\n📰 正在生成 ${targetDate} 公众号日报${noAi ? '(AI 已关闭)' : ''}...\n`));
6476
6691
  const child = spawn(getPythonCommand(), args, {
6477
6692
  windowsHide: true,
@@ -7664,6 +7879,7 @@ program
7664
7879
  .command('check')
7665
7880
  .description('检查运行环境(Python、pip 依赖、数据库配置)')
7666
7881
  .option('--json', '输出 JSON 格式,不包含数据库路径或密钥')
7882
+ .option('--report', '输出一段可直接粘贴给 AI / 贴进 issue 的诊断结论(已脱敏:用户名与 wxid 换占位符,不含密钥与聊天内容)')
7667
7883
  .action(async (opts) => {
7668
7884
  // 微信客户端的**版本**与它自报的**数据目录**:两个分支都要用,所以在这里算一次。
7669
7885
  // 判据见 `wechatClient.ts`(版本取活进程的 `--user-lib-dir`;取不到就是"未知",不是"没问题")。
@@ -7687,7 +7903,9 @@ program
7687
7903
  const dataDirMatchesConfig = (client.dataDirs.length && configuredRoot)
7688
7904
  ? client.dataDirs.some(dir => samePath(dir, configuredRoot))
7689
7905
  : null;
7690
- if (opts.json) {
7906
+ // `--json`(给机器)与 `--report`(给人/AI)用的是**同一份事实**,所以只探测一次:
7907
+ // 两个分支各写一份探测,迟早会出现"JSON 说 Python 可用、报告说不可用"这种自相矛盾。
7908
+ const collectCheckData = async () => {
7691
7909
  const { execFileSync } = await import('child_process');
7692
7910
  const requiredDependencies = ['sqlcipher3', 'html2text', 'zstandard', 'cryptography'];
7693
7911
  const optionalDependencies = ['scrapling'];
@@ -7765,7 +7983,43 @@ program
7765
7983
  caveats: client.caveats,
7766
7984
  },
7767
7985
  };
7768
- console.log(JSON.stringify(data, null, 2));
7986
+ return data;
7987
+ };
7988
+ if (opts.json) {
7989
+ console.log(JSON.stringify(await collectCheckData(), null, 2));
7990
+ return;
7991
+ }
7992
+ // `--report`:一段**可整段复制**的诊断块(脱敏后)。这是"用户说不清、我们又猜不准"
7993
+ // 那个循环的出口 —— issue #3/#9/#10 与 2026-10-18 那封邮件都是同一个根因被描述成四种现象。
7994
+ if (opts.report) {
7995
+ const data = await collectCheckData();
7996
+ console.log(renderDiagnosticReport({
7997
+ cliVersion: CLI_VERSION,
7998
+ // 本地时间:用户对照日志/截图时要能直接对上,UTC 会多一步换算
7999
+ generatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
8000
+ platform: process.platform,
8001
+ arch: process.arch,
8002
+ nodeVersion: process.versions.node,
8003
+ python: {
8004
+ command: getPythonCommand(),
8005
+ available: !!data.runtime.python.available,
8006
+ version: String(data.runtime.python.version || ''),
8007
+ },
8008
+ dependencies: data.dependencies,
8009
+ configuration: data.configuration,
8010
+ keys: {
8011
+ messageKeyConfigured: !!configService.get('ntKey') || !!configService.get('decryptKey'),
8012
+ passphraseConfigured: !!configService.get('favPassphrase'),
8013
+ },
8014
+ wechatClient: {
8015
+ running: data.wechatClient.running,
8016
+ version: data.wechatClient.version,
8017
+ note: data.wechatClient.note,
8018
+ dataDirMatchesConfig: data.wechatClient.dataDirMatchesConfig,
8019
+ dataDirs: data.wechatClient.dataDirs,
8020
+ unusableVersionDirs: data.wechatClient.unusableVersionDirs,
8021
+ },
8022
+ }));
7769
8023
  return;
7770
8024
  }
7771
8025
  console.log(chalk.cyan('🔍 WeFlow CLI 环境检查\n'));
@@ -7842,6 +8096,17 @@ program
7842
8096
  const stat = (await import('fs')).statSync(path);
7843
8097
  const mb = (stat.size / 1024 / 1024).toFixed(0);
7844
8098
  console.log(chalk.white(` ${label}: ${path} (${mb}MB)`));
8099
+ // 微信"存储位置"设到别的盘时,用户目录下那个是**junction**:显示的是 C 盘路径,
8100
+ // 数据其实在 D 盘。不点出来的话,用户会以为配置指错了地方(2026-10-19 实测:
8101
+ // `C:\Users\…\xwechat_files` → `D:\微信\xwechat_files`)。
8102
+ try {
8103
+ const { realpathSync } = await import('fs');
8104
+ const real = realpathSync(path);
8105
+ if (real.toLowerCase() !== path.toLowerCase()) {
8106
+ console.log(chalk.gray(` ↳ 软链指向 ${real}`));
8107
+ }
8108
+ }
8109
+ catch { /* 解析不了就不说,别拿它当错误 */ }
7845
8110
  }
7846
8111
  else {
7847
8112
  const hint = keyRequired ? ' (可选, init 或 sns/fav 命令配置)' : '';