weflow-cli 1.15.1 → 1.17.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 (100) hide show
  1. package/CHANGELOG.md +59 -1
  2. package/OPERATIONS.md +16 -1
  3. package/README.en.md +21 -2
  4. package/README.md +34 -6
  5. package/bin/weflow-cli.ts +389 -6
  6. package/dist/bin/weflow-cli.js +422 -8
  7. package/dist/bin/weflow-cli.js.map +1 -1
  8. package/dist/src/core/ntCore.d.ts +47 -0
  9. package/dist/src/core/ntCore.d.ts.map +1 -1
  10. package/dist/src/core/ntCore.js +24 -0
  11. package/dist/src/core/ntCore.js.map +1 -1
  12. package/dist/src/services/assistantService.d.ts +8 -1
  13. package/dist/src/services/assistantService.d.ts.map +1 -1
  14. package/dist/src/services/assistantService.js +47 -8
  15. package/dist/src/services/assistantService.js.map +1 -1
  16. package/dist/src/services/assistantTools.d.ts +8 -0
  17. package/dist/src/services/assistantTools.d.ts.map +1 -1
  18. package/dist/src/services/assistantTools.js +67 -11
  19. package/dist/src/services/assistantTools.js.map +1 -1
  20. package/dist/src/services/configService.d.ts.map +1 -1
  21. package/dist/src/services/configService.js +3 -2
  22. package/dist/src/services/configService.js.map +1 -1
  23. package/dist/src/services/cursorProbe.d.ts +44 -0
  24. package/dist/src/services/cursorProbe.d.ts.map +1 -0
  25. package/dist/src/services/cursorProbe.js +183 -0
  26. package/dist/src/services/cursorProbe.js.map +1 -0
  27. package/dist/src/services/qchatImport.d.ts +89 -0
  28. package/dist/src/services/qchatImport.d.ts.map +1 -0
  29. package/dist/src/services/qchatImport.js +273 -0
  30. package/dist/src/services/qchatImport.js.map +1 -0
  31. package/dist/src/services/setupVenv.d.ts +43 -0
  32. package/dist/src/services/setupVenv.d.ts.map +1 -0
  33. package/dist/src/services/setupVenv.js +198 -0
  34. package/dist/src/services/setupVenv.js.map +1 -0
  35. package/dist/src/utils/python.d.ts +10 -0
  36. package/dist/src/utils/python.d.ts.map +1 -1
  37. package/dist/src/utils/python.js +27 -4
  38. package/dist/src/utils/python.js.map +1 -1
  39. package/docs/DATA_CONTRACT.md +25 -0
  40. package/docs/DECISIONS.md +92 -30
  41. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
  42. package/docs/HEALTH-CHECK.md +104 -104
  43. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
  44. package/docs/NEXT_ACTIONS.md +236 -236
  45. package/docs/NPM-PUBLISH.md +130 -130
  46. package/docs/POSITIONING.md +68 -68
  47. package/docs/PROJECT_STATE.md +6 -3
  48. package/docs/REPLAY.md +169 -0
  49. package/docs/SYNC_CONTRACT.md +197 -180
  50. package/package.json +89 -88
  51. package/requirements-3x.txt +4 -4
  52. package/requirements-voice.txt +15 -15
  53. package/resources/js/graph3d/NOTICE.txt +35 -35
  54. package/resources/js/graph3d/d3-binarytree.min.js +2 -2
  55. package/resources/js/graph3d/d3-dispatch.min.js +2 -2
  56. package/resources/js/graph3d/d3-force-3d.min.js +2 -2
  57. package/resources/js/graph3d/d3-octree.min.js +2 -2
  58. package/resources/js/graph3d/d3-quadtree.min.js +2 -2
  59. package/resources/js/graph3d/d3-timer.min.js +2 -2
  60. package/resources/js/graph3d/three.min.js +6 -6
  61. package/resources/js/graph3d/viewer2d.js +550 -448
  62. package/resources/panel/quick-menu.cjs +197 -197
  63. package/scripts/_utils.py +1338 -1338
  64. package/scripts/article_notes.py +423 -423
  65. package/scripts/bonds.py +11 -0
  66. package/scripts/chat_notes.py +86 -3
  67. package/scripts/check_readme_commands.py +427 -426
  68. package/scripts/contact_schema.py +339 -339
  69. package/scripts/create_reading_notes.py +11 -11
  70. package/scripts/daily_task.ps1 +213 -177
  71. package/scripts/fav_notes.py +321 -321
  72. package/scripts/graph_2d.py +156 -151
  73. package/scripts/graph_3d.py +672 -646
  74. package/scripts/health_check.py +473 -473
  75. package/scripts/imported_chats.py +68 -0
  76. package/scripts/install_tasks.ps1 +105 -91
  77. package/scripts/leads.py +179 -0
  78. package/scripts/network.py +206 -206
  79. package/scripts/nt_decrypt.py +135 -1
  80. package/scripts/on_this_day.py +178 -178
  81. package/scripts/opinion_timeline.py +138 -138
  82. package/scripts/promises.py +185 -185
  83. package/scripts/replay.py +803 -0
  84. package/scripts/run_hidden.vbs +25 -0
  85. package/scripts/semantic_search.py +73 -5
  86. package/scripts/user_notes.py +315 -315
  87. package/scripts/voice_transcribe.py +139 -139
  88. package/scripts/watch_issues.py +212 -212
  89. package/scripts/watch_mail.py +131 -131
  90. package/src/core/ntCore.ts +59 -0
  91. package/src/services/assistantService.ts +48 -8
  92. package/src/services/assistantTools.ts +73 -10
  93. package/src/services/configService.ts +3 -2
  94. package/src/services/cursorProbe.ts +222 -0
  95. package/src/services/qchatImport.ts +327 -0
  96. package/src/services/setupVenv.ts +238 -0
  97. package/src/services/syncService.ts +363 -363
  98. package/src/services/syncState.ts +275 -275
  99. package/src/services/wechatMedia.ts +166 -166
  100. 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'
