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
package/bin/weflow-cli.ts CHANGED
@@ -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'
@@ -30,6 +33,7 @@ import type { ChatSession } from '../src/types.js'
30
33
  import { disabledIds, describeSkill, readSkillBody, scanSkills, skillRoots, skillState } from '../src/services/assistantSkills.js'
31
34
  import { assistantScenes, validateSceneId, type Scene } from '../src/services/assistantScenes.js'
32
35
  import { detectWechatClient, reportedDataDirs, samePath } from '../src/services/wechatClient.js'
36
+ import { WECHAT_VERIFIED, compareWechatVersions, describeCompatVerdict } from '../src/services/wechatCompat.js'
33
37
 
34
38
  const program = new Command()
35
39
 
@@ -302,6 +306,14 @@ program
302
306
  const data = {
303
307
  schema: 'weflow-capabilities/v1',
304
308
  version: CLI_VERSION,
309
+ // D-130:本包最后用真实数据验证过的微信版本(单一事实源 wechatCompat.ts)。
310
+ // check 的判定也从同一处读;发布清单要求 CHANGELOG 每版记录(来源就是这里)。
311
+ compatibility: {
312
+ wechat: {
313
+ verifiedVersion: WECHAT_VERIFIED.version,
314
+ verifiedAt: WECHAT_VERIFIED.verifiedAt,
315
+ },
316
+ },
305
317
  read: {
306
318
  sessions: { cli: 'sessions --json', mcp: 'wechat.list_sessions' },
307
319
  messages: { cli: 'messages <talker> --json', mcp: 'wechat.export_messages' },
@@ -312,7 +324,30 @@ program
312
324
  versionedContract: 'weflow-message/v1',
313
325
  rawContractPreserved: true,
314
326
  coverage: ['requestedFrom', 'requestedTo', 'requestedLimit', 'returned', 'mayHaveMore', 'oldestCreateTime', 'newestCreateTime'],
315
- incrementalRead: { mode: 'overlapping-time-window', stableCursor: false },
327
+ incrementalRead: {
328
+ mode: 'overlapping-time-window',
329
+ stableCursor: false,
330
+ // D-027 的另一半:光写 false 是句结论,这里把 false → true 的**路径**
331
+ // 摊开。probe 指向的那条命令会实测 NT 数据侧判据(采样有界、结果里
332
+ // 自带 exhaustive=false);backends 里的字段是代码事实,改读取器时同步改。
333
+ cursorReadiness: {
334
+ probe: 'sync cursor-probe --json',
335
+ criteria: [
336
+ 'stable-identity: 每条消息都有非零且不重复的 server_id(缺失时身份退化为按分片的 localId+createTime,不能跨分片续读)',
337
+ 'globally-comparable-order: 跨分片合并的顺序键可比(现在是 createTime+localId,localId 不跨分片可比)',
338
+ 'window-pushdown: 时间窗下推进读取(现在只有 NT 后端)',
339
+ 'coverage-report: 后端能报告本次读取是否完整(现在只有 NT 的分片报告)',
340
+ 'every-backend: 上述各项在所有消息后端同时成立(D-027 的总门)',
341
+ ],
342
+ backends: {
343
+ 'nt': { identity: 'server_id(退化为 localId+createTime)', windowPushdown: true, coverageReport: true, liveProbe: true },
344
+ 'raw-sqlite-4x': { identity: 'MsgSvrID 列被读取、覆盖率未实测', windowPushdown: false, coverageReport: false, liveProbe: false },
345
+ 'wcdb-4x': { identity: 'server_id 随原生 API 返回、未实测', windowPushdown: false, coverageReport: false, liveProbe: false },
346
+ 'legacy-3x': { identity: 'MsgSvrID 列被读取、覆盖率未实测', windowPushdown: false, coverageReport: false, liveProbe: false },
347
+ },
348
+ advertisedWhen: 'every backend reports true on every criterion above',
349
+ },
350
+ },
316
351
  },
317
352
  favorites: {
318
353
  cli: 'fav list --json',
@@ -675,6 +710,232 @@ program
675
710
  console.log(JSON.stringify(data, null, 2))
676
711
  })
677
712
 
