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
@@ -69,6 +69,20 @@ export interface TurnInput {
69
69
  message?: WechatInboundMessage
70
70
  }
71
71
 
72
+ /**
73
+ * 来信里**已落盘**的图片文件(`wechatMediaDownload` 的下载侧产物,D-128)。
74
+ * 只收图片:语音/视频/文件的消费各自是新的出境决策,不搭车。没开下载或下载失败
75
+ * (`filePath` 为空)时这里为空——那种图片消息照旧整条忽略,行为不变。
76
+ */
77
+ function inboundImageFiles(msg?: WechatInboundMessage): Array<{ filePath: string }> {
78
+ if (!msg?.components?.length) return []
79
+ const out: Array<{ filePath: string }> = []
80
+ for (const c of msg.components) {
81
+ if (c.type === 'image' && c.filePath) out.push({ filePath: c.filePath })
82
+ }
83
+ return out
84
+ }
85
+
72
86
  /**
73
87
  * 一轮的结果。**"回什么"与"往哪回"分开**:这里只说回什么,往哪回由调用方的 `deliver` 决定。
74
88
  *
@@ -392,6 +406,11 @@ export class AssistantService {
392
406
  * 消息是串行处理的(queue),所以实例字段在这里是安全的。
393
407
  */
394
408
  private turnCalls = new Set<string>()
409
+ /**
410
+ * 本轮**来信自带**的图片文件(D-128)。消息串行处理,实例字段安全(同 `turnCalls`)。
411
+ * `runToolCall` 建 ctx 时带上,`look_at_image` 的 `#信N` 句柄从这里取。
412
+ */
413
+ private turnInboundImages: Array<{ filePath: string }> = []
395
414
 
396
415
  /** 白名单为空时,把"该把谁加进去"连同**完整**的发送者 ID 打一行。
397
416
  *
@@ -485,6 +504,7 @@ export class AssistantService {
485
504
  }
486
505
  this.turnCalls.add(callKey)
487
506
  const ctx: ToolContext = { userId, memory: this.memory }
507
+ if (this.turnInboundImages.length) ctx.inboundImages = this.turnInboundImages
488
508
  const raw = await executeTool(name, args, ctx)
489
509
  trace.toolCalls += 1
490
510
  trace.steps.push({
@@ -576,22 +596,37 @@ export class AssistantService {
576
596
  * **外部只许经由 `enqueueTurn` 调用**(它保证串行);直接调会被 `inFlight` 挡下——
577
597
  * 见那个字段的注释。
578
598
  */
