weflow-cli 1.16.0 → 1.19.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 (81) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +19 -4
  3. package/bin/weflow-cli.ts +515 -6
  4. package/dist/bin/weflow-cli.js +580 -8
  5. package/dist/bin/weflow-cli.js.map +1 -1
  6. package/dist/src/core/ntCore.d.ts +47 -0
  7. package/dist/src/core/ntCore.d.ts.map +1 -1
  8. package/dist/src/core/ntCore.js +24 -0
  9. package/dist/src/core/ntCore.js.map +1 -1
  10. package/dist/src/services/assistantService.d.ts +8 -1
  11. package/dist/src/services/assistantService.d.ts.map +1 -1
  12. package/dist/src/services/assistantService.js +47 -8
  13. package/dist/src/services/assistantService.js.map +1 -1
  14. package/dist/src/services/assistantTools.d.ts +8 -0
  15. package/dist/src/services/assistantTools.d.ts.map +1 -1
  16. package/dist/src/services/assistantTools.js +67 -11
  17. package/dist/src/services/assistantTools.js.map +1 -1
  18. package/dist/src/services/configService.d.ts.map +1 -1
  19. package/dist/src/services/configService.js +3 -2
  20. package/dist/src/services/configService.js.map +1 -1
  21. package/dist/src/services/cursorProbe.d.ts +44 -0
  22. package/dist/src/services/cursorProbe.d.ts.map +1 -0
  23. package/dist/src/services/cursorProbe.js +183 -0
  24. package/dist/src/services/cursorProbe.js.map +1 -0
  25. package/dist/src/services/diagnosticReport.d.ts +6 -0
  26. package/dist/src/services/diagnosticReport.d.ts.map +1 -1
  27. package/dist/src/services/diagnosticReport.js +16 -0
  28. package/dist/src/services/diagnosticReport.js.map +1 -1
  29. package/dist/src/services/qchatImport.d.ts +89 -0
  30. package/dist/src/services/qchatImport.d.ts.map +1 -0
  31. package/dist/src/services/qchatImport.js +273 -0
  32. package/dist/src/services/qchatImport.js.map +1 -0
  33. package/dist/src/services/setupVenv.d.ts +43 -0
  34. package/dist/src/services/setupVenv.d.ts.map +1 -0
  35. package/dist/src/services/setupVenv.js +198 -0
  36. package/dist/src/services/setupVenv.js.map +1 -0
  37. package/dist/src/services/wechatCompat.d.ts +29 -0
  38. package/dist/src/services/wechatCompat.d.ts.map +1 -0
  39. package/dist/src/services/wechatCompat.js +65 -0
  40. package/dist/src/services/wechatCompat.js.map +1 -0
  41. package/dist/src/utils/python.d.ts +10 -0
  42. package/dist/src/utils/python.d.ts.map +1 -1
  43. package/dist/src/utils/python.js +27 -4
  44. package/dist/src/utils/python.js.map +1 -1
  45. package/docs/DATA_CONTRACT.md +25 -0
  46. package/docs/DECISIONS.md +118 -30
  47. package/docs/NT_DATABASES.md +47 -0
  48. package/docs/PROJECT_STATE.md +7 -3
  49. package/docs/RELEASING.md +4 -0
  50. package/docs/REPLAY.md +169 -0
  51. package/docs/SYNC_CONTRACT.md +197 -180
  52. package/package.json +89 -88
  53. package/resources/js/graph3d/viewer2d.js +550 -448
  54. package/scripts/annual_report.py +3 -1
  55. package/scripts/chat_notes.py +86 -3
  56. package/scripts/check_readme_commands.py +427 -426
  57. package/scripts/extract_todos.py +3 -1
  58. package/scripts/graph_2d.py +156 -151
  59. package/scripts/graph_3d.py +672 -646
  60. package/scripts/imported_chats.py +68 -0
  61. package/scripts/mcp_bridge.py +3 -1
  62. package/scripts/media_index.py +150 -0
  63. package/scripts/nt_common.py +32 -0
  64. package/scripts/nt_decrypt.py +154 -16
  65. package/scripts/on_this_day.py +178 -178
  66. package/scripts/promises.py +185 -185
  67. package/scripts/replay.py +803 -0
  68. package/scripts/run_hidden.vbs +25 -0
  69. package/scripts/semantic_search.py +78 -6
  70. package/scripts/wechat_avatar.py +185 -0
  71. package/scripts/wechat_ledger.py +190 -0
  72. package/src/core/ntCore.ts +59 -0
  73. package/src/services/assistantService.ts +48 -8
  74. package/src/services/assistantTools.ts +73 -10
  75. package/src/services/configService.ts +3 -2
  76. package/src/services/cursorProbe.ts +222 -0
  77. package/src/services/diagnosticReport.ts +22 -0
  78. package/src/services/qchatImport.ts +327 -0
  79. package/src/services/setupVenv.ts +238 -0
  80. package/src/services/wechatCompat.ts +67 -0
  81. package/src/utils/python.ts +33 -4
@@ -7,6 +7,9 @@ import { existsSync, readFileSync, statSync, writeFileSync } from 'fs';
7
7
  import { homedir } from 'os';
8
8
  import { SYNC_SCHEMA, SYNC_SOURCE, syncStateStore } from '../src/services/syncState.js';
9
9
  import { SyncRangeRequiredError, runSync } from '../src/services/syncService.js';
10
+ import { probeCursorReadiness } from '../src/services/cursorProbe.js';
11
+ import { planSetup, runSetup } from '../src/services/setupVenv.js';
12
+ import { planImportQchat, runImportQchat, listImports, readImportedSession } from '../src/services/qchatImport.js';
10
13
  import { dbPathService } from '../src/core/dbPathService.js';
11
14
  import { keyService } from '../src/core/keyService.js';
12
15
  import { NtCore } from '../src/core/ntCore.js';