@@ -312,7 +315,30 @@ program
312
315
  versionedContract: 'weflow-message/v1',
313
316
  rawContractPreserved: true,
314
317
  coverage: ['requestedFrom', 'requestedTo', 'requestedLimit', 'returned', 'mayHaveMore', 'oldestCreateTime', 'newestCreateTime'],
315
- incrementalRead: { mode: 'overlapping-time-window', stableCursor: false },
318
+ incrementalRead: {
319
+ mode: 'overlapping-time-window',
320
+ stableCursor: false,
321
+ // D-027 的另一半:光写 false 是句结论,这里把 false → true 的**路径**
322
+ // 摊开。probe 指向的那条命令会实测 NT 数据侧判据(采样有界、结果里
323
+ // 自带 exhaustive=false);backends 里的字段是代码事实,改读取器时同步改。
324
+ cursorReadiness: {
325
+ probe: 'sync cursor-probe --json',
326
+ criteria: [
327
+ 'stable-identity: 每条消息都有非零且不重复的 server_id(缺失时身份退化为按分片的 localId+createTime,不能跨分片续读)',
328
+ 'globally-comparable-order: 跨分片合并的顺序键可比(现在是 createTime+localId,localId 不跨分片可比)',
329
+ 'window-pushdown: 时间窗下推进读取(现在只有 NT 后端)',
330
+ 'coverage-report: 后端能报告本次读取是否完整(现在只有 NT 的分片报告)',
331
+ 'every-backend: 上述各项在所有消息后端同时成立(D-027 的总门)',
332
+ ],
333
+ backends: {
334
+ 'nt': { identity: 'server_id(退化为 localId+createTime)', windowPushdown: true, coverageReport: true, liveProbe: true },
335
+ 'raw-sqlite-4x': { identity: 'MsgSvrID 列被读取、覆盖率未实测', windowPushdown: false, coverageReport: false, liveProbe: false },
336
+ 'wcdb-4x': { identity: 'server_id 随原生 API 返回、未实测', windowPushdown: false, coverageReport: false, liveProbe: false },
337
+ 'legacy-3x': { identity: 'MsgSvrID 列被读取、覆盖率未实测', windowPushdown: false, coverageReport: false, liveProbe: false },
338
+ },
339
+ advertisedWhen: 'every backend reports true on every criterion above',
340
+ },
341
+ },
316
342
  },
317
343
  favorites: {
318
344
  cli: 'fav list --json',
@@ -675,6 +701,232 @@ program
675
701
  console.log(JSON.stringify(data, null, 2))
676
702
  })
677
703
 