713
+ // ==================== setup(自管 Python venv) ====================
714
+ // Issues 里最大的两类环境噪音(PowerShell 策略 / 商店占位 python 的 9009)里,
715
+ // 后者的根因是"没有能 import sqlcipher3 的 Python"。这条命令一次性解决:
716
+ // 在 ~/.weflow-cli/venv 建 venv 并装 requirements,之后 getPythonCommand() 优先用它。
717
+ program
718
+ .command('setup')
719
+ .description('创建自管 Python 虚拟环境 (~/.weflow-cli/venv) 并安装依赖;之后所有 Python 工作流优先用它')
720
+ .option('--dry-run', '只看计划:venv 位置、用哪个 Python 建、装哪些包(不动任何东西)')
721
+ .option('--yes', '跳过交互确认(机器执行路径)')
722
+ .option('--recreate', '删除现有 venv 后重建(默认已存在则复用)')
723
+ .option('--with-3x', '同时安装 requirements-3x.txt(微信 3.x 旧版密钥提取)')
724
+ .option('--with-voice', '同时安装 requirements-voice.txt(本机语音转写)')
725
+ .option('--non-interactive', '禁止交互提问;与 --yes 配合用于脚本')
726
+ .option('--json', '输出 JSON 格式')
727
+ .action(async (opts) => {
728
+ let plan
729
+ try {
730
+ plan = await planSetup({ recreate: !!opts.recreate, with3x: !!opts.with3x, withVoice: !!opts.withVoice })
731
+ } catch (error: any) {
732
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'SETUP_PLAN_FAILED', error: String(error?.message || error).slice(0, 200) })); process.exit(1) }
733
+ console.error(chalk.red(`✗ 计划失败: ${error?.message || error}`))
734
+ process.exit(1)
735
+ }
736
+
737
+ if (opts.dryRun || !opts.yes) {
738
+ const preview = {
739
+ success: true,
740
+ dryRun: true,
741
+ action: 'setup.venv',
742
+ venvDir: plan.venvDir,
743
+ venvExists: existsSync(plan.venvDir),
744
+ creator: plan.creator,
745
+ requirementFiles: plan.requirementFiles,
746
+ packages: plan.packages,
747
+ substitutions: plan.substitutions,
748
+ recreate: plan.recreate,
749
+ createsUnder: plan.createsUnder,
750
+ network: plan.network,
751
+ // 密钥/数据库一个都不碰:这条命令只写 ~/.weflow-cli/venv
752
+ readsLocalData: false,
753
+ }
754
+ if (opts.dryRun) {
755
+ if (opts.json) { console.log(JSON.stringify(preview)); return }
756
+ console.log(chalk.cyan('\nsetup 计划(--dry-run,不执行)'))
757
+ console.log(chalk.gray(` venv: ${plan.venvDir}${existsSync(plan.venvDir) ? '(已存在,默认复用)' : ''}`))
758
+ console.log(chalk.gray(` 创建者: ${plan.creator ?? '未找到可运行的 Python(先装 Python 3.10+)'}`))
759
+ console.log(chalk.gray(` 安装: ${plan.packages.join(' ')}`))
760
+ for (const sub of plan.substitutions) console.log(chalk.gray(` 替换: ${sub}`))
761
+ console.log(chalk.gray(' 网络请求: pip 访问 PyPI;不读聊天数据、不碰密钥\n'))
762
+ return
763
+ }
764
+ if (opts.json) {
765
+ console.log(JSON.stringify({ ...preview, success: false, dryRun: false, code: 'CONFIRMATION_REQUIRED' }))
766
+ process.exit(1)
767
+ }
768
+ if (process.env.WEFLOW_NON_INTERACTIVE || opts.nonInteractive) {
769
+ console.error(chalk.red('✗ 非交互模式需要 --yes'))
770
+ process.exit(1)
771
+ }
772
+ const { confirmed } = await inquirer.prompt([{
773
+ type: 'confirm',
774
+ name: 'confirmed',
775
+ message: `将在 ${plan.venvDir} 创建虚拟环境并安装 ${plan.packages.length} 个包(pip 需要访问 PyPI)。继续吗?`,
776
+ default: false,
777
+ }])
778
+ if (!confirmed) {
779
+ console.log(chalk.gray('已取消'))
780
+ return
781
+ }
782
+ }
783
+
784
+ const result = await runSetup(plan, (line) => console.log(chalk.gray(` ${line}`)))
785
+ if (opts.json) {
786
+ console.log(JSON.stringify(result))
787
+ if (!result.success) process.exit(1)
788
+ return
789
+ }
790
+ console.log(chalk.cyan('\nPython 环境 setup'))
791
+ for (const step of result.steps) {
792
+ console.log(` ${step.ok ? '✓' : '✗'} ${step.name}${step.detail ? chalk.gray(` — ${step.detail}`) : ''}`)
793
+ }
794
+ if (result.success) {
795
+ console.log(chalk.green(`\n完成。之后的 Python 工作流会优先用: ${result.venvPython}`))
796
+ console.log(chalk.gray(' 下一步: weflow-cli check 验证环境,weflow-cli init 初始化本地数据'))
797
+ } else {
798
+ console.log(chalk.red(`\n失败(${result.error ?? '见上方步骤'})。把 --json 输出贴到 issue 里最好排查。`))
799
+ process.exit(1)
800
+ }
801
+ })
802
+
803
+ // ==================== import(外部 IM 数据导入,D-129) ====================
804
+ // 边界是**文件契约**:读 qchat-cli `qce export --format json` 的产物,归一化落到
805
+ // output/imports/qq/,之后 chat-notes / search-index 就能把 QQ 会话当普通聊天线处理。
806
+ const importCmd = program
807
+ .command('import')
808
+ .description('导入外部 IM 的聊天记录(当前支持 qchat-cli 的 JSON 导出),并入本地知识管道')
809
+
810
+ importCmd
811
+ .command('qchat <path>')
812
+ .description('导入 qchat-cli 的 JSON 导出(单文件或目录;按 messageId 去重,重复导入安全)')
813
+ .option('--dry-run', '只看会导入什么:文件、会话、条数(不写任何东西)')
814
+ .option('--yes', '确认执行(写本地存储 output/imports/qq/)')
815
+ .option('--own-id <qq>', '可选:自己的 QQ 号,用来标 isSend(不给则该字段缺省为未知)')
816
+ .option('--json', '输出 JSON 格式')
817
+ .action(async (inputPath: string, opts) => {
818
+ let plan
819
+ try {
820
+ plan = planImportQchat(inputPath)
821
+ } catch (error: any) {
822
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'IMPORT_PLAN_FAILED', error: String(error?.message || error).slice(0, 200) })); process.exit(1) }
823
+ console.error(chalk.red(`✗ 计划失败: ${error?.message || error}`))
824
+ process.exit(1)
825
+ }
826
+
827
+ const preview = {
828
+ success: true,
829
+ dryRun: true,
830
+ action: 'import.qchat',
831
+ input: inputPath,
832
+ files: plan.files.map(f => ({
833
+ file: basename(f.file), ok: f.ok,
834
+ ...(f.reason ? { reason: f.reason } : {}),
835
+ sessions: f.sessions.map(s => ({ id: s.id, name: s.name, type: s.type, messages: s.messages })),
836
+ })),
837
+ sessionsFound: plan.sessionsFound,
838
+ messagesTotal: plan.messagesTotal,
839
+ storeRoot: plan.storeRoot,
840
+ ownIdProvided: Boolean(opts.ownId),
841
+ readsLocalData: true,
842
+ writesLocalStore: true,
843
+ invokesAI: false,
844
+ network: false,
845
+ }
846
+ if (opts.dryRun) {
847
+ if (opts.json) { console.log(JSON.stringify(preview)); return }
848
+ console.log(chalk.cyan('\nimport qchat 计划(--dry-run,不执行)'))
849
+ console.log(chalk.gray(` 存储: ${plan.storeRoot}`))
850
+ for (const f of plan.files) {
851
+ if (f.ok) {
852
+ for (const sess of f.sessions) {
853
+ console.log(chalk.gray(` ✓ ${basename(f.file)} → 会话「${sess.name}」(${sess.type},${sess.messages} 条)`))
854
+ }
855
+ } else {
856
+ console.log(chalk.yellow(` ✗ ${basename(f.file)} — ${f.reason}`))
857
+ }
858
+ }
859
+ if (opts.ownId) console.log(chalk.gray(` isSend 判定: 按自己的 QQ 号 ${String(opts.ownId).slice(0, 4)}…`))
860
+ console.log('')
861
+ return
862
+ }
863
+ if (!opts.yes) {
864
+ if (opts.json) {
865
+ console.log(JSON.stringify({ ...preview, success: false, dryRun: false, code: 'CONFIRMATION_REQUIRED' }))
866
+ process.exit(1)
867
+ }
868
+ console.error(chalk.red(`✗ 需要确认:将把 ${plan.messagesTotal} 条消息写入本地存储 ${plan.storeRoot}。加 --yes 执行`))
869
+ process.exit(1)
870
+ }
871
+
872
+ const result = runImportQchat(inputPath, opts.ownId ? String(opts.ownId) : undefined)
873
+ if (opts.json) {
874
+ console.log(JSON.stringify(result))
875
+ if (!result.success) process.exit(1)
876
+ return
877
+ }
878
+ if (!result.success) {
879
+ console.error(chalk.red(`✗ 没有可导入的文件(${result.filesSkipped} 个被跳过,原因见 --dry-run)`))
880
+ process.exit(1)
881
+ }
882
+ console.log(chalk.cyan('\n导入完成'))
883
+ for (const sess of result.sessions) {
884
+ console.log(chalk.green(` ✓ 「${sess.name}」共 ${sess.total} 条(新增 ${sess.added}、更新 ${sess.updated}、原样 ${sess.unchanged})`))
885
+ }
886
+ if (result.filesSkipped) console.log(chalk.yellow(` ⚠ 跳过 ${result.filesSkipped} 个不认识的文件`))
887
+ console.log(chalk.gray(` 存储: ${result.storeRoot}`))
888
+ console.log(chalk.gray(' 下一步: weflow-cli import list 查看;chat-notes --source qq 产知识卡;search-index --include-imports 进搜索'))
889
+ })
890
+
891
+ importCmd
892
+ .command('list')
893
+ .description('列出已导入的会话')
894
+ .option('--json', '输出 JSON 格式')
895
+ .action((opts) => {
896
+ const entries = listImports()
897
+ if (opts.json) { console.log(JSON.stringify({ success: true, sessions: entries })); return }
898
+ if (!entries.length) {
899
+ console.log(chalk.gray('\n还没有导入任何会话。先跑 weflow-cli import qchat <qchat 导出文件>'))
900
+ return
901
+ }
902
+ console.log(chalk.cyan(`\n已导入 ${entries.length} 个会话`))
903
+ for (const e of entries) {
904
+ const span = e.newestCreateTime
905
+ ? `${new Date((e.oldestCreateTime ?? 0) * 1000).toLocaleDateString('zh-CN')} ~ ${new Date(e.newestCreateTime * 1000).toLocaleDateString('zh-CN')}`
906
+ : '(无消息)'
907
+ console.log(` 「${e.name}」(${e.type},${e.messages} 条,${span})`)
908
+ }
909
+ console.log('')
910
+ })
911
+
912
+ importCmd
913
+ .command('show <sessionId>')
914
+ .description('读回一个已导入会话的消息(验证导入)')
915
+ .option('-n, --limit <n>', '只看最近 N 条(0 = 全部)', '20')
916
+ .option('--json', '输出 JSON 格式')
917
+ .action((sessionId: string, opts) => {
918
+ const limit = Number(opts.limit)
919
+ if (!Number.isInteger(limit) || limit < 0) {
920
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error: 'limit 必须是 ≥ 0 的整数' })); process.exit(1) }
921
+ console.error(chalk.red('✗ limit 必须是 ≥ 0 的整数'))
922
+ process.exit(1)
923
+ }
924
+ const data = readImportedSession(sessionId, limit)
925
+ if (!data) {
926
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'IMPORT_SESSION_NOT_FOUND' })); process.exit(1) }
927
+ console.error(chalk.red(`✗ 没有会话 ${sessionId} 的导入记录`))
928
+ process.exit(1)
929
+ }
930
+ if (opts.json) { console.log(JSON.stringify({ success: true, ...data })); return }
931
+ console.log(chalk.cyan(`\n「${data.session.name}」共 ${data.messages.length} 条(存储里全部 ${listImports().find(e => e.id === sessionId)?.messages ?? '?'} 条)`))
932
+ for (const m of data.messages) {
933
+ const who = m.isSend === undefined ? m.senderName : (m.isSend ? '我' : m.senderName)
934
+ console.log(chalk.gray(`[${new Date(m.createTime * 1000).toLocaleString('zh-CN')}] ${who}(${m.messageType}): `) + m.parsedContent.slice(0, 80))
935
+ }
936
+ console.log('')
937
+ })
938
+
678
939
  // ==================== init ====================