@@ -28,6 +31,7 @@ import { applyDerivedNtKeys, enableFavorites, detectFavDbPath } from '../src/ser
28
31
  import { disabledIds, readSkillBody, scanSkills, skillRoots, skillState } from '../src/services/assistantSkills.js';
29
32
  import { assistantScenes, validateSceneId } from '../src/services/assistantScenes.js';
30
33
  import { detectWechatClient, reportedDataDirs, samePath } from '../src/services/wechatClient.js';
34
+ import { WECHAT_VERIFIED, compareWechatVersions, describeCompatVerdict } from '../src/services/wechatCompat.js';
31
35
  const program = new Command();
32
36
  function resolvePackageRoot() {
33
37
  return resolvePackageRootFrom(import.meta.url);
@@ -285,6 +289,14 @@ program
285
289
  const data = {
286
290
  schema: 'weflow-capabilities/v1',
287
291
  version: CLI_VERSION,
292
+ // D-130:本包最后用真实数据验证过的微信版本(单一事实源 wechatCompat.ts)。
293
+ // check 的判定也从同一处读;发布清单要求 CHANGELOG 每版记录(来源就是这里)。
294
+ compatibility: {
295
+ wechat: {
296
+ verifiedVersion: WECHAT_VERIFIED.version,
297
+ verifiedAt: WECHAT_VERIFIED.verifiedAt,
298
+ },
299
+ },
288
300
  read: {
289
301
  sessions: { cli: 'sessions --json', mcp: 'wechat.list_sessions' },
290
302
  messages: { cli: 'messages <talker> --json', mcp: 'wechat.export_messages' },
@@ -295,7 +307,30 @@ program
295
307
  versionedContract: 'weflow-message/v1',
296
308
  rawContractPreserved: true,
297
309
  coverage: ['requestedFrom', 'requestedTo', 'requestedLimit', 'returned', 'mayHaveMore', 'oldestCreateTime', 'newestCreateTime'],
298
- incrementalRead: { mode: 'overlapping-time-window', stableCursor: false },
310
+ incrementalRead: {
311
+ mode: 'overlapping-time-window',
312
+ stableCursor: false,
313
+ // D-027 的另一半:光写 false 是句结论,这里把 false → true 的**路径**
314
+ // 摊开。probe 指向的那条命令会实测 NT 数据侧判据(采样有界、结果里
315
+ // 自带 exhaustive=false);backends 里的字段是代码事实,改读取器时同步改。
316
+ cursorReadiness: {
317
+ probe: 'sync cursor-probe --json',
318
+ criteria: [
319
+ 'stable-identity: 每条消息都有非零且不重复的 server_id(缺失时身份退化为按分片的 localId+createTime,不能跨分片续读)',
320
+ 'globally-comparable-order: 跨分片合并的顺序键可比(现在是 createTime+localId,localId 不跨分片可比)',
321
+ 'window-pushdown: 时间窗下推进读取(现在只有 NT 后端)',
322
+ 'coverage-report: 后端能报告本次读取是否完整(现在只有 NT 的分片报告)',
323
+ 'every-backend: 上述各项在所有消息后端同时成立(D-027 的总门)',
324
+ ],
325
+ backends: {
326
+ 'nt': { identity: 'server_id(退化为 localId+createTime)', windowPushdown: true, coverageReport: true, liveProbe: true },
327
+ 'raw-sqlite-4x': { identity: 'MsgSvrID 列被读取、覆盖率未实测', windowPushdown: false, coverageReport: false, liveProbe: false },
328
+ 'wcdb-4x': { identity: 'server_id 随原生 API 返回、未实测', windowPushdown: false, coverageReport: false, liveProbe: false },
329
+ 'legacy-3x': { identity: 'MsgSvrID 列被读取、覆盖率未实测', windowPushdown: false, coverageReport: false, liveProbe: false },
330
+ },
331
+ advertisedWhen: 'every backend reports true on every criterion above',
332
+ },
333
+ },
299
334
  },
300
335
  favorites: {
301
336
  cli: 'fav list --json',
@@ -657,6 +692,256 @@ program
657
692
  };
658
693
  console.log(JSON.stringify(data, null, 2));
659
694
  });