704
+ // ==================== setup(自管 Python venv) ====================
705
+ // Issues 里最大的两类环境噪音(PowerShell 策略 / 商店占位 python 的 9009)里,
706
+ // 后者的根因是"没有能 import sqlcipher3 的 Python"。这条命令一次性解决:
707
+ // 在 ~/.weflow-cli/venv 建 venv 并装 requirements,之后 getPythonCommand() 优先用它。
708
+ program
709
+ .command('setup')
710
+ .description('创建自管 Python 虚拟环境 (~/.weflow-cli/venv) 并安装依赖;之后所有 Python 工作流优先用它')
711
+ .option('--dry-run', '只看计划:venv 位置、用哪个 Python 建、装哪些包(不动任何东西)')
712
+ .option('--yes', '跳过交互确认(机器执行路径)')
713
+ .option('--recreate', '删除现有 venv 后重建(默认已存在则复用)')
714
+ .option('--with-3x', '同时安装 requirements-3x.txt(微信 3.x 旧版密钥提取)')
715
+ .option('--with-voice', '同时安装 requirements-voice.txt(本机语音转写)')
716
+ .option('--non-interactive', '禁止交互提问;与 --yes 配合用于脚本')
717
+ .option('--json', '输出 JSON 格式')
718
+ .action(async (opts) => {
719
+ let plan
720
+ try {
721
+ plan = await planSetup({ recreate: !!opts.recreate, with3x: !!opts.with3x, withVoice: !!opts.withVoice })
722
+ } catch (error: any) {
723
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'SETUP_PLAN_FAILED', error: String(error?.message || error).slice(0, 200) })); process.exit(1) }
724
+ console.error(chalk.red(`✗ 计划失败: ${error?.message || error}`))
725
+ process.exit(1)
726
+ }
727
+
728
+ if (opts.dryRun || !opts.yes) {
729
+ const preview = {
730
+ success: true,
731
+ dryRun: true,
732
+ action: 'setup.venv',
733
+ venvDir: plan.venvDir,
734
+ venvExists: existsSync(plan.venvDir),
735
+ creator: plan.creator,
736
+ requirementFiles: plan.requirementFiles,
737
+ packages: plan.packages,
738
+ substitutions: plan.substitutions,
739
+ recreate: plan.recreate,
740
+ createsUnder: plan.createsUnder,
741
+ network: plan.network,
742
+ // 密钥/数据库一个都不碰:这条命令只写 ~/.weflow-cli/venv
743
+ readsLocalData: false,
744
+ }
745
+ if (opts.dryRun) {
746
+ if (opts.json) { console.log(JSON.stringify(preview)); return }
747
+ console.log(chalk.cyan('\nsetup 计划(--dry-run,不执行)'))
748
+ console.log(chalk.gray(` venv: ${plan.venvDir}${existsSync(plan.venvDir) ? '(已存在,默认复用)' : ''}`))
749
+ console.log(chalk.gray(` 创建者: ${plan.creator ?? '未找到可运行的 Python(先装 Python 3.10+)'}`))
750
+ console.log(chalk.gray(` 安装: ${plan.packages.join(' ')}`))
751
+ for (const sub of plan.substitutions) console.log(chalk.gray(` 替换: ${sub}`))
752
+ console.log(chalk.gray(' 网络请求: pip 访问 PyPI;不读聊天数据、不碰密钥\n'))
753
+ return
754
+ }
755
+ if (opts.json) {
756
+ console.log(JSON.stringify({ ...preview, success: false, dryRun: false, code: 'CONFIRMATION_REQUIRED' }))
757
+ process.exit(1)
758
+ }
759
+ if (process.env.WEFLOW_NON_INTERACTIVE || opts.nonInteractive) {
760
+ console.error(chalk.red('✗ 非交互模式需要 --yes'))
761
+ process.exit(1)
762
+ }
763
+ const { confirmed } = await inquirer.prompt([{
764
+ type: 'confirm',
765
+ name: 'confirmed',
766
+ message: `将在 ${plan.venvDir} 创建虚拟环境并安装 ${plan.packages.length} 个包(pip 需要访问 PyPI)。继续吗?`,
767
+ default: false,
768
+ }])
769
+ if (!confirmed) {
770
+ console.log(chalk.gray('已取消'))
771
+ return
772
+ }
773
+ }
774
+
775
+ const result = await runSetup(plan, (line) => console.log(chalk.gray(` ${line}`)))
776
+ if (opts.json) {
777
+ console.log(JSON.stringify(result))
778
+ if (!result.success) process.exit(1)
779
+ return
780
+ }
781
+ console.log(chalk.cyan('\nPython 环境 setup'))
782
+ for (const step of result.steps) {
783
+ console.log(` ${step.ok ? '✓' : '✗'} ${step.name}${step.detail ? chalk.gray(` — ${step.detail}`) : ''}`)
784
+ }
785
+ if (result.success) {
786
+ console.log(chalk.green(`\n完成。之后的 Python 工作流会优先用: ${result.venvPython}`))
787
+ console.log(chalk.gray(' 下一步: weflow-cli check 验证环境,weflow-cli init 初始化本地数据'))
788
+ } else {
789
+ console.log(chalk.red(`\n失败(${result.error ?? '见上方步骤'})。把 --json 输出贴到 issue 里最好排查。`))
790
+ process.exit(1)
791
+ }
792
+ })
793
+
794
+ // ==================== import(外部 IM 数据导入,D-129) ====================
795
+ // 边界是**文件契约**:读 qchat-cli `qce export --format json` 的产物,归一化落到
796
+ // output/imports/qq/,之后 chat-notes / search-index 就能把 QQ 会话当普通聊天线处理。
797
+ const importCmd = program
798
+ .command('import')
799
+ .description('导入外部 IM 的聊天记录(当前支持 qchat-cli 的 JSON 导出),并入本地知识管道')
800
+
801
+ importCmd
802
+ .command('qchat <path>')
803
+ .description('导入 qchat-cli 的 JSON 导出(单文件或目录;按 messageId 去重,重复导入安全)')
804
+ .option('--dry-run', '只看会导入什么:文件、会话、条数(不写任何东西)')
805
+ .option('--yes', '确认执行(写本地存储 output/imports/qq/)')
806
+ .option('--own-id <qq>', '可选:自己的 QQ 号,用来标 isSend(不给则该字段缺省为未知)')
807
+ .option('--json', '输出 JSON 格式')
808
+ .action(async (inputPath: string, opts) => {
809
+ let plan
810
+ try {
811
+ plan = planImportQchat(inputPath)
812
+ } catch (error: any) {
813
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'IMPORT_PLAN_FAILED', error: String(error?.message || error).slice(0, 200) })); process.exit(1) }
814
+ console.error(chalk.red(`✗ 计划失败: ${error?.message || error}`))
815
+ process.exit(1)
816
+ }
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) { console.log(JSON.stringify(preview)); return }
839
+ console.log(chalk.cyan('\nimport qchat 计划(--dry-run,不执行)'))
840
+ console.log(chalk.gray(` 存储: ${plan.storeRoot}`))
841
+ for (const f of plan.files) {
842
+ if (f.ok) {
843
+ for (const sess of f.sessions) {
844
+ console.log(chalk.gray(` ✓ ${basename(f.file)} → 会话「${sess.name}」(${sess.type},${sess.messages} 条)`))
845
+ }
846
+ } else {
847
+ console.log(chalk.yellow(` ✗ ${basename(f.file)} — ${f.reason}`))
848
+ }
849
+ }
850
+ if (opts.ownId) console.log(chalk.gray(` isSend 判定: 按自己的 QQ 号 ${String(opts.ownId).slice(0, 4)}…`))
851
+ console.log('')
852
+ return
853
+ }
854
+ if (!opts.yes) {
855
+ if (opts.json) {
856
+ console.log(JSON.stringify({ ...preview, success: false, dryRun: false, code: 'CONFIRMATION_REQUIRED' }))
857
+ process.exit(1)
858
+ }
859
+ console.error(chalk.red(`✗ 需要确认:将把 ${plan.messagesTotal} 条消息写入本地存储 ${plan.storeRoot}。加 --yes 执行`))
860
+ process.exit(1)
861
+ }
862
+
863
+ const result = runImportQchat(inputPath, opts.ownId ? String(opts.ownId) : undefined)
864
+ if (opts.json) {
865
+ console.log(JSON.stringify(result))
866
+ if (!result.success) process.exit(1)
867
+ return
868
+ }
869
+ if (!result.success) {
870
+ console.error(chalk.red(`✗ 没有可导入的文件(${result.filesSkipped} 个被跳过,原因见 --dry-run)`))
871
+ process.exit(1)
872
+ }
873
+ console.log(chalk.cyan('\n导入完成'))
874
+ for (const sess of result.sessions) {
875
+ console.log(chalk.green(` ✓ 「${sess.name}」共 ${sess.total} 条(新增 ${sess.added}、更新 ${sess.updated}、原样 ${sess.unchanged})`))
876
+ }
877
+ if (result.filesSkipped) console.log(chalk.yellow(` ⚠ 跳过 ${result.filesSkipped} 个不认识的文件`))
878
+ console.log(chalk.gray(` 存储: ${result.storeRoot}`))
879
+ console.log(chalk.gray(' 下一步: weflow-cli import list 查看;chat-notes --source qq 产知识卡;search-index --include-imports 进搜索'))
880
+ })
881
+
882
+ importCmd
883
+ .command('list')
884
+ .description('列出已导入的会话')
885
+ .option('--json', '输出 JSON 格式')
886
+ .action((opts) => {
887
+ const entries = listImports()
888
+ if (opts.json) { console.log(JSON.stringify({ success: true, sessions: entries })); return }
889
+ if (!entries.length) {
890
+ console.log(chalk.gray('\n还没有导入任何会话。先跑 weflow-cli import qchat <qchat 导出文件>'))
891
+ return
892
+ }
893
+ console.log(chalk.cyan(`\n已导入 ${entries.length} 个会话`))
894
+ for (const e of entries) {
895
+ const span = e.newestCreateTime
896
+ ? `${new Date((e.oldestCreateTime ?? 0) * 1000).toLocaleDateString('zh-CN')} ~ ${new Date(e.newestCreateTime * 1000).toLocaleDateString('zh-CN')}`
897
+ : '(无消息)'
898
+ console.log(` 「${e.name}」(${e.type},${e.messages} 条,${span})`)
899
+ }
900
+ console.log('')
901
+ })
902
+
903
+ importCmd
904
+ .command('show <sessionId>')
905
+ .description('读回一个已导入会话的消息(验证导入)')
906
+ .option('-n, --limit <n>', '只看最近 N 条(0 = 全部)', '20')
907
+ .option('--json', '输出 JSON 格式')
908
+ .action((sessionId: string, opts) => {
909
+ const limit = Number(opts.limit)
910
+ if (!Number.isInteger(limit) || limit < 0) {
911
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error: 'limit 必须是 ≥ 0 的整数' })); process.exit(1) }
912
+ console.error(chalk.red('✗ limit 必须是 ≥ 0 的整数'))
913
+ process.exit(1)
914
+ }
915
+ const data = readImportedSession(sessionId, limit)
916
+ if (!data) {
917
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'IMPORT_SESSION_NOT_FOUND' })); process.exit(1) }
918
+ console.error(chalk.red(`✗ 没有会话 ${sessionId} 的导入记录`))
919
+ process.exit(1)
920
+ }
921
+ if (opts.json) { console.log(JSON.stringify({ success: true, ...data })); return }
922
+ console.log(chalk.cyan(`\n「${data.session.name}」共 ${data.messages.length} 条(存储里全部 ${listImports().find(e => e.id === sessionId)?.messages ?? '?'} 条)`))
923
+ for (const m of data.messages) {
924
+ const who = m.isSend === undefined ? m.senderName : (m.isSend ? '我' : m.senderName)
925
+ console.log(chalk.gray(`[${new Date(m.createTime * 1000).toLocaleString('zh-CN')}] ${who}(${m.messageType}): `) + m.parsedContent.slice(0, 80))
926
+ }
927
+ console.log('')
928
+ })
929
+
678
930
  // ==================== init ====================
