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
@@ -1,206 +1,206 @@
1
- """人脉图谱:谁和你在同一个群、谁适合做引荐人。
2
-
3
- 数据全在 `contact.db`(联系人 + 群 + 群成员),不碰消息库,也就没有"读聊天内容"这件事:
4
- contact(id, username, nick_name, remark) 4192 人
5
- chat_room(id, username, owner) 80 个群
6
- chatroom_member(room_id, member_id) 4305 条关系(member_id 是 contact.id)
7
-
8
- 用法:
9
- python scripts/network.py --groups 群概览(成员数,按大小排)
10
- python scripts/network.py --with 张三 我和他共同在哪些群
11
- python scripts/network.py --intro 张三 谁最适合把我介绍给他
12
- python scripts/network.py --intro 张三 --json
13
- """
14
- from __future__ import annotations
15
-
16
- import argparse
17
- import json
18
- import pathlib
19
- import sys
20
- from collections import defaultdict
21
-
22
- SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
23
- sys.path.insert(0, str(SCRIPT_DIR))
24
-
25
- from _utils import load_config, decrypt_lock # noqa: E402
26
- from nt_common import derive_database_key # noqa: E402
27
- from export_chat_html import connect # noqa: E402
28
-
29
-
30
- def _open_contact(cfg):
31
- path = cfg.get('contactDbPath') or ''
32
- if not path:
33
- raise SystemExit('配置里没有 contactDbPath,先跑 weflow-cli init')
34
- key = decrypt_lock(cfg.get('contactKey', '') or cfg.get('ntKey', '') or '')
35
- salt = decrypt_lock(cfg.get('contactSalt', '') or cfg.get('ntSalt', '') or '')
36
- pw = decrypt_lock(cfg.get('favPassphrase', '') or cfg.get('decryptKey', '') or '')
37
- k, s = derive_database_key(path, key, salt, pw)
38
- return connect(path, k, s)
39
-
40
-
41
- def load_graph(cfg):
42
- """(people, rooms, members) —— people: id->名字; rooms: id->群名; members: room->{id}"""
43
- conn, cur = _open_contact(cfg)
44
- try:
45
- people = {}
46
- for pid, username, nick, remark in cur.execute(
47
- 'SELECT id, username, COALESCE(nick_name,""), COALESCE(remark,"") FROM contact'):
48
- name = remark or nick or username
49
- people[int(pid)] = {'name': name, 'username': username, 'nick': nick, 'remark': remark}
50
-
51
- rooms = {}
52
- # 群昵称在 `contact` 里(`@chatroom` 那条的 remark/nick_name),`chat_room.username`
53
- # 只是 id —— 不查这一步,输出里 80 个群全叫 `47966561144@chatroom`。
54
- by_username = {p['username']: p for p in people.values()}
55
- for rid, username, owner in cur.execute('SELECT id, username, COALESCE(owner,"") FROM chat_room'):
56
- info = by_username.get(username) or {}
57
- name = info.get('remark') or info.get('nick') or username
58
- rooms[int(rid)] = {'name': name, 'username': username, 'owner': owner}
59
-
60
- members = defaultdict(set)
61
- for rid, mid in cur.execute('SELECT room_id, member_id FROM chatroom_member'):
62
- members[int(rid)].add(int(mid))
63
- return people, rooms, members
64
- finally:
65
- conn.close()
66
-
67
-
68
- def resolve(people, needle):
69
- """按 wxid / 昵称 / 备注找一个人;返回 (id, 记录) 或 None。
70
-
71
- **排除群**:`contact` 表里 `@chatroom` 也在,用 `--with 老表` 会先命中「老表亲戚群」
72
- 这个群而不是人(第一版就这么错),所以匹配人时跳过它们。
73
- """
74
- needle = (needle or '').strip()
75
- if not needle:
76
- return None
77
- exact, fuzzy = [], []
78
- for pid, p in people.items():
79
- if p['username'].endswith('@chatroom'):
80
- continue
81
- fields = (p['name'], p['username'], p['nick'], p['remark'])
82
- if needle == p['username']:
83
- return pid, p
84
- if any(needle == f for f in fields if f):
85
- exact.append((pid, p))
86
- elif any(needle in f for f in fields if f):
87
- fuzzy.append((pid, p))
88
- pool = exact or fuzzy
89
- return pool[0] if pool else None
90
-
91
-
92
- def find_me(people, members, cfg):
93
- """自己的 contact.id。
94
-
95
- 先按配置里的 wxid 找;**找不到就用"出现在最多群里的人"** —— 你在自己所有群里,
96
- 而 `contact` 表不一定收录自己(实测这里就没有),少了这个兜底,
97
- 「共同群」这类功能会直接退化成一列群名。
98
- """
99
- wxid = cfg.get('wxid') or ''
100
- if wxid:
101
- for pid, p in people.items():
102
- if p['username'] == wxid:
103
- return pid
104
- counts = defaultdict(int)
105
- for ids in members.values():
106
- for pid in ids:
107
- counts[pid] += 1
108
- if counts:
109
- pid, n = max(counts.items(), key=lambda kv: kv[1])
110
- if n >= 3:
111
- return pid
112
- return None
113
-
114
-
115
- def rooms_of(members, pid):
116
- return {rid for rid, ids in members.items() if pid in ids}
117
-
118
-
119
- def cmd_groups(people, rooms, members):
120
- rows = []
121
- for rid, info in rooms.items():
122
- ids = members.get(rid, set())
123
- rows.append({'room': info['name'], 'members': len(ids)})
124
- rows.sort(key=lambda r: -r['members'])
125
- print(f'共 {len(rows)} 个群\n')
126
- for r in rows[:30]:
127
- print(f" {r['members']:>4} 人 {r['room']}")
128
- if len(rows) > 30:
129
- print(f' …还有 {len(rows)-30} 个')
130
-
131
-
132
- def cmd_with(people, rooms, members, needle, cfg):
133
- me_id = find_me(people, members, cfg)
134
- me = (me_id, people.get(me_id, {})) if me_id else None
135
- target = resolve(people, needle)
136
- if not target:
137
- raise SystemExit(f'没找到「{needle}」')
138
- tid, tinfo = target
139
- t_rooms = rooms_of(members, tid)
140
- print(f"目标:{tinfo['name']}({tinfo['username']})在 {len(t_rooms)} 个群")
141
- if not me:
142
- print('(配置里没有自己的 wxid,跳过共同群计算)')
143
- for rid in sorted(t_rooms):
144
- print(f" · {rooms[rid]['name']}")
145
- return
146
- my_rooms = rooms_of(members, me[0])
147
- common = t_rooms & my_rooms
148
- print(f"你们共同在 {len(common)} 个群:")
149
- for rid in sorted(common):
150
- print(f" · {rooms[rid]['name']}({len(members.get(rid, []))} 人)")
151
- if not common:
152
- print(' (没有共同群)')
153
-
154
-
155
- def cmd_intro(people, rooms, members, needle, cfg, top=8):
156
- me_id = find_me(people, members, cfg)
157
- me = (me_id, people.get(me_id, {})) if me_id else None
158
- target = resolve(people, needle)
159
- if not target:
160
- raise SystemExit(f'没找到「{needle}」')
161
- tid, tinfo = target
162
- if not me:
163
- raise SystemExit('配置里没有自己的 wxid,算不了引荐路径')
164
- my_rooms = rooms_of(members, me[0])
165
- t_rooms = rooms_of(members, tid)
166
-
167
- # 候选人 = 同时在我和目标的群里的人;分数 = 交集群数,其次是他和目标最近的共同群大小
168
- scores = defaultdict(lambda: {'rooms': set()})
169
- for rid in my_rooms & t_rooms:
170
- for pid in members.get(rid, set()):
171
- if pid in (me[0], tid):
172
- continue
173
- scores[pid]['rooms'].add(rid)
174
- if not scores:
175
- print(f'你和「{tinfo["name"]}」不在同一个群,也没有共同的联系人可引荐')
176
- return
177
- ranked = sorted(scores.items(), key=lambda kv: -len(kv[1]['rooms']))
178
- print(f"想认识「{tinfo['name']}」,这些人同时和你们在一个群:\n")
179
- for pid, info in ranked[:top]:
180
- p = people.get(pid, {})
181
- shared = '、'.join(rooms[r]['name'] for r in list(info['rooms'])[:3])
182
- print(f" {p.get('name') or p.get('username')} 共同群 {len(info['rooms'])} 个:{shared}")
183
-
184
-
185
- def main():
186
- ap = argparse.ArgumentParser(description='人脉图谱:共同群与引荐路径')
187
- ap.add_argument('--groups', action='store_true', help='群概览')
188
- ap.add_argument('--with', dest='with_who', default='', help='我和他共同在哪些群')
189
- ap.add_argument('--intro', default='', help='谁能把我介绍给他')
190
- ap.add_argument('--json', action='store_true')
191
- args = ap.parse_args()
192
-
193
- cfg = load_config()
194
- people, rooms, members = load_graph(cfg)
195
- if args.groups:
196
- cmd_groups(people, rooms, members)
197
- elif args.with_who:
198
- cmd_with(people, rooms, members, args.with_who, cfg)
199
- elif args.intro:
200
- cmd_intro(people, rooms, members, args.intro, cfg)
201
- else:
202
- ap.print_help()
203
-
204
-
205
- if __name__ == '__main__':
206
- main()
1
+ """人脉图谱:谁和你在同一个群、谁适合做引荐人。
2
+
3
+ 数据全在 `contact.db`(联系人 + 群 + 群成员),不碰消息库,也就没有"读聊天内容"这件事:
4
+ contact(id, username, nick_name, remark) 4192 人
5
+ chat_room(id, username, owner) 80 个群
6
+ chatroom_member(room_id, member_id) 4305 条关系(member_id 是 contact.id)
7
+
8
+ 用法:
9
+ python scripts/network.py --groups 群概览(成员数,按大小排)
10
+ python scripts/network.py --with 张三 我和他共同在哪些群
11
+ python scripts/network.py --intro 张三 谁最适合把我介绍给他
12
+ python scripts/network.py --intro 张三 --json
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import pathlib
19
+ import sys
20
+ from collections import defaultdict
21
+
22
+ SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
23
+ sys.path.insert(0, str(SCRIPT_DIR))
24
+
25
+ from _utils import load_config, decrypt_lock # noqa: E402
26
+ from nt_common import derive_database_key # noqa: E402
27
+ from export_chat_html import connect # noqa: E402
28
+
29
+
30
+ def _open_contact(cfg):
31
+ path = cfg.get('contactDbPath') or ''
32
+ if not path:
33
+ raise SystemExit('配置里没有 contactDbPath,先跑 weflow-cli init')
34
+ key = decrypt_lock(cfg.get('contactKey', '') or cfg.get('ntKey', '') or '')
35
+ salt = decrypt_lock(cfg.get('contactSalt', '') or cfg.get('ntSalt', '') or '')
36
+ pw = decrypt_lock(cfg.get('favPassphrase', '') or cfg.get('decryptKey', '') or '')
37
+ k, s = derive_database_key(path, key, salt, pw)
38
+ return connect(path, k, s)
39
+
40
+
41
+ def load_graph(cfg):
42
+ """(people, rooms, members) —— people: id->名字; rooms: id->群名; members: room->{id}"""
43
+ conn, cur = _open_contact(cfg)
44
+ try:
45
+ people = {}
46
+ for pid, username, nick, remark in cur.execute(
47
+ 'SELECT id, username, COALESCE(nick_name,""), COALESCE(remark,"") FROM contact'):
48
+ name = remark or nick or username
49
+ people[int(pid)] = {'name': name, 'username': username, 'nick': nick, 'remark': remark}
50
+
51
+ rooms = {}
52
+ # 群昵称在 `contact` 里(`@chatroom` 那条的 remark/nick_name),`chat_room.username`
53
+ # 只是 id —— 不查这一步,输出里 80 个群全叫 `47966561144@chatroom`。
54
+ by_username = {p['username']: p for p in people.values()}
55
+ for rid, username, owner in cur.execute('SELECT id, username, COALESCE(owner,"") FROM chat_room'):
56
+ info = by_username.get(username) or {}
57
+ name = info.get('remark') or info.get('nick') or username
58
+ rooms[int(rid)] = {'name': name, 'username': username, 'owner': owner}
59
+
60
+ members = defaultdict(set)
61
+ for rid, mid in cur.execute('SELECT room_id, member_id FROM chatroom_member'):
62
+ members[int(rid)].add(int(mid))
63
+ return people, rooms, members
64
+ finally:
65
+ conn.close()
66
+
67
+
68
+ def resolve(people, needle):
69
+ """按 wxid / 昵称 / 备注找一个人;返回 (id, 记录) 或 None。
70
+
71
+ **排除群**:`contact` 表里 `@chatroom` 也在,用 `--with 老表` 会先命中「老表亲戚群」
72
+ 这个群而不是人(第一版就这么错),所以匹配人时跳过它们。
73
+ """
74
+ needle = (needle or '').strip()
75
+ if not needle:
76
+ return None
77
+ exact, fuzzy = [], []
78
+ for pid, p in people.items():
79
+ if p['username'].endswith('@chatroom'):
80
+ continue
81
+ fields = (p['name'], p['username'], p['nick'], p['remark'])
82
+ if needle == p['username']:
83
+ return pid, p
84
+ if any(needle == f for f in fields if f):
85
+ exact.append((pid, p))
86
+ elif any(needle in f for f in fields if f):
87
+ fuzzy.append((pid, p))
88
+ pool = exact or fuzzy
89
+ return pool[0] if pool else None
90
+
91
+
92
+ def find_me(people, members, cfg):
93
+ """自己的 contact.id。
94
+
95
+ 先按配置里的 wxid 找;**找不到就用"出现在最多群里的人"** —— 你在自己所有群里,
96
+ 而 `contact` 表不一定收录自己(实测这里就没有),少了这个兜底,
97
+ 「共同群」这类功能会直接退化成一列群名。
98
+ """
99
+ wxid = cfg.get('wxid') or ''
100
+ if wxid:
101
+ for pid, p in people.items():
102
+ if p['username'] == wxid:
103
+ return pid
104
+ counts = defaultdict(int)
105
+ for ids in members.values():
106
+ for pid in ids:
107
+ counts[pid] += 1
108
+ if counts:
109
+ pid, n = max(counts.items(), key=lambda kv: kv[1])
110
+ if n >= 3:
111
+ return pid
112
+ return None
113
+
114
+
115
+ def rooms_of(members, pid):
116
+ return {rid for rid, ids in members.items() if pid in ids}
117
+
118
+
119
+ def cmd_groups(people, rooms, members):
120
+ rows = []
121
+ for rid, info in rooms.items():
122
+ ids = members.get(rid, set())
123
+ rows.append({'room': info['name'], 'members': len(ids)})
124
+ rows.sort(key=lambda r: -r['members'])
125
+ print(f'共 {len(rows)} 个群\n')
126
+ for r in rows[:30]:
127
+ print(f" {r['members']:>4} 人 {r['room']}")
128
+ if len(rows) > 30:
129
+ print(f' …还有 {len(rows)-30} 个')
130
+
131
+
132
+ def cmd_with(people, rooms, members, needle, cfg):
133
+ me_id = find_me(people, members, cfg)
134
+ me = (me_id, people.get(me_id, {})) if me_id else None
135
+ target = resolve(people, needle)
136
+ if not target:
137
+ raise SystemExit(f'没找到「{needle}」')
138
+ tid, tinfo = target
139
+ t_rooms = rooms_of(members, tid)
140
+ print(f"目标:{tinfo['name']}({tinfo['username']})在 {len(t_rooms)} 个群")
141
+ if not me:
142
+ print('(配置里没有自己的 wxid,跳过共同群计算)')
143
+ for rid in sorted(t_rooms):
144
+ print(f" · {rooms[rid]['name']}")
145
+ return
146
+ my_rooms = rooms_of(members, me[0])
147
+ common = t_rooms & my_rooms
148
+ print(f"你们共同在 {len(common)} 个群:")
149
+ for rid in sorted(common):
150
+ print(f" · {rooms[rid]['name']}({len(members.get(rid, []))} 人)")
151
+ if not common:
152
+ print(' (没有共同群)')
153
+
154
+
155
+ def cmd_intro(people, rooms, members, needle, cfg, top=8):
156
+ me_id = find_me(people, members, cfg)
157
+ me = (me_id, people.get(me_id, {})) if me_id else None
158
+ target = resolve(people, needle)
159
+ if not target:
160
+ raise SystemExit(f'没找到「{needle}」')
161
+ tid, tinfo = target
162
+ if not me:
163
+ raise SystemExit('配置里没有自己的 wxid,算不了引荐路径')
164
+ my_rooms = rooms_of(members, me[0])
165
+ t_rooms = rooms_of(members, tid)
166
+
167
+ # 候选人 = 同时在我和目标的群里的人;分数 = 交集群数,其次是他和目标最近的共同群大小
168
+ scores = defaultdict(lambda: {'rooms': set()})
169
+ for rid in my_rooms & t_rooms:
170
+ for pid in members.get(rid, set()):
171
+ if pid in (me[0], tid):
172
+ continue
173
+ scores[pid]['rooms'].add(rid)
174
+ if not scores:
175
+ print(f'你和「{tinfo["name"]}」不在同一个群,也没有共同的联系人可引荐')
176
+ return
177
+ ranked = sorted(scores.items(), key=lambda kv: -len(kv[1]['rooms']))
178
+ print(f"想认识「{tinfo['name']}」,这些人同时和你们在一个群:\n")
179
+ for pid, info in ranked[:top]:
180
+ p = people.get(pid, {})
181
+ shared = '、'.join(rooms[r]['name'] for r in list(info['rooms'])[:3])
182
+ print(f" {p.get('name') or p.get('username')} 共同群 {len(info['rooms'])} 个:{shared}")
183
+
184
+
185
+ def main():
186
+ ap = argparse.ArgumentParser(description='人脉图谱:共同群与引荐路径')
187
+ ap.add_argument('--groups', action='store_true', help='群概览')
188
+ ap.add_argument('--with', dest='with_who', default='', help='我和他共同在哪些群')
189
+ ap.add_argument('--intro', default='', help='谁能把我介绍给他')
190
+ ap.add_argument('--json', action='store_true')
191
+ args = ap.parse_args()
192
+
193
+ cfg = load_config()
194
+ people, rooms, members = load_graph(cfg)
195
+ if args.groups:
196
+ cmd_groups(people, rooms, members)
197
+ elif args.with_who:
198
+ cmd_with(people, rooms, members, args.with_who, cfg)
199
+ elif args.intro:
200
+ cmd_intro(people, rooms, members, args.intro, cfg)
201
+ else:
202
+ ap.print_help()
203
+
204
+
205
+ if __name__ == '__main__':
206
+ main()
@@ -1275,6 +1275,120 @@ def get_messages(conns, talker, limit=100, offset=0, name_map=None, own_wxid=Non
1275
1275
  return {"messages": collected}
1276
1276
 
1277
1277
 
1278
+ def cursor_probe(db_path, key_hex, salt_hex, passphrase='',
1279
+ tables_per_shard=5, rows_per_table=500):
1280
+ """Measure what a stable incremental cursor would need on this database.
1281
+
1282
+ D-027 keeps `stableCursor: false` until every message backend can honour
1283
+ the same semantics. This probe answers the NT backend's **data-side** half:
1284
+ whether every message table carries the anchor columns, and whether sampled
1285
+ rows actually hold a nonzero `server_id` (the reader's identity falls back
1286
+ to localId+createTime without one, and `local_id` is per-shard, so those
1287
+ rows cannot be resumed across shards). Duplicate server ids are collected
1288
+ across the sample too - an id that appears in two places cannot key a
1289
+ cursor, whatever the column definition says.
1290
+
1291
+ Sampling is bounded and says so (`exhaustive: false`): `tables_per_shard`
1292
+ tables per shard (sorted names, deterministic) read at both rowid ends,
1293
+ `rows_per_table` rows each. A full scan of every shard would cost minutes
1294
+ on a real account; the schema check IS exhaustive - `PRAGMA table_info` is
1295
+ cheap, so every Msg_* table's anchor columns are checked, not sampled.
1296
+
1297
+ Read-only: no write, no network, nothing leaves stdout.
1298
+ """
1299
+ pairs, failures = connect_message_shards_detailed(
1300
+ db_path, key_hex, salt_hex, passphrase)
1301
+ shard_items = []
1302
+ totals = {'tables': 0, 'tablesSampled': 0, 'rowsSampled': 0,
1303
+ 'serverIdMissing': 0}
1304
+ anchor_missing_total = 0
1305
+ # server_id(str) -> {(shard, table)} it was sampled in. A set per id keeps
1306
+ # the two rowid ends of one small table from double-counting the same row
1307
+ # as a "duplicate" - both ends see it, but it is one location.
1308
+ id_locations = {}
1309
+
1310
+ for failure in failures:
1311
+ shard_items.append({
1312
+ 'name': failure['name'], 'opened': False, 'reason': failure['reason'],
1313
+ 'tables': None,
1314
+ })
1315
+
1316
+ for path, conn in pairs:
1317
+ item = {'name': os.path.basename(path), 'opened': True, 'reason': None}
1318
+ try:
1319
+ tables = sorted(msg_tables(conn))
1320
+ item['tables'] = len(tables)
1321
+ totals['tables'] += len(tables)
1322
+
1323
+ # Schema half: exhaustive over every message table.
1324
+ anchor_missing = []
1325
+ for table in tables:
1326
+ cols = table_columns(conn.cursor(), table)
1327
+ missing = [c for c in MESSAGE_ANCHOR_COLUMNS if c not in cols]
1328
+ if missing and missing not in anchor_missing:
1329
+ anchor_missing.append(missing)
1330
+ anchor_missing_total += len(anchor_missing)
1331
+ item['anchorColumnsMissing'] = anchor_missing
1332
+
1333
+ # Data half: bounded sample, both rowid ends per table. A table
1334
+ # shorter than the bound hands the same rows to both ends - they
1335
+ # are one sample, not two, so rowids are deduplicated per table
1336
+ # (and the same id read at both ends must not look "duplicated").
1337
+ sample_targets = tables[:tables_per_shard]
1338
+ item['tablesSampled'] = len(sample_targets)
1339
+ totals['tablesSampled'] += len(sample_targets)
1340
+ sampled = 0
1341
+ missing_ids = 0
1342
+ for table in sample_targets:
1343
+ c = conn.cursor()
1344
+ seen_rowids = set()
1345
+ table_ids = set()
1346
+ for order in ('ASC', 'DESC'):
1347
+ c.execute(
1348
+ 'SELECT rowid, server_id FROM "%s" ORDER BY rowid %s LIMIT ?'
1349
+ % (table, order), (rows_per_table,))
1350
+ for rowid, server_id in c.fetchall():
1351
+ if rowid in seen_rowids:
1352
+ continue
1353
+ seen_rowids.add(rowid)
1354
+ sampled += 1
1355
+ if server_id is None or server_id == 0:
1356
+ missing_ids += 1
1357
+ else:
1358
+ table_ids.add(str(int(server_id)))
1359
+ for sid in table_ids:
1360
+ id_locations.setdefault(sid, set()).add((item['name'], table))
1361
+ item['rowsSampled'] = sampled
1362
+ item['serverIdMissing'] = missing_ids
1363
+ totals['rowsSampled'] += sampled
1364
+ totals['serverIdMissing'] += missing_ids
1365
+ except Exception as exc:
1366
+ item['reason'] = 'READ_FAILED: %s' % str(exc).split('\n')[0][:120]
1367
+ finally:
1368
+ shard_items.append(item)
1369
+ conn.close()
1370
+
1371
+ duplicates = {sid: locs for sid, locs in id_locations.items() if len(locs) > 1}
1372
+ return {
1373
+ 'shards': shard_items,
1374
+ 'schemaAnchorMissingTables': anchor_missing_total,
1375
+ 'sample': {
1376
+ 'tablesPerShard': tables_per_shard,
1377
+ 'rowsPerTable': rows_per_table,
1378
+ 'exhaustive': False,
1379
+ 'tablesTotal': totals['tables'],
1380
+ 'tablesSampled': totals['tablesSampled'],
1381
+ 'rowsSampled': totals['rowsSampled'],
1382
+ 'serverIdMissing': totals['serverIdMissing'],
1383
+ 'duplicateServerIds': len(duplicates),
1384
+ 'duplicateServerIdSamples': [
1385
+ {'serverId': sid, 'locations': sorted('%s/%s' % loc for loc in locs)}
1386
+ for sid, locs in sorted(duplicates.items())[:20]
1387
+ ],
1388
+ },
1389
+ }
1390
+
1391
+
1278
1392
  def get_contacts(conns, limit=200, keyword=None):
1279
1393
  """Get contacts from NT database, merged across every shard.