695
+ // ==================== setup(自管 Python venv) ====================
696
+ // Issues 里最大的两类环境噪音(PowerShell 策略 / 商店占位 python 的 9009)里,
697
+ // 后者的根因是"没有能 import sqlcipher3 的 Python"。这条命令一次性解决:
698
+ // 在 ~/.weflow-cli/venv 建 venv 并装 requirements,之后 getPythonCommand() 优先用它。
699
+ program
700
+ .command('setup')
701
+ .description('创建自管 Python 虚拟环境 (~/.weflow-cli/venv) 并安装依赖;之后所有 Python 工作流优先用它')
702
+ .option('--dry-run', '只看计划:venv 位置、用哪个 Python 建、装哪些包(不动任何东西)')
703
+ .option('--yes', '跳过交互确认(机器执行路径)')
704
+ .option('--recreate', '删除现有 venv 后重建(默认已存在则复用)')
705
+ .option('--with-3x', '同时安装 requirements-3x.txt(微信 3.x 旧版密钥提取)')
706
+ .option('--with-voice', '同时安装 requirements-voice.txt(本机语音转写)')
707
+ .option('--non-interactive', '禁止交互提问;与 --yes 配合用于脚本')
708
+ .option('--json', '输出 JSON 格式')
709
+ .action(async (opts) => {
710
+ let plan;
711
+ try {
712
+ plan = await planSetup({ recreate: !!opts.recreate, with3x: !!opts.with3x, withVoice: !!opts.withVoice });
713
+ }
714
+ catch (error) {
715
+ if (opts.json) {
716
+ console.log(JSON.stringify({ success: false, code: 'SETUP_PLAN_FAILED', error: String(error?.message || error).slice(0, 200) }));
717
+ process.exit(1);
718
+ }
719
+ console.error(chalk.red(`✗ 计划失败: ${error?.message || error}`));
720
+ process.exit(1);
721
+ }
722
+ if (opts.dryRun || !opts.yes) {
723
+ const preview = {
724
+ success: true,
725
+ dryRun: true,
726
+ action: 'setup.venv',
727
+ venvDir: plan.venvDir,
728
+ venvExists: existsSync(plan.venvDir),
729
+ creator: plan.creator,
730
+ requirementFiles: plan.requirementFiles,
731
+ packages: plan.packages,
732
+ substitutions: plan.substitutions,
733
+ recreate: plan.recreate,
734
+ createsUnder: plan.createsUnder,
735
+ network: plan.network,
736
+ // 密钥/数据库一个都不碰:这条命令只写 ~/.weflow-cli/venv
737
+ readsLocalData: false,
738
+ };
739
+ if (opts.dryRun) {
740
+ if (opts.json) {
741
+ console.log(JSON.stringify(preview));
742
+ return;
743
+ }
744
+ console.log(chalk.cyan('\nsetup 计划(--dry-run,不执行)'));
745
+ console.log(chalk.gray(` venv: ${plan.venvDir}${existsSync(plan.venvDir) ? '(已存在,默认复用)' : ''}`));
746
+ console.log(chalk.gray(` 创建者: ${plan.creator ?? '未找到可运行的 Python(先装 Python 3.10+)'}`));
747
+ console.log(chalk.gray(` 安装: ${plan.packages.join(' ')}`));
748
+ for (const sub of plan.substitutions)
749
+ console.log(chalk.gray(` 替换: ${sub}`));
750
+ console.log(chalk.gray(' 网络请求: pip 访问 PyPI;不读聊天数据、不碰密钥\n'));
751
+ return;
752
+ }
753
+ if (opts.json) {
754
+ console.log(JSON.stringify({ ...preview, success: false, dryRun: false, code: 'CONFIRMATION_REQUIRED' }));
755
+ process.exit(1);
756
+ }
757
+ if (process.env.WEFLOW_NON_INTERACTIVE || opts.nonInteractive) {
758
+ console.error(chalk.red('✗ 非交互模式需要 --yes'));
759
+ process.exit(1);
760
+ }
761
+ const { confirmed } = await inquirer.prompt([{
762
+ type: 'confirm',
763
+ name: 'confirmed',
764
+ message: `将在 ${plan.venvDir} 创建虚拟环境并安装 ${plan.packages.length} 个包(pip 需要访问 PyPI)。继续吗?`,
765
+ default: false,
766
+ }]);
767
+ if (!confirmed) {
768
+ console.log(chalk.gray('已取消'));
769
+ return;
770
+ }
771
+ }
772
+ const result = await runSetup(plan, (line) => console.log(chalk.gray(` ${line}`)));
773
+ if (opts.json) {
774
+ console.log(JSON.stringify(result));
775
+ if (!result.success)
776
+ process.exit(1);
777
+ return;
778
+ }
779
+ console.log(chalk.cyan('\nPython 环境 setup'));
780
+ for (const step of result.steps) {
781
+ console.log(` ${step.ok ? '✓' : '✗'} ${step.name}${step.detail ? chalk.gray(` — ${step.detail}`) : ''}`);
782
+ }
783
+ if (result.success) {
784
+ console.log(chalk.green(`\n完成。之后的 Python 工作流会优先用: ${result.venvPython}`));
785
+ console.log(chalk.gray(' 下一步: weflow-cli check 验证环境,weflow-cli init 初始化本地数据'));
786
+ }
787
+ else {
788
+ console.log(chalk.red(`\n失败(${result.error ?? '见上方步骤'})。把 --json 输出贴到 issue 里最好排查。`));
789
+ process.exit(1);
790
+ }
791
+ });
792
+ // ==================== import(外部 IM 数据导入,D-129) ====================
793
+ // 边界是**文件契约**:读 qchat-cli `qce export --format json` 的产物,归一化落到
794
+ // output/imports/qq/,之后 chat-notes / search-index 就能把 QQ 会话当普通聊天线处理。
795
+ const importCmd = program
796
+ .command('import')
797
+ .description('导入外部 IM 的聊天记录(当前支持 qchat-cli 的 JSON 导出),并入本地知识管道');
798
+ importCmd
799
+ .command('qchat <path>')
800
+ .description('导入 qchat-cli 的 JSON 导出(单文件或目录;按 messageId 去重,重复导入安全)')
801
+ .option('--dry-run', '只看会导入什么:文件、会话、条数(不写任何东西)')
802
+ .option('--yes', '确认执行(写本地存储 output/imports/qq/)')
803
+ .option('--own-id <qq>', '可选:自己的 QQ 号,用来标 isSend(不给则该字段缺省为未知)')
804
+ .option('--json', '输出 JSON 格式')
805
+ .action(async (inputPath, opts) => {
806
+ let plan;
807
+ try {
808
+ plan = planImportQchat(inputPath);
809
+ }
810
+ catch (error) {
811
+ if (opts.json) {
812
+ console.log(JSON.stringify({ success: false, code: 'IMPORT_PLAN_FAILED', error: String(error?.message || error).slice(0, 200) }));
813
+ process.exit(1);
814
+ }
815
+ console.error(chalk.red(`✗ 计划失败: ${error?.message || error}`));
816
+ process.exit(1);
817
+ }
818
+ const preview = {
819
+ success: true,
820
+ dryRun: true,
821
+ action: 'import.qchat',
822
+ input: inputPath,
823
+ files: plan.files.map(f => ({
824
+ file: basename(f.file), ok: f.ok,
825
+ ...(f.reason ? { reason: f.reason } : {}),
826
+ sessions: f.sessions.map(s => ({ id: s.id, name: s.name, type: s.type, messages: s.messages })),
827
+ })),
828
+ sessionsFound: plan.sessionsFound,
829
+ messagesTotal: plan.messagesTotal,
830
+ storeRoot: plan.storeRoot,
831
+ ownIdProvided: Boolean(opts.ownId),
832
+ readsLocalData: true,
833
+ writesLocalStore: true,
834
+ invokesAI: false,
835
+ network: false,
836
+ };
837
+ if (opts.dryRun) {
838
+ if (opts.json) {
839
+ console.log(JSON.stringify(preview));
840
+ return;
841
+ }
842
+ console.log(chalk.cyan('\nimport qchat 计划(--dry-run,不执行)'));
843
+ console.log(chalk.gray(` 存储: ${plan.storeRoot}`));
844
+ for (const f of plan.files) {
845
+ if (f.ok) {
846
+ for (const sess of f.sessions) {
847
+ console.log(chalk.gray(` ✓ ${basename(f.file)} → 会话「${sess.name}」(${sess.type},${sess.messages} 条)`));
848
+ }
849
+ }
850
+ else {
851
+ console.log(chalk.yellow(` ✗ ${basename(f.file)} — ${f.reason}`));
852
+ }
853
+ }
854
+ if (opts.ownId)
855
+ console.log(chalk.gray(` isSend 判定: 按自己的 QQ 号 ${String(opts.ownId).slice(0, 4)}…`));
856
+ console.log('');
857
+ return;
858
+ }
859
+ if (!opts.yes) {
860
+ if (opts.json) {
861
+ console.log(JSON.stringify({ ...preview, success: false, dryRun: false, code: 'CONFIRMATION_REQUIRED' }));
862
+ process.exit(1);
863
+ }
864
+ console.error(chalk.red(`✗ 需要确认:将把 ${plan.messagesTotal} 条消息写入本地存储 ${plan.storeRoot}。加 --yes 执行`));
865
+ process.exit(1);
866
+ }
867
+ const result = runImportQchat(inputPath, opts.ownId ? String(opts.ownId) : undefined);
868
+ if (opts.json) {
869
+ console.log(JSON.stringify(result));
870
+ if (!result.success)
871
+ process.exit(1);
872
+ return;
873
+ }
874
+ if (!result.success) {
875
+ console.error(chalk.red(`✗ 没有可导入的文件(${result.filesSkipped} 个被跳过,原因见 --dry-run)`));
876
+ process.exit(1);
877
+ }
878
+ console.log(chalk.cyan('\n导入完成'));
879
+ for (const sess of result.sessions) {
880
+ console.log(chalk.green(` ✓ 「${sess.name}」共 ${sess.total} 条(新增 ${sess.added}、更新 ${sess.updated}、原样 ${sess.unchanged})`));
881
+ }
882
+ if (result.filesSkipped)
883
+ console.log(chalk.yellow(` ⚠ 跳过 ${result.filesSkipped} 个不认识的文件`));
884
+ console.log(chalk.gray(` 存储: ${result.storeRoot}`));
885
+ console.log(chalk.gray(' 下一步: weflow-cli import list 查看;chat-notes --source qq 产知识卡;search-index --include-imports 进搜索'));
886
+ });
887
+ importCmd
888
+ .command('list')
889
+ .description('列出已导入的会话')
890
+ .option('--json', '输出 JSON 格式')
891
+ .action((opts) => {
892
+ const entries = listImports();
893
+ if (opts.json) {
894
+ console.log(JSON.stringify({ success: true, sessions: entries }));
895
+ return;
896
+ }
897
+ if (!entries.length) {
898
+ console.log(chalk.gray('\n还没有导入任何会话。先跑 weflow-cli import qchat <qchat 导出文件>'));
899
+ return;
900
+ }
901
+ console.log(chalk.cyan(`\n已导入 ${entries.length} 个会话`));
902
+ for (const e of entries) {
903
+ const span = e.newestCreateTime
904
+ ? `${new Date((e.oldestCreateTime ?? 0) * 1000).toLocaleDateString('zh-CN')} ~ ${new Date(e.newestCreateTime * 1000).toLocaleDateString('zh-CN')}`
905
+ : '(无消息)';
906
+ console.log(` 「${e.name}」(${e.type},${e.messages} 条,${span})`);
907
+ }
908
+ console.log('');
909
+ });
910
+ importCmd
911
+ .command('show <sessionId>')
912
+ .description('读回一个已导入会话的消息(验证导入)')
913
+ .option('-n, --limit <n>', '只看最近 N 条(0 = 全部)', '20')
914
+ .option('--json', '输出 JSON 格式')
915
+ .action((sessionId, opts) => {
916
+ const limit = Number(opts.limit);
917
+ if (!Number.isInteger(limit) || limit < 0) {
918
+ if (opts.json) {
919
+ console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error: 'limit 必须是 ≥ 0 的整数' }));
920
+ process.exit(1);
921
+ }
922
+ console.error(chalk.red('✗ limit 必须是 ≥ 0 的整数'));
923
+ process.exit(1);
924
+ }
925
+ const data = readImportedSession(sessionId, limit);
926
+ if (!data) {
927
+ if (opts.json) {
928
+ console.log(JSON.stringify({ success: false, code: 'IMPORT_SESSION_NOT_FOUND' }));
929
+ process.exit(1);
930
+ }
931
+ console.error(chalk.red(`✗ 没有会话 ${sessionId} 的导入记录`));
932
+ process.exit(1);
933
+ }
934
+ if (opts.json) {
935
+ console.log(JSON.stringify({ success: true, ...data }));
936
+ return;
937
+ }
938
+ console.log(chalk.cyan(`\n「${data.session.name}」共 ${data.messages.length} 条(存储里全部 ${listImports().find(e => e.id === sessionId)?.messages ?? '?'} 条)`));
939
+ for (const m of data.messages) {
940
+ const who = m.isSend === undefined ? m.senderName : (m.isSend ? '我' : m.senderName);
941
+ console.log(chalk.gray(`[${new Date(m.createTime * 1000).toLocaleString('zh-CN')}] ${who}(${m.messageType}): `) + m.parsedContent.slice(0, 80));
942
+ }
943
+ console.log('');
944
+ });
660
945
  // ==================== init ====================