679
931
  program
680
932
  .command('init')
@@ -1806,6 +2058,53 @@ syncCmd
1806
2058
  if (!success) process.exit(1)
1807
2059
  })
1808
2060
 
2061
+ // 稳定游标的"路径可见"探针(D-027)。只读、本地、无确认门——与 `check` 同一副姿态:
2062
+ // 它是诊断,不是副作用。`stableCursor` 的结论**不会**因为它翻 true;它做的是把
2063
+ // "还差什么"逐条摊开给下游(she-love-me、MCP 客户端)看。
2064
+ syncCmd
2065
+ .command('cursor-probe')
2066
+ .description('探测当前消息后端离稳定增量游标还差什么(只读,NT 后端带实测采样)')
2067
+ .option('--json', '输出 JSON 格式')
2068
+ .option('--tables-per-shard <n>', '每分片采样的会话表数量(schema 检查始终全量)', '5')
2069
+ .option('--rows-per-table <n>', '每张表两端各采样的行数', '500')
2070
+ .action(async (opts) => {
2071
+ const parseBound = (raw: string, label: string): number | undefined => {
2072
+ const n = Number(raw)
2073
+ if (!Number.isInteger(n) || n < 1 || n > 10_000) {
2074
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error: `${label} 必须是 1-10000 的整数` })); process.exit(1) }
2075
+ console.error(chalk.red(`✗ ${label} 必须是 1-10000 的整数`))
2076
+ process.exit(1)
2077
+ }
2078
+ return n
2079
+ }
2080
+ let report
2081
+ try {
2082
+ report = await probeCursorReadiness({
2083
+ tablesPerShard: parseBound(opts.tablesPerShard, '--tables-per-shard'),
2084
+ rowsPerTable: parseBound(opts.rowsPerTable, '--rows-per-table'),
2085
+ })
2086
+ } catch (error: any) {
2087
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'CURSOR_PROBE_FAILED', error: error?.message || '探测失败' })); process.exit(1) }
2088
+ console.error(chalk.red(`✗ 探测失败: ${error?.message || error}`))
2089
+ process.exit(1)
2090
+ }
2091
+ if (opts.json) { console.log(JSON.stringify(report)); return }
2092
+
2093
+ console.log(chalk.cyan('\n稳定游标探针(结论不变:stableCursor = false,D-027)'))
2094
+ console.log(chalk.gray(` 后端: ${report.backend ?? '(未配置)'} — ${report.backendNote}`))
2095
+ console.log(chalk.gray(` 实测: ${report.probedLive ? `已采样(exhaustive=false)` : '未运行(该后端无实测探针或未初始化)'}`))
2096
+ for (const c of report.criteria) {
2097
+ const mark = c.met === null ? '?' : c.met ? '✓' : '✗'
2098
+ console.log(` ${mark} ${c.id}`)
2099
+ console.log(chalk.gray(` ${c.detail}`))
2100
+ }
2101
+ if (report.sample) {
2102
+ const sm = report.sample
2103
+ console.log(chalk.gray(` 采样: ${sm.rowsSampled} 行 / ${sm.tablesSampled} 表(共 ${sm.tablesTotal} 表,每表两端各 ${sm.rowsPerTable} 行)`))
2104
+ }
2105
+ console.log('')
2106
+ })
2107
+
1809
2108
  // ==================== contacts ====================