1280
1394
 
@@ -1627,10 +1741,23 @@ def main():
1627
1741
  fav_list_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1628
1742
  fav_list_parser.add_argument('--limit', type=int, default=100)
1629
1743
  fav_list_parser.add_argument('--offset', type=int, default=0)
1630
- fav_list_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Search keyword')
1744
+ fav_list_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Filter by keyword')
1631
1745
  fav_list_parser.add_argument('--type', type=int, dest='fav_type',
1632
1746
  help='Filter by type: 1=text 2=image 4=video 5=article 14=chatrecord')
1633
1747
 
1748
+ # cursor-probe command: the data-side half of D-027's stable-cursor answer.
1749
+ cursor_parser = sub.add_parser(
1750
+ 'cursor-probe',
1751
+ help='Probe what a stable incremental cursor would need here (read-only, bounded sample)')
1752
+ cursor_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to NT database')
1753
+ cursor_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
1754
+ cursor_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
1755
+ cursor_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
1756
+ cursor_parser.add_argument('--tables-per-shard', type=int, default=5,
1757
+ help='Message tables sampled per shard for the identity half (schema half is exhaustive)')
1758
+ cursor_parser.add_argument('--rows-per-table', type=int, default=500,
1759
+ help='Rows read at each rowid end per sampled table')
1760
+
1634
1761
  args = parser.parse_args()
1635
1762
 
1636
1763
  if args.command == 'scan':
@@ -1812,6 +1939,13 @@ def main():
1812
1939
  args.keyword, getattr(args, 'fav_type', None))
1813
1940
  print(json.dumps(result, ensure_ascii=True, default=str))
1814
1941
 
1942
+ elif args.command == 'cursor-probe':
1943
+ result = cursor_probe(args.db, args.key, args.salt,
1944
+ getattr(args, 'passphrase', '') or '',
1945
+ tables_per_shard=args.tables_per_shard,
1946
+ rows_per_table=args.rows_per_table)
1947
+ print(json.dumps(result, ensure_ascii=True))
1948
+
1815
1949
  else:
1816
1950
  parser.print_help()
1817
1951