661
946
  program
662
947
  .command('init')
@@ -1829,6 +2114,194 @@ syncCmd
1829
2114
  if (!success)
1830
2115
  process.exit(1);
1831
2116
  });
2117
+ // 稳定游标的"路径可见"探针(D-027)。只读、本地、无确认门——与 `check` 同一副姿态:
2118
+ // 它是诊断,不是副作用。`stableCursor` 的结论**不会**因为它翻 true;它做的是把
2119
+ // "还差什么"逐条摊开给下游(she-love-me、MCP 客户端)看。
2120
+ syncCmd
2121
+ .command('cursor-probe')
2122
+ .description('探测当前消息后端离稳定增量游标还差什么(只读,NT 后端带实测采样)')
2123
+ .option('--json', '输出 JSON 格式')
2124
+ .option('--tables-per-shard <n>', '每分片采样的会话表数量(schema 检查始终全量)', '5')
2125
+ .option('--rows-per-table <n>', '每张表两端各采样的行数', '500')
2126
+ .action(async (opts) => {
2127
+ const parseBound = (raw, label) => {
2128
+ const n = Number(raw);
2129
+ if (!Number.isInteger(n) || n < 1 || n > 10_000) {
2130
+ if (opts.json) {
2131
+ console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error: `${label} 必须是 1-10000 的整数` }));
2132
+ process.exit(1);
2133
+ }
2134
+ console.error(chalk.red(`✗ ${label} 必须是 1-10000 的整数`));
2135
+ process.exit(1);
2136
+ }
2137
+ return n;
2138
+ };
2139
+ let report;
2140
+ try {
2141
+ report = await probeCursorReadiness({
2142
+ tablesPerShard: parseBound(opts.tablesPerShard, '--tables-per-shard'),
2143
+ rowsPerTable: parseBound(opts.rowsPerTable, '--rows-per-table'),
2144
+ });
2145
+ }
2146
+ catch (error) {
2147
+ if (opts.json) {
2148
+ console.log(JSON.stringify({ success: false, code: 'CURSOR_PROBE_FAILED', error: error?.message || '探测失败' }));
2149
+ process.exit(1);
2150
+ }
2151
+ console.error(chalk.red(`✗ 探测失败: ${error?.message || error}`));
2152
+ process.exit(1);
2153
+ }
2154
+ if (opts.json) {
2155
+ console.log(JSON.stringify(report));
2156
+ return;
2157
+ }
2158
+ console.log(chalk.cyan('\n稳定游标探针(结论不变:stableCursor = false,D-027)'));
2159
+ console.log(chalk.gray(` 后端: ${report.backend ?? '(未配置)'} — ${report.backendNote}`));
2160
+ console.log(chalk.gray(` 实测: ${report.probedLive ? `已采样(exhaustive=false)` : '未运行(该后端无实测探针或未初始化)'}`));
2161
+ for (const c of report.criteria) {
2162
+ const mark = c.met === null ? '?' : c.met ? '✓' : '✗';
2163
+ console.log(` ${mark} ${c.id}`);
2164
+ console.log(chalk.gray(` ${c.detail}`));
2165
+ }
2166
+ if (report.sample) {
2167
+ const sm = report.sample;
2168
+ console.log(chalk.gray(` 采样: ${sm.rowsSampled} 行 / ${sm.tablesSampled} 表(共 ${sm.tablesTotal} 表,每表两端各 ${sm.rowsPerTable} 行)`));
2169
+ }
2170
+ console.log('');
2171
+ });
2172
+ // ==================== ledger(资金流水:红包/转账,读 general.db) ====================
2173
+ // 2026-10-26 NT 普查产物(docs/NT_DATABASES.md)。如实边界写在脚本头:红包表无
2174
+ // 时间/金额列(在消息正文里,按 messageServerId 溯源是后续工作);状态枚举语义
2175
+ // 未逐值验证,原样输出不翻译。本地只读、不联网。
2176
+ program
2177
+ .command('ledger')
2178
+ .description('资金流水:红包与转账(读 general.db;本地只读)')
2179
+ .option('--talker <contact>', '只看某个会话(id 或备注/昵称子串)')
2180
+ .option('--days <n>', '只看最近 N 天(按转账时间;红包无时间列不受此限)')
2181
+ .option('--json', '输出 JSON 格式')
2182
+ .action(async (opts) => {
2183
+ const { execFile } = await import('child_process');
2184
+ const { promisify } = await import('util');
2185
+ const execFileAsync = promisify(execFile);
2186
+ const script = join(resolvePackageRoot(), 'scripts', 'wechat_ledger.py');
2187
+ const args = [script, '--json'];
2188
+ if (opts.days !== undefined) {
2189
+ const days = parseCliInteger(opts.days, 'days', 1, 36_500, opts.json);
2190
+ args.push('--days', String(days));
2191
+ }
2192
+ try {
2193
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
2194
+ timeout: 120_000, maxBuffer: 10 * 1024 * 1024,
2195
+ // D-020:会话标识走环境变量,不进 argv
2196
+ env: createPythonProcessEnv(opts.talker ? { WEFLOW_TALKER: String(opts.talker) } : {}),
2197
+ });
2198
+ const payload = JSON.parse(stdout.slice(stdout.indexOf('{')));
2199
+ if (opts.json) {
2200
+ console.log(JSON.stringify(payload));
2201
+ if (!payload.success)
2202
+ process.exit(1);
2203
+ return;
2204
+ }
2205
+ if (!payload.success) {
2206
+ console.error(chalk.red(`✗ ${payload.error}`));
2207
+ process.exit(1);
2208
+ }
2209
+ console.log(chalk.cyan(`
2210
+ 资金流水:红包 ${payload.redEnvelopes} 笔、转账 ${payload.transfers} 笔`));
2211
+ console.log(chalk.gray(' (红包无时间/金额列——在消息正文里,按 messageServerId 看先后;状态枚举原样,语义未验证)'));
2212
+ for (const r of payload.rows.slice(0, 40)) {
2213
+ const when = r.time ? new Date(r.time * 1000).toLocaleString('zh-CN', { hour12: false }) : '(无时间列)';
2214
+ const extra = r.kind === '红包'
2215
+ ? `status=${r.hbStatus}/${r.receiveStatus}`
2216
+ : `对方=${r.counterpartyDisplay || r.counterparty || '?'}${r.invalidTime ? ` invalid=${r.invalidTime}` : ''}`;
2217
+ console.log(` [${when}] ${r.kind} · ${r.sessionDisplay} · ${r.direction} · ${extra}`);
2218
+ }
2219
+ if (payload.rows.length > 40)
2220
+ console.log(chalk.gray(` …共 ${payload.rows.length} 行(--json 看全表)`));
2221
+ console.log('');
2222
+ }
2223
+ catch (e) {
2224
+ if (opts.json) {
2225
+ console.log(JSON.stringify({ success: false, code: 'LEDGER_FAILED', error: safeSubprocessError(e) }));
2226
+ process.exit(1);
2227
+ }
2228
+ console.error(chalk.red(`✗ ${safeSubprocessError(e)}`));
2229
+ process.exit(1);
2230
+ }
2231
+ });
2232
+ // ==================== avatar(联系人头像,读 head_image.db) ====================
2233
+ // 2026-10-26 NT 数据库普查的直接产物(docs/NT_DATABASES.md):头像本体内嵌在
2234
+ // head_image.db 里(JPEG/PNG 完整字节,按 username 键控,一人多行=变更历史),
2235
+ // 比文件缓存可靠(清理不丢)。本地只读、不联网。
2236
+ program
2237
+ .command('avatar [contact]')
2238
+ .description('导出联系人头像(读 head_image.db;--list 看谁有头像,--all 批量导出最新头像)')
2239
+ .option('--list', '只列出有头像的 username 与头像数')
2240
+ .option('--all', '导出所有 username 的最新头像到 --out 目录')
2241
+ .option('--out <path>', '输出文件(单联系人)或目录(--all),默认 output/avatars/')
2242
+ .option('--json', '输出 JSON 格式')
2243
+ .action(async (contact, opts) => {
2244
+ if (!contact && !opts.list && !opts.all) {
2245
+ if (opts.json) {
2246
+ console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error: '要给联系人,或用 --list / --all' }));
2247
+ process.exit(1);
2248
+ }
2249
+ console.error(chalk.red('✗ 要给联系人,或用 --list / --all'));
2250
+ process.exit(1);
2251
+ }
2252
+ if (contact && (opts.list || opts.all)) {
2253
+ if (opts.json) {
2254
+ console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error: '联系人与 --list/--all 互斥' }));
2255
+ process.exit(1);
2256
+ }
2257
+ console.error(chalk.red('✗ 联系人与 --list/--all 互斥'));
2258
+ process.exit(1);
2259
+ }
2260
+ const { execFile } = await import('child_process');
2261
+ const { promisify } = await import('util');
2262
+ const execFileAsync = promisify(execFile);
2263
+ const script = join(resolvePackageRoot(), 'scripts', 'wechat_avatar.py');
2264
+ const args = [script, ...(opts.list ? ['--list'] : opts.all ? ['--all'] : []), '--json'];
2265
+ // D-020:联系人标识走环境变量,不进 argv;输出路径同理(脚本 --out 缺省读它)
2266
+ const env = {
2267
+ ...(contact ? { WEFLOW_TALKER: String(contact) } : {}),
2268
+ ...(opts.out ? { WEFLOW_AVATAR_OUT: String(opts.out) } : {}),
2269
+ };
2270
+ try {
2271
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
2272
+ timeout: 120_000, maxBuffer: 10 * 1024 * 1024,
2273
+ env: createPythonProcessEnv(env),
2274
+ });
2275
+ const payload = JSON.parse(stdout.slice(stdout.indexOf('{')));
2276
+ if (opts.json) {
2277
+ console.log(JSON.stringify(payload));
2278
+ if (!payload.success)
2279
+ process.exit(1);
2280
+ return;
2281
+ }
2282
+ if (!payload.success) {
2283
+ console.error(chalk.red(`✗ ${payload.error}`));
2284
+ process.exit(1);
2285
+ }
2286
+ if (payload.action === 'avatar.export') {
2287
+ console.log(chalk.green(`✓ 已导出「${payload.username}」的最新头像(${payload.bytes} 字节,历史 ${payload.historyCount} 张)→ ${payload.out}`));
2288
+ }
2289
+ else if (payload.action === 'avatar.export-all') {
2290
+ console.log(chalk.green(`✓ 已导出 ${payload.written} 个最新头像 → ${payload.dir}`));
2291
+ }
2292
+ else {
2293
+ console.log(payload.error || `共 ${payload.users?.length ?? '?'} 个 username 有头像(用 --json 看全表)`);
2294
+ }
2295
+ }
2296
+ catch (e) {
2297
+ if (opts.json) {
2298
+ console.log(JSON.stringify({ success: false, code: 'AVATAR_FAILED', error: safeSubprocessError(e) }));
2299
+ process.exit(1);
2300
+ }
2301
+ console.error(chalk.red(`✗ ${safeSubprocessError(e)}`));
2302
+ process.exit(1);
2303
+ }
2304
+ });
1832
2305
  // ==================== contacts ====================