1810
2109
  // contact-schema:读 contact.db 里**群**的 ext_buffer(本地只读:不联网、不调模型)
1811
2110
  //
@@ -5204,6 +5503,7 @@ program
5204
5503
  .option('--api-key <key>', 'DashScope embedding API key')
5205
5504
  .option('--days <n>', '聊天记录收最近多少天(默认 90)')
5206
5505
  .option('--article-days <n>', '日报文章收最近多少个日期目录(默认 30)')
5506
+ .option('--include-imports', '把 weflow-cli import qchat 导入的 QQ 会话也收进索引(聊天半边)')
5207
5507
  .option('--dry-run', '仅预览,不读取数据库、调用网络或写入索引')
5208
5508
  .option('--yes', '确认构建或重建索引')
5209
5509
  .option('--json', '输出 JSON 格式')
@@ -5252,6 +5552,7 @@ program
5252
5552
  mode: opts.full ? 'full' : 'incremental',
5253
5553
  chatDays,
5254
5554
  articleDays,
5555
+ includeImports: !!opts.includeImports,
5255
5556
  readsLocalData: true,
5256
5557
  usesCloudEmbedding: true,
5257
5558
  replacesExistingIndex: !!opts.full,
@@ -5289,6 +5590,7 @@ program
5289
5590
  const args: string[] = [script, 'build', '--days', String(chatDays),
5290
5591
  '--article-days', String(articleDays)]