679
940
  program
680
941
  .command('init')
@@ -1806,6 +2067,155 @@ syncCmd
1806
2067
  if (!success) process.exit(1)
1807
2068
  })
1808
2069
 
2070
+ // 稳定游标的"路径可见"探针(D-027)。只读、本地、无确认门——与 `check` 同一副姿态:
2071
+ // 它是诊断,不是副作用。`stableCursor` 的结论**不会**因为它翻 true;它做的是把
2072
+ // "还差什么"逐条摊开给下游(she-love-me、MCP 客户端)看。
2073
+ syncCmd
2074
+ .command('cursor-probe')
2075
+ .description('探测当前消息后端离稳定增量游标还差什么(只读,NT 后端带实测采样)')
2076
+ .option('--json', '输出 JSON 格式')
2077
+ .option('--tables-per-shard <n>', '每分片采样的会话表数量(schema 检查始终全量)', '5')
2078
+ .option('--rows-per-table <n>', '每张表两端各采样的行数', '500')
2079
+ .action(async (opts) => {
2080
+ const parseBound = (raw: string, label: string): number | undefined => {
2081
+ const n = Number(raw)
2082
+ if (!Number.isInteger(n) || n < 1 || n > 10_000) {
2083
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error: `${label} 必须是 1-10000 的整数` })); process.exit(1) }
2084
+ console.error(chalk.red(`✗ ${label} 必须是 1-10000 的整数`))
2085
+ process.exit(1)
2086
+ }
2087
+ return n
2088
+ }
2089
+ let report
2090
+ try {
2091
+ report = await probeCursorReadiness({
2092
+ tablesPerShard: parseBound(opts.tablesPerShard, '--tables-per-shard'),
2093
+ rowsPerTable: parseBound(opts.rowsPerTable, '--rows-per-table'),
2094
+ })
2095
+ } catch (error: any) {
2096
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'CURSOR_PROBE_FAILED', error: error?.message || '探测失败' })); process.exit(1) }
2097
+ console.error(chalk.red(`✗ 探测失败: ${error?.message || error}`))
2098
+ process.exit(1)
2099
+ }
2100
+ if (opts.json) { console.log(JSON.stringify(report)); return }
2101
+
2102
+ console.log(chalk.cyan('\n稳定游标探针(结论不变:stableCursor = false,D-027)'))
2103
+ console.log(chalk.gray(` 后端: ${report.backend ?? '(未配置)'} — ${report.backendNote}`))
2104
+ console.log(chalk.gray(` 实测: ${report.probedLive ? `已采样(exhaustive=false)` : '未运行(该后端无实测探针或未初始化)'}`))
2105
+ for (const c of report.criteria) {
2106
+ const mark = c.met === null ? '?' : c.met ? '✓' : '✗'
2107
+ console.log(` ${mark} ${c.id}`)
2108
+ console.log(chalk.gray(` ${c.detail}`))
2109
+ }
2110
+ if (report.sample) {
2111
+ const sm = report.sample
2112
+ console.log(chalk.gray(` 采样: ${sm.rowsSampled} 行 / ${sm.tablesSampled} 表(共 ${sm.tablesTotal} 表,每表两端各 ${sm.rowsPerTable} 行)`))
2113
+ }
2114
+ console.log('')
2115
+ })
2116
+
2117
+ // ==================== ledger(资金流水:红包/转账,读 general.db) ====================
2118
+ // 2026-10-26 NT 普查产物(docs/NT_DATABASES.md)。如实边界写在脚本头:红包表无
2119
+ // 时间/金额列(在消息正文里,按 messageServerId 溯源是后续工作);状态枚举语义
2120
+ // 未逐值验证,原样输出不翻译。本地只读、不联网。
2121
+ program
2122
+ .command('ledger')
2123
+ .description('资金流水:红包与转账(读 general.db;本地只读)')
2124
+ .option('--talker <contact>', '只看某个会话(id 或备注/昵称子串)')
2125
+ .option('--days <n>', '只看最近 N 天(按转账时间;红包无时间列不受此限)')
2126
+ .option('--json', '输出 JSON 格式')
2127
+ .action(async (opts) => {
2128
+ const { execFile } = await import('child_process')
2129
+ const { promisify } = await import('util')
2130
+ const execFileAsync = promisify(execFile)
2131
+ const script = join(resolvePackageRoot(), 'scripts', 'wechat_ledger.py')
2132
+ const args = [script, '--json']
2133
+ if (opts.days !== undefined) {
2134
+ const days = parseCliInteger(opts.days, 'days', 1, 36_500, opts.json)
2135
+ args.push('--days', String(days))
2136
+ }
2137
+ try {
2138
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
2139
+ timeout: 120_000, maxBuffer: 10 * 1024 * 1024,
2140
+ // D-020:会话标识走环境变量,不进 argv
2141
+ env: createPythonProcessEnv(opts.talker ? { WEFLOW_TALKER: String(opts.talker) } : {}),
2142
+ })
2143
+ const payload = JSON.parse(stdout.slice(stdout.indexOf('{')))
2144
+ if (opts.json) { console.log(JSON.stringify(payload)); if (!payload.success) process.exit(1); return }
2145
+ if (!payload.success) { console.error(chalk.red(`✗ ${payload.error}`)); process.exit(1) }
2146
+ console.log(chalk.cyan(`
2147
+ 资金流水:红包 ${payload.redEnvelopes} 笔、转账 ${payload.transfers} 笔`))
2148
+ console.log(chalk.gray(' (红包无时间/金额列——在消息正文里,按 messageServerId 看先后;状态枚举原样,语义未验证)'))
2149
+ for (const r of payload.rows.slice(0, 40)) {
2150
+ const when = r.time ? new Date(r.time * 1000).toLocaleString('zh-CN', { hour12: false }) : '(无时间列)'
2151
+ const extra = r.kind === '红包'
2152
+ ? `status=${r.hbStatus}/${r.receiveStatus}`
2153
+ : `对方=${r.counterpartyDisplay || r.counterparty || '?'}${r.invalidTime ? ` invalid=${r.invalidTime}` : ''}`
2154
+ console.log(` [${when}] ${r.kind} · ${r.sessionDisplay} · ${r.direction} · ${extra}`)
2155
+ }
2156
+ if (payload.rows.length > 40) console.log(chalk.gray(` …共 ${payload.rows.length} 行(--json 看全表)`))
2157
+ console.log('')
2158
+ } catch (e: any) {
2159
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'LEDGER_FAILED', error: safeSubprocessError(e) })); process.exit(1) }
2160
+ console.error(chalk.red(`✗ ${safeSubprocessError(e)}`))
2161
+ process.exit(1)
2162
+ }
2163
+ })
2164
+
2165
+ // ==================== avatar(联系人头像,读 head_image.db) ====================
2166
+ // 2026-10-26 NT 数据库普查的直接产物(docs/NT_DATABASES.md):头像本体内嵌在
2167
+ // head_image.db 里(JPEG/PNG 完整字节,按 username 键控,一人多行=变更历史),
2168
+ // 比文件缓存可靠(清理不丢)。本地只读、不联网。
2169
+ program
2170
+ .command('avatar [contact]')
2171
+ .description('导出联系人头像(读 head_image.db;--list 看谁有头像,--all 批量导出最新头像)')
2172
+ .option('--list', '只列出有头像的 username 与头像数')
2173
+ .option('--all', '导出所有 username 的最新头像到 --out 目录')
2174
+ .option('--out <path>', '输出文件(单联系人)或目录(--all),默认 output/avatars/')
2175
+ .option('--json', '输出 JSON 格式')
2176
+ .action(async (contact: string | undefined, opts) => {
2177
+ if (!contact && !opts.list && !opts.all) {
2178
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error: '要给联系人,或用 --list / --all' })); process.exit(1) }
2179
+ console.error(chalk.red('✗ 要给联系人,或用 --list / --all'))
2180
+ process.exit(1)
2181
+ }
2182
+ if (contact && (opts.list || opts.all)) {
2183
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error: '联系人与 --list/--all 互斥' })); process.exit(1) }
2184
+ console.error(chalk.red('✗ 联系人与 --list/--all 互斥'))
2185
+ process.exit(1)
2186
+ }
2187
+ const { execFile } = await import('child_process')
2188
+ const { promisify } = await import('util')
2189
+ const execFileAsync = promisify(execFile)
2190
+ const script = join(resolvePackageRoot(), 'scripts', 'wechat_avatar.py')
2191
+ const args = [script, ...(opts.list ? ['--list'] : opts.all ? ['--all'] : []), '--json']
2192
+ // D-020:联系人标识走环境变量,不进 argv;输出路径同理(脚本 --out 缺省读它)
2193
+ const env = {
2194
+ ...(contact ? { WEFLOW_TALKER: String(contact) } : {}),
2195
+ ...(opts.out ? { WEFLOW_AVATAR_OUT: String(opts.out) } : {}),
2196
+ }
2197
+ try {
2198
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
2199
+ timeout: 120_000, maxBuffer: 10 * 1024 * 1024,
2200
+ env: createPythonProcessEnv(env),
2201
+ })
2202
+ const payload = JSON.parse(stdout.slice(stdout.indexOf('{')))
2203
+ if (opts.json) { console.log(JSON.stringify(payload)); if (!payload.success) process.exit(1); return }
2204
+ if (!payload.success) { console.error(chalk.red(`✗ ${payload.error}`)); process.exit(1) }
2205
+ if (payload.action === 'avatar.export') {
2206
+ console.log(chalk.green(`✓ 已导出「${payload.username}」的最新头像(${payload.bytes} 字节,历史 ${payload.historyCount} 张)→ ${payload.out}`))
2207
+ } else if (payload.action === 'avatar.export-all') {
2208
+ console.log(chalk.green(`✓ 已导出 ${payload.written} 个最新头像 → ${payload.dir}`))
2209
+ } else {
2210
+ console.log(payload.error || `共 ${payload.users?.length ?? '?'} 个 username 有头像(用 --json 看全表)`)
2211
+ }
2212
+ } catch (e: any) {
2213
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'AVATAR_FAILED', error: safeSubprocessError(e) })); process.exit(1) }
2214
+ console.error(chalk.red(`✗ ${safeSubprocessError(e)}`))
2215
+ process.exit(1)
2216
+ }
2217
+ })
2218
+
1809
2219
  // ==================== contacts ====================