1833
2306
  // contact-schema:读 contact.db 里**群**的 ext_buffer(本地只读:不联网、不调模型)
1834
2307
  //
@@ -5433,6 +5906,7 @@ program
5433
5906
  .option('--api-key <key>', 'DashScope embedding API key')
5434
5907
  .option('--days <n>', '聊天记录收最近多少天(默认 90)')
5435
5908
  .option('--article-days <n>', '日报文章收最近多少个日期目录(默认 30)')
5909
+ .option('--include-imports', '把 weflow-cli import qchat 导入的 QQ 会话也收进索引(聊天半边)')
5436
5910
  .option('--dry-run', '仅预览,不读取数据库、调用网络或写入索引')
5437
5911
  .option('--yes', '确认构建或重建索引')
5438
5912
  .option('--json', '输出 JSON 格式')
@@ -5482,6 +5956,7 @@ program
5482
5956
  mode: opts.full ? 'full' : 'incremental',
5483
5957
  chatDays,
5484
5958
  articleDays,
5959
+ includeImports: !!opts.includeImports,
5485
5960
  readsLocalData: true,
5486
5961
  usesCloudEmbedding: true,
5487
5962
  replacesExistingIndex: !!opts.full,
@@ -5522,6 +5997,8 @@ program
5522
5997
  '--article-days', String(articleDays)];