5291
5592
  if (opts.full) args.push('--full')
5593
+ if (opts.includeImports) args.push('--include-imports')
5292
5594
  try {
5293
5595
  const { stdout } = await execFileAsync(getPythonCommand(), args, {
5294
5596
  // 30 分钟:全量重建实测要跑二十分钟以上(16k 条 / 1500+ 批),而这里原来给的是 5 分钟 --
@@ -5973,6 +6275,7 @@ program
5973
6275
  .description('把最近一段时间的会话整理成知识卡(之后用 wiki compile 聚成概念页)—— **只写本地文件,不发送**')
5974
6276
  .option('--days <n>', '看最近多少天', '30')
5975
6277
  .option('--limit <n>', '最多处理几个会话', '40')
6278
+ .option('--source <source>', '聊天来源:wechat(默认)/ qq(只读 import qchat 导入的会话,不碰微信库)/ all(都要)', 'wechat')
5976
6279
  .option('--dry-run', '仅预览:几个会话、多少字符会发给生成模型(只读本地,零出境)')
5977
6280
  .option('--yes', '确认把对话发送给生成模型')
5978
6281
  .option('--transcribe-voice',
@@ -5986,12 +6289,13 @@ program
5986
6289
  const script = join(pkgRoot, 'scripts', 'chat_notes.py')
5987
6290
  const days = parseCliInteger(opts.days, 'days', 1, 3650, opts.json)
5988
6291
  const limit = parseCliInteger(opts.limit, 'limit', 1, 200, opts.json)
6292
+ requireCliChoice(opts.source, ['wechat', 'qq', 'all'], 'source', opts.json)
5989
6293
  // 与 `draft` 同一条纪律:**"确认过了"只用一个变量**,参数在确认之后才拼
5990
6294
  let confirmed = !!opts.yes
5991
6295
  if (!opts.dryRun && !confirmed) {
5992
6296
  const preview = {
5993
6297
  success: false, dryRun: false, action: 'chat-notes',
5994
- code: 'CONFIRMATION_REQUIRED', days, limit,
6298
+ code: 'CONFIRMATION_REQUIRED', days, limit, source: opts.source,
5995
6299
  readsLocalChat: true, invokesAI: true, writesLocalFiles: true, sendsNothing: true,
5996
6300
  }
5997
6301
  if (opts.json) {
@@ -6011,6 +6315,7 @@ program
6011
6315
  }
6012
6316
  }
6013
6317
  const args = [script, '--days', String(days), '--limit', String(limit),
6318
+ '--source', String(opts.source),
6014
6319
  ...(confirmed ? ['--yes'] : []),
6015
6320
  ...(opts.transcribeVoice ? ['--transcribe-voice'] : []),
6016
6321
  ...(opts.json ? ['--json'] : []),
@@ -7455,12 +7760,12 @@ program
7455
7760
 
7456
7761
  try {
7457
7762
  const args = [script, '--years', String(opts.years || '1,3,5'), '--limit', String(limit)]
7458
- if (opts.talker) args.push('--talker', String(opts.talker))
7459
7763
  if (opts.json) args.push('--json')
7764
+ // D-020:会话标识不进子进程 argv(进程列表里看得见),走 WEFLOW_TALKER。
7460
7765
  const { stdout } = await execFileAsync(getPythonCommand(), args, {
7461
7766
  timeout: 300_000,
7462
7767
  maxBuffer: 10 * 1024 * 1024,
7463
- env: pythonProcessEnv(),
7768
+ env: createPythonProcessEnv(opts.talker ? { WEFLOW_TALKER: String(opts.talker) } : {}),
7464
7769
  })
7465
7770
  console.log(stdout)
7466
7771
  } catch (e: any) {
@@ -7539,12 +7844,12 @@ program
7539
7844
 
7540
7845
  try {
7541
7846
  const args = [script, '--days', String(days), '--limit', String(limit)]
7542
- if (opts.talker) args.push('--talker', String(opts.talker))
7543
7847
  if (opts.json) args.push('--json')
7848
+ // D-020:同 today——会话标识走 WEFLOW_TALKER,不进 argv。
7544
7849
  const { stdout } = await execFileAsync(getPythonCommand(), args, {
7545
7850
  timeout: 300_000,
7546
7851
  maxBuffer: 10 * 1024 * 1024,
7547
- env: pythonProcessEnv(),
7852
+ env: createPythonProcessEnv(opts.talker ? { WEFLOW_TALKER: String(opts.talker) } : {}),
7548
7853
  })
7549
7854
  console.log(stdout)
7550
7855
  } catch (e: any) {
@@ -7561,6 +7866,84 @@ program
7561
7866
  }
7562
7867
  })
7563
7868
 
7869
+ // ==================== leads(线索雷达)====================
7870
+ program
7871
+ .command('leads')
7872
+ .description('线索雷达:聊天里的报价/采购/合作/需求信号,带原话与跟进状态(规则判定,不调模型)')
7873
+ .option('--days <number>', '看最近多少天', '30')
7874
+ .option('--min-score <number>', '最低分,4 分以上更接近真实生意', '2')
7875
+ .option('--limit <number>', '最多列几条', '20')
7876
+ .option('--json', '输出 JSON 格式')
7877
+ .action(async (opts) => {
7878
+ const { execFile } = await import('child_process')
7879
+ const { promisify } = await import('util')
7880
+ const execFileAsync = promisify(execFile)
7881
+ const script = join(resolvePackageRoot(), 'scripts', 'leads.py')
7882
+ const days = parseCliInteger(opts.days, 'days', 1, 3650, opts.json)
7883
+ const minScore = parseCliInteger(opts.minScore, 'min-score', 1, 50, opts.json)
7884
+ const limit = parseCliInteger(opts.limit, 'limit', 1, 500, opts.json)
7885
+
7886
+ try {
7887
+ const args = [script, '--days', String(days), '--min-score', String(minScore),
7888
+ '--limit', String(limit)]
7889
+ if (opts.json) args.push('--json')
7890
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
7891
+ timeout: 300_000,
7892
+ maxBuffer: 10 * 1024 * 1024,
7893
+ env: pythonProcessEnv(),
7894
+ })
7895
+ console.log(stdout)
7896
+ } catch (e: any) {
7897
+ if (opts.json) {
7898
+ console.log(JSON.stringify({
7899
+ success: false,
7900
+ code: 'LEADS_FAILED',
7901
+ error: '线索雷达不可用,请运行 check --json 检查配置和依赖',
7902
+ }))
7903
+ process.exit(1)
7904
+ }
7905
+ console.error(chalk.red(`\n${safeSubprocessError(e, '统计失败')}`))
7906
+ process.exit(1)
7907
+ }
7908
+ })
7909
+
7910
+ // ==================== replay(关系时间重放)====================
7911
+ program
7912
+ .command('replay')
7913
+ .description('关系网络时间重放:按月展开,生成可播放的单文件 HTML(本地、离线、不调模型)')
7914
+ .option('--out <file>', '输出路径', 'output/replay.html')
7915
+ .option('--min-weight <number>', '当月互动量门槛,低于此不计入', '10')
7916
+ .option('--json', '只输出聚合结果(验收用)')
7917
+ .action(async (opts) => {
7918
+ const { execFile } = await import('child_process')
7919
+ const { promisify } = await import('util')
7920
+ const execFileAsync = promisify(execFile)
7921
+ const script = join(resolvePackageRoot(), 'scripts', 'replay.py')
7922
+ const minWeight = parseCliInteger(opts.minWeight, 'min-weight', 1, 10000, opts.json)
7923
+
7924
+ try {
7925
+ const args = [script, '--min-weight', String(minWeight), '--out', String(opts.out)]
7926
+ if (opts.json) args.push('--json')
7927
+ const { stdout } = await execFileAsync(getPythonCommand(), args, {
7928
+ timeout: 600_000,
7929
+ maxBuffer: 64 * 1024 * 1024,
7930
+ env: pythonProcessEnv(),
7931
+ })
7932
+ console.log(stdout)
7933
+ } catch (e: any) {
7934
+ if (opts.json) {
7935
+ console.log(JSON.stringify({
7936
+ success: false,
7937
+ code: 'REPLAY_FAILED',
7938
+ error: '时间重放不可用,请运行 check --json 检查配置和依赖',
7939
+ }))
7940
+ process.exit(1)
7941
+ }
7942
+ console.error(chalk.red(`\n${safeSubprocessError(e, '生成失败')}`))
7943
+ process.exit(1)
7944
+ }
7945
+ })
7946
+
7564
7947
  // ==================== timeline(某号观点演变)====================
7565
7948
  program
7566
7949
  .command('timeline')