1810
2220
  // contact-schema:读 contact.db 里**群**的 ext_buffer(本地只读:不联网、不调模型)
1811
2221
  //
@@ -5204,6 +5614,7 @@ program
5204
5614
  .option('--api-key <key>', 'DashScope embedding API key')
5205
5615
  .option('--days <n>', '聊天记录收最近多少天(默认 90)')
5206
5616
  .option('--article-days <n>', '日报文章收最近多少个日期目录(默认 30)')
5617
+ .option('--include-imports', '把 weflow-cli import qchat 导入的 QQ 会话也收进索引(聊天半边)')
5207
5618
  .option('--dry-run', '仅预览,不读取数据库、调用网络或写入索引')
5208
5619
  .option('--yes', '确认构建或重建索引')
5209
5620
  .option('--json', '输出 JSON 格式')
@@ -5252,6 +5663,7 @@ program
5252
5663
  mode: opts.full ? 'full' : 'incremental',
5253
5664
  chatDays,
5254
5665
  articleDays,
5666
+ includeImports: !!opts.includeImports,
5255
5667
  readsLocalData: true,
5256
5668
  usesCloudEmbedding: true,
5257
5669
  replacesExistingIndex: !!opts.full,
@@ -5289,6 +5701,7 @@ program
5289
5701
  const args: string[] = [script, 'build', '--days', String(chatDays),
5290
5702
  '--article-days', String(articleDays)]