5523
5998
  if (opts.full)
5524
5999
  args.push('--full');
6000
+ if (opts.includeImports)
6001
+ args.push('--include-imports');
5525
6002
  try {
5526
6003
  const { stdout } = await execFileAsync(getPythonCommand(), args, {
5527
6004
  // 30 分钟:全量重建实测要跑二十分钟以上(16k 条 / 1500+ 批),而这里原来给的是 5 分钟 --
@@ -6244,6 +6721,7 @@ program
6244
6721
  .description('把最近一段时间的会话整理成知识卡(之后用 wiki compile 聚成概念页)—— **只写本地文件,不发送**')
6245
6722
  .option('--days <n>', '看最近多少天', '30')
6246
6723
  .option('--limit <n>', '最多处理几个会话', '40')
6724
+ .option('--source <source>', '聊天来源:wechat(默认)/ qq(只读 import qchat 导入的会话,不碰微信库)/ all(都要)', 'wechat')
6247
6725
  .option('--dry-run', '仅预览:几个会话、多少字符会发给生成模型(只读本地,零出境)')
6248
6726
  .option('--yes', '确认把对话发送给生成模型')
6249
6727
  .option('--transcribe-voice', '把缺转写的语音**本机**识别掉再产卡(本地 whisper,慢、吃 CPU、不出网;配合 --dry-run 就是只补转写、不调模型)')
@@ -6256,12 +6734,13 @@ program
6256
6734
  const script = join(pkgRoot, 'scripts', 'chat_notes.py');
6257
6735
  const days = parseCliInteger(opts.days, 'days', 1, 3650, opts.json);
6258
6736
  const limit = parseCliInteger(opts.limit, 'limit', 1, 200, opts.json);
6737
+ requireCliChoice(opts.source, ['wechat', 'qq', 'all'], 'source', opts.json);
6259
6738
  // 与 `draft` 同一条纪律:**"确认过了"只用一个变量**,参数在确认之后才拼
6260
6739
  let confirmed = !!opts.yes;
6261
6740
  if (!opts.dryRun && !confirmed) {
6262
6741
  const preview = {
6263
6742
  success: false, dryRun: false, action: 'chat-notes',
6264
- code: 'CONFIRMATION_REQUIRED', days, limit,
6743
+ code: 'CONFIRMATION_REQUIRED', days, limit, source: opts.source,
6265
6744
  readsLocalChat: true, invokesAI: true, writesLocalFiles: true, sendsNothing: true,
6266
6745
  };
6267
6746
  if (opts.json) {
@@ -6281,6 +6760,7 @@ program
6281
6760
  }
6282
6761
  }
6283
6762
  const args = [script, '--days', String(days), '--limit', String(limit),
6763
+ '--source', String(opts.source),
6284
6764
  ...(confirmed ? ['--yes'] : []),
6285
6765
  ...(opts.transcribeVoice ? ['--transcribe-voice'] : []),
6286
6766
  ...(opts.json ? ['--json'] : []),
@@ -7737,14 +8217,13 @@ program
7737
8217
  const limit = parseCliInteger(opts.limit, 'limit', 1, 200, opts.json);
7738
8218
  try {
7739
8219
  const args = [script, '--years', String(opts.years || '1,3,5'), '--limit', String(limit)];
7740
- if (opts.talker)
7741
- args.push('--talker', String(opts.talker));
7742
8220
  if (opts.json)
7743
8221
  args.push('--json');
8222
+ // D-020:会话标识不进子进程 argv(进程列表里看得见),走 WEFLOW_TALKER。
7744
8223
  const { stdout } = await execFileAsync(getPythonCommand(), args, {
7745
8224
  timeout: 300_000,
7746
8225
  maxBuffer: 10 * 1024 * 1024,
7747
- env: pythonProcessEnv(),
8226
+ env: createPythonProcessEnv(opts.talker ? { WEFLOW_TALKER: String(opts.talker) } : {}),
7748
8227
  });
7749
8228
  console.log(stdout);
7750
8229
  }
@@ -7825,14 +8304,13 @@ program
7825
8304
  const limit = parseCliInteger(opts.limit, 'limit', 1, 500, opts.json);
7826
8305
  try {
7827
8306
  const args = [script, '--days', String(days), '--limit', String(limit)];
7828
- if (opts.talker)
7829
- args.push('--talker', String(opts.talker));
7830
8307
  if (opts.json)
7831
8308
  args.push('--json');
8309
+ // D-020:同 today——会话标识走 WEFLOW_TALKER,不进 argv。
7832
8310
  const { stdout } = await execFileAsync(getPythonCommand(), args, {
7833
8311
  timeout: 300_000,
7834
8312
  maxBuffer: 10 * 1024 * 1024,
7835
- env: pythonProcessEnv(),
8313
+ env: createPythonProcessEnv(opts.talker ? { WEFLOW_TALKER: String(opts.talker) } : {}),
7836
8314
  });
7837
8315
  console.log(stdout);
7838
8316
  }
@@ -7890,6 +8368,86 @@ program
7890
8368
  process.exit(1);
7891
8369
  }
7892
8370
  });
8371
+ // ==================== media(媒体精确匹配)====================
8372
+ program
8373
+ .command('media')
8374
+ .description('媒体精确匹配:用 hardlink.db 把 md5 直接对到本地文件(图片/文件/视频;本地只读)')
8375
+ .option('--md5 <prefix>', '按 md5 前缀查')
8376
+ .option('--type <kind>', 'image | file | video | all', 'all')
8377
+ .option('--limit <n>', '最多列几条', '20')
8378
+ .option('--missing-only', '只列文件已不在本地的(微信会清理)')
8379
+ .option('--json', '输出 JSON 格式')
8380
+ .action(async (opts) => {
8381
+ const { execFile } = await import('child_process');
8382
+ const { promisify } = await import('util');
8383
+ const execFileAsync = promisify(execFile);
8384
+ const script = join(resolvePackageRoot(), 'scripts', 'media_index.py');
8385
+ const limit = parseCliInteger(opts.limit, 'limit', 1, 5000, opts.json);
8386
+ try {
8387
+ const args = [script, '--type', String(opts.type || 'all'), '--limit', String(limit)];
8388
+ if (opts.md5)
8389
+ args.push('--md5', String(opts.md5));
8390
+ if (opts.missingOnly)
8391
+ args.push('--missing-only');
8392
+ if (opts.json)
8393
+ args.push('--json');
8394
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
8395
+ timeout: 300_000,
8396
+ maxBuffer: 16 * 1024 * 1024,
8397
+ env: pythonProcessEnv(),
8398
+ });
8399
+ console.log(stdout);
8400
+ }
8401
+ catch (e) {
8402
+ if (opts.json) {
8403
+ console.log(JSON.stringify({
8404
+ success: false,
8405
+ code: 'MEDIA_FAILED',
8406
+ error: '媒体索引不可用,请运行 check --json 检查配置和依赖',
8407
+ }));
8408
+ process.exit(1);
8409
+ }
8410
+ console.error(chalk.red(`\n${safeSubprocessError(e, '查询失败')}`));
8411
+ process.exit(1);
8412
+ }
8413
+ });
8414
+ // ==================== replay(关系时间重放)====================
8415
+ program
8416
+ .command('replay')
8417
+ .description('关系网络时间重放:按月展开,生成可播放的单文件 HTML(本地、离线、不调模型)')
8418
+ .option('--out <file>', '输出路径', 'output/replay.html')
8419
+ .option('--min-weight <number>', '当月互动量门槛,低于此不计入', '10')
8420
+ .option('--json', '只输出聚合结果(验收用)')
8421
+ .action(async (opts) => {
8422
+ const { execFile } = await import('child_process');
8423
+ const { promisify } = await import('util');
8424
+ const execFileAsync = promisify(execFile);
8425
+ const script = join(resolvePackageRoot(), 'scripts', 'replay.py');
8426
+ const minWeight = parseCliInteger(opts.minWeight, 'min-weight', 1, 10000, opts.json);
8427
+ try {
8428
+ const args = [script, '--min-weight', String(minWeight), '--out', String(opts.out)];
8429
+ if (opts.json)
8430
+ args.push('--json');
8431
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
8432
+ timeout: 600_000,
8433
+ maxBuffer: 64 * 1024 * 1024,
8434
+ env: pythonProcessEnv(),
8435
+ });
8436
+ console.log(stdout);
8437
+ }
8438
+ catch (e) {
8439
+ if (opts.json) {
8440
+ console.log(JSON.stringify({
8441
+ success: false,
8442
+ code: 'REPLAY_FAILED',
8443
+ error: '时间重放不可用,请运行 check --json 检查配置和依赖',
8444
+ }));
8445
+ process.exit(1);
8446
+ }
8447
+ console.error(chalk.red(`\n${safeSubprocessError(e, '生成失败')}`));
8448
+ process.exit(1);
8449
+ }
8450
+ });
7893
8451
  // ==================== timeline(某号观点演变)====================