579
- async handleMessage(userId: string, text: string, kind: string): Promise<string> {
599
+ async handleMessage(userId: string, text: string, kind: string,
600
+ inboundImages: Array<{ filePath: string }> = []): Promise<string> {
580
601
  if (this.inFlight) {
581
602
  throw new Error('handleMessage 重入:它依赖串行调用(记忆窗口、turnCalls 都是实例字段)')
582
603
  }
583
604
  this.inFlight = true
584
605
  try {
585
- return await this.handleMessageInner(userId, text, kind)
606
+ return await this.handleMessageInner(userId, text, kind, inboundImages)
586
607
  } finally {
587
608
  this.inFlight = false
588
609
  }
589
610
  }
590
611
 
591
- private async handleMessageInner(userId: string, text: string, kind: string): Promise<string> {
592
- if (kind !== 'text') return '目前只支持文字消息哦'
612
+ private async handleMessageInner(userId: string, text: string, kind: string,
613
+ inboundImages: Array<{ filePath: string }> = []): Promise<string> {
614
+ // kind==='image' 只有在**带着已落盘图片**时才是 D-128 那条新路;裸的图片消息
615
+ // (runTurn 已经过滤掉的那种)走到这里仍按老规矩拒绝。
616
+ if (kind !== 'text' && !(kind === 'image' && inboundImages.length)) {
617
+ return '目前只支持文字消息哦'
618
+ }
593
619
 
594
- const t = text.trim()
620
+ let t = text.trim()
621
+ // 每轮重置(空数组也覆盖):上一轮的图片不能漏到这一轮的句柄空间里。
622
+ this.turnInboundImages = inboundImages
623
+ // 来信图片的句柄指路牌:**能看才摆**(strict 且非本地推理不摆——与 get_messages 的
624
+ // `#N` 同一规则,不摆出一个工具必定会拒绝的东西)。文件本身挂在工具 ctx 上,
625
+ // 这里只告诉模型"有图、看的话用哪个句柄"。
626
+ if (inboundImages.length && (privacyGate.mode() !== 'strict' || privacyGate.isLocalInference())) {
627
+ const handles = inboundImages.map((_f, i) => `#信${i + 1}`).join('、')
628
+ t += `(这张来信带 ${inboundImages.length} 张图片:${handles}。要看就用 look_at_image,不看也可以直接聊。)`
629
+ }
595
630
  const startedAt = Date.now()
596
631
  this.turnCalls.clear()
597
632
  // 一轮的轨迹:判断了什么、调了什么、几轮、为什么停。**内置指令也要记**——
@@ -786,7 +821,12 @@ export class AssistantService {
786
821
  */
787
822
  private async runTurn(input: TurnInput, onLog?: (line: string) => void): Promise<TurnOutcome> {
788
823
  if (!this.running) return { status: 'not-running' }
789
- const { sessionId, text, kind, origin } = input
824
+ const { sessionId, kind, origin } = input
825
+ // D-128:带**已落盘**图片的来信从"整条忽略"变成一轮(文本占位);没落盘的
826
+ // (没开下载 / 下载失败)照旧忽略——文件不在,句柄无从谈起。
827
+ const inboundImages = inboundImageFiles(input.message)
828
+ const imageAsTurn = kind === 'image' && inboundImages.length > 0
829
+ const text = imageAsTurn ? (input.text.trim() || '(发来一张图片)') : input.text
790
830
 
791
831
  if (origin === 'wechat') {
792
832
  const msg = input.message!
@@ -797,7 +837,7 @@ export class AssistantService {
797
837
  this.maybeAnnounceWhitelistBootstrap(access, msg, onLog)
798
838
  return { status: 'denied', reason: access.reason }
799
839
  }
800
- if (kind !== 'text') {
840
+ if (kind !== 'text' && !imageAsTurn) {
801
841
  onLog?.(` → 忽略非文本消息 (${kind})`)
802
842
  return { status: 'ignored', reason: 'non-text' }
803
843
  }
@@ -815,7 +855,7 @@ export class AssistantService {
815
855
  }
816
856
 
817
857
  try {
818
- const reply = await this.handleMessage(sessionId, text, kind)
858
+ const reply = await this.handleMessage(sessionId, text, kind, inboundImages)
819
859
  this.dailyCount++
820
860
  // **这里不报"已回复"**:回没回得出去是投递方才知道的事(微信的 sendText 会失败)。
821
861
  // 两处都报的话,微信那条路会在日志里出现两遍"已回复",看着像处理了两条消息。
@@ -306,6 +306,12 @@ export interface ToolContext {
306
306
  * 附到对话里(见 `assistantService.toApiMessages`)。
307
307
  */
308
308
  pendingImages?: AttachedImage[]
309
+ /**
310
+ * 本轮**来信自带**的图片(Bot 通道按 `wechatMediaDownload` 落盘解密过的文件,D-128)。
311
+ * `look_at_image` 的 `#信N` 句柄指向这里:文件已在本机,不走 `read_image.py`。
312
+ * 与 `pendingImages` 的分工:那是"模型这一轮已经看过的",这是"来信里还没看的"。
313
+ */
314
+ inboundImages?: Array<{ filePath: string }>
309
315
  /**
310
316
  * 调用方是**外部机器**(MCP 那条路)时置 true。
311
317
  *
@@ -328,6 +334,28 @@ export interface AttachedImage {
328
334
  /** 一轮对话里最多看几张图。每次调用一张,这是上限——图片按体积算钱,也按体积算隐私。 */
329
335
  const MAX_IMAGES_PER_TURN = 2
330
336
 
337
+ /**
338
+ * 来信图片的大小上限(D-128)。超了就明说"太大",**不缩放**:入站文件是证据形态,
339
+ * 缩了再给模型看,模型说的就不是那张图(导出路径缩放是因为那是给人看的页面)。
340
+ */
341
+ const MAX_INBOUND_IMAGE_BYTES = 4 * 1024 * 1024
342
+
343
+ /** 从文件头魔数判图片类型,不信任文件名——来信文件的名字来自服务端报文。 */
344
+ function sniffImageMime(buf: Buffer): string | null {
345
+ if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
346
+ return 'image/png'
347
+ }
348
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
349
+ return 'image/jpeg'
350
+ }
351
+ if (buf.length >= 12
352
+ && buf.subarray(0, 4).toString('latin1') === 'RIFF'
353
+ && buf.subarray(8, 12).toString('latin1') === 'WEBP') {
354
+ return 'image/webp'
355
+ }
356
+ return null
357
+ }
358
+
331
359
  function fmtTime(ts: number): string {
332
360
  const n = Number(ts)
333
361
  const d = n > 1e12 ? new Date(n) : new Date(n * 1000)
@@ -738,16 +766,17 @@ export const TOOL_DEFS: ToolDef[] = [
738
766
  type: 'function',
739
767
  function: {
740
768
  name: 'look_at_image',
741
- description: '看聊天里的一张图片(把图片本身交给模型看,不只是"这里有一张图")。'
742
- + '`image` 填 get_messages 输出里 `[图片 #1234]` 的编号。'
743
- + '一轮最多看两张;隐私模式为 strict 时不可用(聊天图片不出发本机)。',
769
+ description: '看一张图片(把图片本身交给模型看,不只是"这里有一张图")。'
770
+ + '看聊天记录里的图:`image` 填 get_messages 输出里 `[图片 #1234]` 的编号,`contact` 给会话。'
771
+ + '看**本轮来信自带**的图:`image` 填 `#信1`(只有来信带图时才会出现这个句柄),不用 contact。'
772
+ + '一轮最多看两张;隐私模式为 strict 时不可用(图片不出发本机)。',
744
773
  parameters: {
745
774
  type: 'object',
746
775
  properties: {
747
- contact: { type: 'string', description: '这张图所在会话的联系人显示名' },
748
- image: { type: 'string', description: 'get_messages 里 [图片 #N] 的编号 N' },
776
+ contact: { type: 'string', description: '聊天记录那条路:这张图所在会话的联系人显示名(#信N 不需要)' },
777
+ image: { type: 'string', description: '[图片 #N] 的编号 N,或来信图片的 #信N' },
749
778
  },
750
- required: ['contact', 'image'],
779
+ required: ['image'],
751
780
  },
752
781
  },
753
782
  },
@@ -1471,20 +1500,54 @@ export async function executeTool(name: string, args: Record<string, any>, ctx:
1471
1500
  // 句柄在 get_messages 里长这样:`[图片 #1234]`。允许带不带 `#` 都接受——模型从
1472
1501
  // 上下文里抄一个编号过来,"抄得像不像"不该决定它能不能看图。
1473
1502
  const image = String(args.image ?? '').replace(/^#/, '').trim()
1474
- if (!contact || !image) return '(缺少 contact 或 image 参数)'
1475
- if (!/^\d+$/.test(image)) {
1476
- return `(image 要填 get_messages 里 [图片 #N] 的编号,收到的是「${image.slice(0, 20)}」)`
1503
+ if (!image) return '(缺少 image 参数)'
1504
+ // 来信自带的图(D-128):句柄 `信N`,与聊天记录的 `#N` 是两个空间。静态校验
1505
+ // 先于隐私门——参数错就是参数错,与档位无关。
1506
+ const inbound = /^信(\d+)$/.exec(image)
1507
+ if (!inbound) {
1508
+ if (!contact) return '(缺少 contact 参数:看聊天记录里的图要说明在哪个会话)'
1509
+ if (!/^\d+$/.test(image)) {
1510
+ return `(image 要填 get_messages 里 [图片 #N] 的编号或来信的 #信N,收到的是「${image.slice(0, 20)}」)`
1511
+ }
1477
1512
  }
1478
1513
  // 第一层门:strict 模式(第三方聊天正文不出境)下图片也不出去。第二层在
1479
1514
  // `toApiMessages` 里——"工具肯给"与"这道门肯放"是两件事。
1480
1515
  if (privacyGate.mode() === 'strict' && !privacyGate.isLocalInference()) {
1481
- return '(当前隐私模式是 strict:聊天图片不发出本机,用不了这个工具。'
1516
+ return '(当前隐私模式是 strict:图片不发出本机,用不了这个工具。'
1482
1517
  + '要让我看图,得把 assistantPrivacy 改成 balanced)'
1483
1518
  }
1484
1519
  const budget = ctx.pendingImages ?? []
1485
1520
  if (budget.length >= MAX_IMAGES_PER_TURN) {
1486
1521
  return `(这一轮已经看过 ${MAX_IMAGES_PER_TURN} 张图了,先把已经看到的说完,别再取)`
1487
1522
  }
1523
+
1524
+ // ---- 来信自带的图:文件已在本机(wechatMediaDownload 落盘解密过),
1525
+ // 不走 read_image.py。
1526
+ if (inbound) {
1527
+ const files = ctx.inboundImages ?? []
1528
+ const n = Number(inbound[1])
1529
+ const ref = files[n - 1]
1530
+ if (!ref) {
1531
+ return `(本轮来信没有 #信${n} 这张图${files.length ? `(只有 #信1 到 #信${files.length})` : ':来信没带可看的图片文件'})`
1532
+ }
1533
+ let buf: Buffer
1534
+ try {
1535
+ buf = readFileSync(ref.filePath)
1536
+ } catch {
1537
+ return `(读不到 #信${n} 的图片文件:落盘文件已不在(可能被清理过))`
1538
+ }
1539
+ if (buf.length > MAX_INBOUND_IMAGE_BYTES) {
1540
+ // 不缩放(D-128):入站文件是证据形态,缩了再给模型看,模型说的就不是那张图。
1541
+ return `(这张来信图片 ${(buf.length / 1048576).toFixed(1)} MB,超过 ${MAX_INBOUND_IMAGE_BYTES / 1048576} MB 上限,没有附上)`
1542
+ }
1543
+ const mime = sniffImageMime(buf)
1544
+ if (!mime) {
1545
+ return `(#信${n} 的文件头不是 png/jpeg/webp,附上也没有意义)`
1546
+ }
1547
+ ;(ctx.pendingImages ??= []).push({ b64: buf.toString('base64'), mime })
1548
+ return `(已附上来信图片 #信${n}(${buf.length} 字节)。你能看到它了,直接据此回答。)`
1549
+ }
1550
+
1488
1551
  const talker = await resolveTalker(contact)
1489
1552
  // 90 秒。建媒体索引是这条路唯一的成本,且随会话大小走:实测单聊 ~1 秒、
1490
1553
  // 一个 3 万条消息的大群 18.9 秒(而那个群本机只有缩略图)。取到的图会落在
@@ -121,7 +121,7 @@ const CONFIG_DIR = join(homedir(), '.weflow-cli')
121
121
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
122
122
 
123
123
  export class ConfigService {
124
- private config: CliConfig = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', wechatMediaDownload: 'false', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', judgeProvider: 'native', openrouterApiKey: '', openrouterJudgeModel: '', judgeLocalUrl: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', skillDirs: '', skillDisabled: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true' }
124
+ private config: CliConfig = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', wechatMediaDownload: 'false', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', judgeProvider: 'native', openrouterApiKey: '', openrouterJudgeModel: '', judgeLocalUrl: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', skillDirs: '', skillDisabled: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true', dailyMailTo: '' }
125
125
 
126
126
  /** 本进程修改过、待回写的字段 (多进程并发写保护) */
127
127
  private dirty = new Set<keyof CliConfig>()
@@ -199,6 +199,7 @@ export class ConfigService {
199
199
  dailySourceCategories: data.dailySourceCategories || '',
200
200
  dailyExcludeTopics: data.dailyExcludeTopics || '',
201
201
  dailyAiEnabled: data.dailyAiEnabled === 'false' ? 'false' : 'true',
202
+ dailyMailTo: data.dailyMailTo || '',
202
203
  }
203
204
  }
204
205
  } catch (e) {
@@ -383,7 +384,7 @@ export class ConfigService {
383
384
  }
384
385
 
385
386
  clear(): void {
386
- this.config = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', wechatMediaDownload: 'false', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', judgeProvider: 'native', openrouterApiKey: '', openrouterJudgeModel: '', judgeLocalUrl: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', skillDirs: '', skillDisabled: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true' }
387
+ this.config = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', wechatMediaDownload: 'false', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', judgeProvider: 'native', openrouterApiKey: '', openrouterJudgeModel: '', judgeLocalUrl: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', skillDirs: '', skillDisabled: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true', dailyMailTo: '' }
387
388
  // clear 意图是全量重置: 所有字段标记为脏, 覆盖磁盘上的全部旧值
388
389
  this.dirty = new Set(Object.keys(this.config) as (keyof CliConfig)[])
389
390
  this.save()
@@ -0,0 +1,222 @@
1
+ /**
2
+ * 稳定增量游标的"路径可见"层(D-027)。
3
+ *
4
+ * `capabilities --json` 的 `stableCursor: false` 一直是一句结论;这里把它背后的
5
+ * 判据拆开,逐条给出当前状态与差在哪,让下游(she-love-me、MCP 客户端)能看见
6
+ * false → true 要满足什么,而不是等它某天悄悄变 true。
7
+ *
8
+ * 两类事实,来源不同、都必须如实标注:
9
+ * - **代码事实**(静态):每个后端有没有时间窗下推、有没有覆盖报告、合并顺序键是什么。
10
+ * 这些写在下面的表里,改读取器时要同步改。
11
+ * - **数据事实**(实测):NT 库里每张消息表有没有锚点列、采样的行带不带非零
12
+ * `server_id`。这些只有开库才知道,由 `sync cursor-probe` 现场 probing,采样
13
+ * 有界且结果里写明 `exhaustive: false`。
14
+ */
15
+ import { existsSync } from 'fs'
16
+ import { join } from 'path'
17
+ import { configService } from './configService.js'
18
+ import { NtCore } from '../core/ntCore.js'
19
+ import type { NtCursorProbeResult, NtCursorProbeShard, NtCursorProbeSample } from '../core/ntCore.js'
20
+
21
+ export type MessageBackend = 'nt' | 'raw-sqlite-4x' | 'wcdb-4x' | 'legacy-3x'
22
+
23
+ export interface CursorCriterion {
24
+ id: string
25
+ /** 这条判据满足没有:true/false 是已核实的结论,null 是"当前无法判定" */
26
+ met: boolean | null
27
+ detail: string
28
+ }
29
+
30
+ export interface CursorProbeReport {
31
+ success: boolean
32
+ schema: 'weflow-cursor-probe/v1'
33
+ /** 结论不动:探针是让路径可见,不是替 D-027 翻案 */
34
+ stableCursor: false
35
+ backend: MessageBackend | null
36
+ backendNote: string
37
+ /** 数据事实那一半跑了没有(未初始化 / 非 NT 后端 = false) */
38
+ probedLive: boolean
39
+ criteria: CursorCriterion[]
40
+ /** NT 实测明细;probedLive=false 时为 undefined */
41
+ shards?: NtCursorProbeShard[]
42
+ sample?: NtCursorProbeSample
43
+ error?: string
44
+ }
45
+
46
+ /**
47
+ * 静态判断"这台机器会走哪条消息后端"。**镜像 `chatService.connect4x` 的尝试顺序**
48
+ * (先已解密 MSG0,再 NT,最后 WCDB),但只看配置与文件存在性、不开库——真正的
49
+ * 开库判定在 connect 里,而探测命令不该为了报一个名字去连数据库。
50
+ */
51
+ export function likelyMessageBackend(): { backend: MessageBackend | null; note: string } {
52
+ const version = configService.get('dataVersion')
53
+ const dbPath = configService.get('dbPath')
54
+ const wxid = configService.get('wxid')
55
+ const dbPath3x = configService.get('dbPath3x')
56
+ const key3x = configService.get('decryptKey3x')
57
+ const ntDbPath = configService.get('ntDbPath')
58
+ const ntKey = configService.get('ntKey')
59
+ const ntSalt = configService.get('ntSalt')
60
+
61
+ if (version === '3.x' || (!version && dbPath3x && key3x)) {
62
+ return { backend: 'legacy-3x', note: '按 3.x 配置判定(sqlcipher + MSG 库)' }
63
+ }
64
+ if (version !== '4.x' && !dbPath && !(ntDbPath && ntKey && ntSalt)) {
65
+ return { backend: null, note: '未初始化:没有可判定的消息后端(先运行 weflow-cli init)' }
66
+ }
67
+ if (dbPath && wxid && existsSync(join(dbPath, wxid, 'Msg', 'Multi', 'MSG0_decrypted.db'))) {
68
+ return { backend: 'raw-sqlite-4x', note: '发现已解密的 MSG0_decrypted.db(connect4x 的第一优先)' }
69
+ }
70
+ if (ntDbPath && ntKey && ntSalt && existsSync(ntDbPath)) {
71
+ return { backend: 'nt', note: 'NT 配置完整(db_storage/message 分片)' }
72
+ }
73
+ if (dbPath) {
74
+ return { backend: 'wcdb-4x', note: '无解密 MSG0、无 NT 配置,connect4x 的最后兜底是 WCDB API' }
75
+ }
76
+ return { backend: null, note: '4.x 配置不完整:既无 dbPath 也无 NT 三件套' }
77
+ }
78
+
79
+ /** 各后端的**代码事实**。改读取器(下推 / 报告 / 顺序键)时同步改这张表。 */
80
+ export const BACKEND_CAPS: Record<MessageBackend, {
81
+ identity: string
82
+ windowPushdown: boolean
83
+ coverageReport: boolean
84
+ liveProbe: boolean
85
+ }> = {
86
+ 'nt': {
87
+ identity: 'server_id(缺失时退化为 localId+createTime,后者不跨分片)',
88
+ windowPushdown: true, // D-030: --from/--to 推进 SQL
89
+ coverageReport: true, // 分片报告(connect_message_shards_detailed)
90
+ liveProbe: true,
91
+ },
92
+ 'raw-sqlite-4x': {
93
+ identity: 'MsgSvrID 列被读取,但覆盖率从未实测',
94
+ windowPushdown: false,
95
+ coverageReport: false,
96
+ liveProbe: false,
97
+ },
98
+ 'wcdb-4x': {
99
+ identity: 'server_id 随原生 API 返回,未实测;另有按时间的原生游标未接入 chatService',
100
+ windowPushdown: false,
101
+ coverageReport: false,
102
+ liveProbe: false,
103
+ },
104
+ 'legacy-3x': {
105
+ identity: 'MsgSvrID 列被读取,但覆盖率从未实测',
106
+ windowPushdown: false,
107
+ coverageReport: false,
108
+ liveProbe: false,
109
+ },
110
+ }
111
+
112
+ export async function probeCursorReadiness(opts: {
113
+ tablesPerShard?: number
114
+ rowsPerTable?: number
115
+ } = {}): Promise<CursorProbeReport> {
116
+ const { backend, note } = likelyMessageBackend()
117
+
118
+ const criteria: CursorCriterion[] = []
119
+ let live: NtCursorProbeResult | null = null
120
+ let shards: NtCursorProbeShard[] | undefined
121
+ let sample: NtCursorProbeSample | undefined
122
+
123
+ if (backend === 'nt') {
124
+ const ntDbPath = configService.get('ntDbPath')!
125
+ const ntKey = configService.get('ntKey')!
126
+ const ntSalt = configService.get('ntSalt')!
127
+ live = await new NtCore(ntDbPath, ntKey, ntSalt).cursorProbe(opts)
128
+ if (live.success) {
129
+ shards = live.shards
130
+ sample = live.sample
131
+ }
132
+ }
133
+
134
+ const caps = backend ? BACKEND_CAPS[backend] : null
135
+
136
+ // 1. stable-identity
137
+ if (backend === null) {
138
+ criteria.push({
139
+ id: 'stable-identity',
140
+ met: null,
141
+ detail: '没有可判定的后端',
142
+ })
143
+ } else if (backend === 'nt') {
144
+ if (!live?.success || !sample) {
145
+ criteria.push({
146
+ id: 'stable-identity',
147
+ met: null,
148
+ detail: `NT 实测没有跑成:${live?.error ?? '无结果'}`,
149
+ })
150
+ } else {
151
+ const failed = (live.shards ?? []).filter(s => !s.opened)
152
+ const anchorBad = live.schemaAnchorMissingTables ?? 0
153
+ const parts: string[] = []
154
+ if (failed.length) parts.push(`${failed.length} 个分片没打开`)
155
+ if (anchorBad > 0) parts.push(`${anchorBad} 组消息表缺锚点列`)
156
+ if (sample.serverIdMissing > 0) parts.push(`采样 ${sample.rowsSampled} 行里 ${sample.serverIdMissing} 行没有非零 server_id(这些行的身份退化为按分片的 localId+createTime)`)
157
+ if (sample.duplicateServerIds > 0) parts.push(`采样里 ${sample.duplicateServerIds} 个 server_id 出现在多个位置`)
158
+ criteria.push({
159
+ id: 'stable-identity',
160
+ met: parts.length === 0,
161
+ detail: parts.length
162
+ ? parts.join(';')
163
+ : `采样 ${sample.rowsSampled} 行(${sample.tablesSampled}/${sample.tablesTotal} 张表)全部带非零 server_id 且无重复;schema 全量检查 ${anchorBad} 组缺列。注意 exhaustive=false,这是采样结论`,
164
+ })
165
+ }
166
+ } else {
167
+ criteria.push({
168
+ id: 'stable-identity',
169
+ met: null,
170
+ detail: `${backend}:${caps!.identity};该后端没有实测探针`,
171
+ })
172
+ }
173
+
174
+ // 2. globally-comparable-order(代码事实,对所有后端同为 false)
175
+ criteria.push({
176
+ id: 'globally-comparable-order',
177
+ met: false,
178
+ detail: '读取器跨分片合并的顺序键是 (createTime, localId),localId 是分片内编号、不可跨分片比较;换成 (createTime, serverId) 是稳定游标在读取器侧的前提',
179
+ })
180
+
181
+ // 3. window-pushdown
182
+ criteria.push({
183
+ id: 'window-pushdown',
184
+ met: caps ? caps.windowPushdown : null,
185
+ detail: caps
186
+ ? (caps.windowPushdown
187
+ ? `${backend}:--from/--to 已下推进 SQL(D-030)`
188
+ : `${backend}:没有时间窗下推,窗口过滤全靠调用方拉全量后自筛`)
189
+ : '没有可判定的后端',
190
+ })
191
+
192
+ // 4. coverage-report
193
+ criteria.push({
194
+ id: 'coverage-report',
195
+ met: caps ? caps.coverageReport : null,
196
+ detail: caps
197
+ ? (caps.coverageReport
198
+ ? `${backend}:每分片读取结果都带覆盖报告(complete/unverified/partial)`
199
+ : `${backend}:无法报告读取是否完整,sync 在它上面永远是 unverified`)
200
+ : '没有可判定的后端',
201
+ })
202
+
203
+ // 5. every-backend(D-027 的总门)
204
+ const others = (Object.keys(BACKEND_CAPS) as MessageBackend[]).filter(b => b !== backend)
205
+ criteria.push({
206
+ id: 'every-backend',
207
+ met: false,
208
+ detail: `D-027:稳定游标要所有消息后端同时满足上述判据。当前后端 ${backend ?? '(未配置)'};其余后端(${others.join('、')})各自缺下推与覆盖报告`,
209
+ })
210
+
211
+ return {
212
+ success: true,
213
+ schema: 'weflow-cursor-probe/v1',
214
+ stableCursor: false,
215
+ backend,
216
+ backendNote: note,
217
+ probedLive: !!(backend === 'nt' && live?.success),
218
+ criteria,
219
+ ...(shards ? { shards } : {}),
220
+ ...(sample ? { sample } : {}),
221
+ }
222
+ }
@@ -49,6 +49,12 @@ export interface DiagnosticInput {
49
49
  dataDirMatchesConfig?: boolean | null
50
50
  dataDirs?: string[]
51
51
  unusableVersionDirs?: Array<{ dir: string; files: number }>
52
+ /** D-130:本机加载版本 vs 本包验证版本(check 那边算好传进来) */
53
+ compatibility?: {
54
+ verifiedVersion: string
55
+ verifiedAt: string
56
+ verdict: 'verified' | 'newer-than-verified' | 'older-than-verified' | 'unknown'
57
+ }
52
58
  }
53
59
  /** 最近一次失败的原文(可选;`redactForReport` 会再过一遍) */
54
60
  lastError?: string | null
@@ -157,6 +163,14 @@ export function diagnose(input: DiagnosticInput): DiagnosticFinding[] {
157
163
  hint: '指向它的加载会失败。如果客户端起不来,先看这里;正常情况下可以忽略。',
158
164
  })
159
165
  }
166
+ if (client.compatibility?.verdict === 'newer-than-verified') {
167
+ out.push({
168
+ level: 'warning',
169
+ title: `本机微信(${client.version})比本版本验证过的 ${client.compatibility.verifiedVersion} 更新`,
170
+ hint: '如果读不出数据,先怀疑版本变化——去 GitHub 看有没有更新版本的 weflow-cli,'
171
+ + '或把这份报告贴进 issue(版本差就是第一条线索)。',
172
+ })
173
+ }
160
174
  }
161
175
 
162
176
  if (!out.length) {
@@ -209,6 +223,14 @@ export function renderDiagnosticReport(input: DiagnosticInput): string {
209
223
  } else {
210
224
  say(`运行中: ${yesNo(client.running)}`)
211
225
  say(`加载版本: ${client.version || `未知${client.note ? `(${client.note})` : ''}`}`)
226
+ if (client.compatibility) {
227
+ const v = client.compatibility.verdict
228
+ const word = v === 'verified' ? '就是验证过的版本'
229
+ : v === 'newer-than-verified' ? '比验证过的更新(读不出数据先怀疑这里)'
230
+ : v === 'older-than-verified' ? '比验证过的更旧(一般兼容)'
231
+ : '无法判定(微信没在运行或探测失败)'
232
+ say(`与本版本的关系: ${word}(本版本验证过 ${client.compatibility.verifiedVersion},${client.compatibility.verifiedAt})`)
233
+ }
212
234
  if (client.dataDirs?.length) say(`客户端自报数据目录: ${client.dataDirs.join('、')}`)
213
235
  say(`与配置推出来的同一份: ${yesNo(client.dataDirMatchesConfig)}`)
214
236
  if (client.unusableVersionDirs?.length) {