5291
5703
  if (opts.full) args.push('--full')
5704
+ if (opts.includeImports) args.push('--include-imports')
5292
5705
  try {
5293
5706
  const { stdout } = await execFileAsync(getPythonCommand(), args, {
5294
5707
  // 30 分钟:全量重建实测要跑二十分钟以上(16k 条 / 1500+ 批),而这里原来给的是 5 分钟 --
@@ -5973,6 +6386,7 @@ program
5973
6386
  .description('把最近一段时间的会话整理成知识卡(之后用 wiki compile 聚成概念页)—— **只写本地文件,不发送**')
5974
6387
  .option('--days <n>', '看最近多少天', '30')
5975
6388
  .option('--limit <n>', '最多处理几个会话', '40')
6389
+ .option('--source <source>', '聊天来源:wechat(默认)/ qq(只读 import qchat 导入的会话,不碰微信库)/ all(都要)', 'wechat')
5976
6390
  .option('--dry-run', '仅预览:几个会话、多少字符会发给生成模型(只读本地,零出境)')
5977
6391
  .option('--yes', '确认把对话发送给生成模型')
5978
6392
  .option('--transcribe-voice',
@@ -5986,12 +6400,13 @@ program
5986
6400
  const script = join(pkgRoot, 'scripts', 'chat_notes.py')
5987
6401
  const days = parseCliInteger(opts.days, 'days', 1, 3650, opts.json)
5988
6402
  const limit = parseCliInteger(opts.limit, 'limit', 1, 200, opts.json)
6403
+ requireCliChoice(opts.source, ['wechat', 'qq', 'all'], 'source', opts.json)
5989
6404
  // 与 `draft` 同一条纪律:**"确认过了"只用一个变量**,参数在确认之后才拼
5990
6405
  let confirmed = !!opts.yes
5991
6406
  if (!opts.dryRun && !confirmed) {
5992
6407
  const preview = {
5993
6408
  success: false, dryRun: false, action: 'chat-notes',
5994
- code: 'CONFIRMATION_REQUIRED', days, limit,
6409
+ code: 'CONFIRMATION_REQUIRED', days, limit, source: opts.source,
5995
6410
  readsLocalChat: true, invokesAI: true, writesLocalFiles: true, sendsNothing: true,
5996
6411
  }
5997
6412
  if (opts.json) {
@@ -6011,6 +6426,7 @@ program
6011
6426
  }
6012
6427
  }
6013
6428
  const args = [script, '--days', String(days), '--limit', String(limit),
6429
+ '--source', String(opts.source),
6014
6430
  ...(confirmed ? ['--yes'] : []),
6015
6431
  ...(opts.transcribeVoice ? ['--transcribe-voice'] : []),
6016
6432
  ...(opts.json ? ['--json'] : []),
@@ -7455,12 +7871,12 @@ program
7455
7871
 
7456
7872
  try {
7457
7873
  const args = [script, '--years', String(opts.years || '1,3,5'), '--limit', String(limit)]
7458
- if (opts.talker) args.push('--talker', String(opts.talker))
7459
7874
  if (opts.json) args.push('--json')
7875
+ // D-020:会话标识不进子进程 argv(进程列表里看得见),走 WEFLOW_TALKER。
7460
7876
  const { stdout } = await execFileAsync(getPythonCommand(), args, {
7461
7877
  timeout: 300_000,
7462
7878
  maxBuffer: 10 * 1024 * 1024,
7463
- env: pythonProcessEnv(),
7879
+ env: createPythonProcessEnv(opts.talker ? { WEFLOW_TALKER: String(opts.talker) } : {}),
7464
7880
  })
7465
7881
  console.log(stdout)
7466
7882
  } catch (e: any) {
@@ -7539,12 +7955,12 @@ program
7539
7955
 
7540
7956
  try {
7541
7957
  const args = [script, '--days', String(days), '--limit', String(limit)]
7542
- if (opts.talker) args.push('--talker', String(opts.talker))
7543
7958
  if (opts.json) args.push('--json')
7959
+ // D-020:同 today——会话标识走 WEFLOW_TALKER,不进 argv。
7544
7960
  const { stdout } = await execFileAsync(getPythonCommand(), args, {
7545
7961
  timeout: 300_000,
7546
7962
  maxBuffer: 10 * 1024 * 1024,
7547
- env: pythonProcessEnv(),
7963
+ env: createPythonProcessEnv(opts.talker ? { WEFLOW_TALKER: String(opts.talker) } : {}),
7548
7964
  })
7549
7965
  console.log(stdout)
7550
7966
  } catch (e: any) {
@@ -7602,6 +8018,84 @@ program
7602
8018
  }
7603
8019
  })
7604
8020
 
8021
+ // ==================== media(媒体精确匹配)====================
8022
+ program
8023
+ .command('media')
8024
+ .description('媒体精确匹配:用 hardlink.db 把 md5 直接对到本地文件(图片/文件/视频;本地只读)')
8025
+ .option('--md5 <prefix>', '按 md5 前缀查')
8026
+ .option('--type <kind>', 'image | file | video | all', 'all')
8027
+ .option('--limit <n>', '最多列几条', '20')
8028
+ .option('--missing-only', '只列文件已不在本地的(微信会清理)')
8029
+ .option('--json', '输出 JSON 格式')
8030
+ .action(async (opts) => {
8031
+ const { execFile } = await import('child_process')
8032
+ const { promisify } = await import('util')
8033
+ const execFileAsync = promisify(execFile)
8034
+ const script = join(resolvePackageRoot(), 'scripts', 'media_index.py')
8035
+ const limit = parseCliInteger(opts.limit, 'limit', 1, 5000, opts.json)
8036
+
8037
+ try {
8038
+ const args = [script, '--type', String(opts.type || 'all'), '--limit', String(limit)]
8039
+ if (opts.md5) args.push('--md5', String(opts.md5))
8040
+ if (opts.missingOnly) args.push('--missing-only')
8041
+ if (opts.json) args.push('--json')
8042
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
8043
+ timeout: 300_000,
8044
+ maxBuffer: 16 * 1024 * 1024,
8045
+ env: pythonProcessEnv(),
8046
+ })
8047
+ console.log(stdout)
8048
+ } catch (e: any) {
8049
+ if (opts.json) {
8050
+ console.log(JSON.stringify({
8051
+ success: false,
8052
+ code: 'MEDIA_FAILED',
8053
+ error: '媒体索引不可用,请运行 check --json 检查配置和依赖',
8054
+ }))
8055
+ process.exit(1)
8056
+ }
8057
+ console.error(chalk.red(`\n${safeSubprocessError(e, '查询失败')}`))
8058
+ process.exit(1)
8059
+ }
8060
+ })
8061
+
8062
+ // ==================== replay(关系时间重放)====================
8063
+ program
8064
+ .command('replay')
8065
+ .description('关系网络时间重放:按月展开,生成可播放的单文件 HTML(本地、离线、不调模型)')
8066
+ .option('--out <file>', '输出路径', 'output/replay.html')
8067
+ .option('--min-weight <number>', '当月互动量门槛,低于此不计入', '10')
8068
+ .option('--json', '只输出聚合结果(验收用)')
8069
+ .action(async (opts) => {
8070
+ const { execFile } = await import('child_process')
8071
+ const { promisify } = await import('util')
8072
+ const execFileAsync = promisify(execFile)
8073
+ const script = join(resolvePackageRoot(), 'scripts', 'replay.py')
8074
+ const minWeight = parseCliInteger(opts.minWeight, 'min-weight', 1, 10000, opts.json)
8075
+
8076
+ try {
8077
+ const args = [script, '--min-weight', String(minWeight), '--out', String(opts.out)]
8078
+ if (opts.json) args.push('--json')
8079
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
8080
+ timeout: 600_000,
8081
+ maxBuffer: 64 * 1024 * 1024,
8082
+ env: pythonProcessEnv(),
8083
+ })
8084
+ console.log(stdout)
8085
+ } catch (e: any) {
8086
+ if (opts.json) {
8087
+ console.log(JSON.stringify({
8088
+ success: false,
8089
+ code: 'REPLAY_FAILED',
8090
+ error: '时间重放不可用,请运行 check --json 检查配置和依赖',
8091
+ }))
8092
+ process.exit(1)
8093
+ }
8094
+ console.error(chalk.red(`\n${safeSubprocessError(e, '生成失败')}`))
8095
+ process.exit(1)
8096
+ }
8097
+ })
8098
+
7605
8099
  // ==================== timeline(某号观点演变)====================