7894
8452
  program
7895
8453
  .command('timeline')
@@ -8225,6 +8783,12 @@ program
8225
8783
  // 只报条数与最近时间(不读崩溃内容)
8226
8784
  crashRecords: client.crashRecords,
8227
8785
  caveats: client.caveats,
8786
+ // D-130:本机实际加载的版本 vs 本包验证过的版本(判定只有这一份实现)
8787
+ compatibility: {
8788
+ verifiedVersion: WECHAT_VERIFIED.version,
8789
+ verifiedAt: WECHAT_VERIFIED.verifiedAt,
8790
+ verdict: compareWechatVersions(client.version, WECHAT_VERIFIED.version),
8791
+ },
8228
8792
  },
8229
8793
  };
8230
8794
  return data;
@@ -8262,6 +8826,7 @@ program
8262
8826
  dataDirMatchesConfig: data.wechatClient.dataDirMatchesConfig,
8263
8827
  dataDirs: data.wechatClient.dataDirs,
8264
8828
  unusableVersionDirs: data.wechatClient.unusableVersionDirs,
8829
+ compatibility: data.wechatClient.compatibility,
8265
8830
  },
8266
8831
  }));
8267
8832
  return;
@@ -8319,6 +8884,13 @@ program
8319
8884
  }
8320
8885
  }
8321
8886
  }
8887
+ // 3.5 微信版本兼容(D-130):本机加载的 vs 本包验证过的
8888
+ {
8889
+ const verdict = compareWechatVersions(client.version, WECHAT_VERIFIED.version);
8890
+ const mark = verdict === 'verified' ? chalk.green('✓') : verdict === 'newer-than-verified' ? chalk.yellow('⚠') : chalk.gray('·');
8891
+ console.log(chalk.white('\n微信版本兼容:'));
8892
+ console.log(` ${mark} ${client.version ?? '未知(微信没在运行)'} · ${describeCompatVerdict(verdict, WECHAT_VERIFIED.version)}`);
8893
+ }
8322
8894
  // 4. Config
8323
8895
  console.log(chalk.white('\n配置状态:'));
8324
8896
  const configured = configService.isConfigured();