7606
8100
  program
7607
8101
  .command('timeline')
@@ -7921,6 +8415,12 @@ program
7921
8415
  // 只报条数与最近时间(不读崩溃内容)
7922
8416
  crashRecords: client.crashRecords,
7923
8417
  caveats: client.caveats,
8418
+ // D-130:本机实际加载的版本 vs 本包验证过的版本(判定只有这一份实现)
8419
+ compatibility: {
8420
+ verifiedVersion: WECHAT_VERIFIED.version,
8421
+ verifiedAt: WECHAT_VERIFIED.verifiedAt,
8422
+ verdict: compareWechatVersions(client.version, WECHAT_VERIFIED.version),
8423
+ },
7924
8424
  },
7925
8425
  }
7926
8426
  return data
@@ -7960,6 +8460,7 @@ program
7960
8460
  dataDirMatchesConfig: data.wechatClient.dataDirMatchesConfig,
7961
8461
  dataDirs: data.wechatClient.dataDirs,
7962
8462
  unusableVersionDirs: data.wechatClient.unusableVersionDirs,
8463
+ compatibility: data.wechatClient.compatibility,
7963
8464
  },
7964
8465
  }))
7965
8466
  return
@@ -8018,6 +8519,14 @@ program
8018
8519
  }
8019
8520
  }
8020
8521
 
8522
+ // 3.5 微信版本兼容(D-130):本机加载的 vs 本包验证过的
8523
+ {
8524
+ const verdict = compareWechatVersions(client.version, WECHAT_VERIFIED.version)
8525
+ const mark = verdict === 'verified' ? chalk.green('✓') : verdict === 'newer-than-verified' ? chalk.yellow('⚠') : chalk.gray('·')
8526
+ console.log(chalk.white('\n微信版本兼容:'))
8527
+ console.log(` ${mark} ${client.version ?? '未知(微信没在运行)'} · ${describeCompatVerdict(verdict, WECHAT_VERIFIED.version)}`)
8528
+ }
8529
+
8021
8530
  // 4. Config
8022
8531
  console.log(chalk.white('\n配置状态:'))
8023
8532
  const configured = configService.isConfigured()