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
@@ -0,0 +1,68 @@
1
+ # -*- coding: utf-8 -*-
2
+ """读 `weflow-cli import qchat` 落下的存储(output/imports/qq/sessions/)。
3
+
4
+ **单一读者**:chat_notes 与 semantic_search 都从这里取导入会话。两边各解析一遍格式,
5
+ 漂了就是"知识卡看得见、搜索看不见"这类静默分叉——`nt_common` 存在的同款理由。
6
+
7
+ 存储形状(写入方是 `src/services/qchatImport.ts`,字段语义对齐 weflow-message/v1):
8
+ 每个会话一个 JSON:schema/session{id,name,type}/importedAt/sourceFiles/messages[],
9
+ 消息字段 serverId(=qchat messageId,全局唯一)/createTime(unix 秒)/isSend(可缺省=
10
+ 未知)/senderUsername(QQ 号,敏感)/senderName/messageType(13 稳定名)/parsedContent/
11
+ rawContent。
12
+
13
+ 路径:默认 `output/imports/qq/sessions`(cwd 相对,与 chat_notes 的 OUTPUT_ROOT 同一
14
+ 约定);`WEFLOW_IMPORT_ROOT` 指到别处时用 `<root>/sessions`(测试用,TS 写入方读同
15
+ 一个变量)。
16
+ """
17
+ import json
18
+ import os
19
+ from pathlib import Path
20
+
21
+ SCHEMA = 'weflow-import/qq-v1'
22
+
23
+
24
+ def sessions_dir(root=None):
25
+ if root is None:
26
+ root = os.environ.get('WEFLOW_IMPORT_ROOT')
27
+ if root:
28
+ return Path(root) / 'sessions'
29
+ return Path('output/imports/qq/sessions')
30
+
31
+
32
+ def sessions(root=None):
33
+ """全部已导入会话(按导入时间新→旧)。坏文件/非本 schema 的文件跳过不挡路。
34
+
35
+ 返回的是存储原文(dict),消息已按 createTime 升序。
36
+ """
37
+ d = sessions_dir(root)
38
+ if not d.is_dir():
39
+ return []
40
+ out = []
41
+ for p in sorted(d.glob('*.json')):
42
+ try:
43
+ data = json.loads(p.read_text(encoding='utf-8'))
44
+ except Exception:
45
+ continue
46
+ if not isinstance(data, dict):
47
+ continue
48
+ if data.get('schema') != SCHEMA or not isinstance(data.get('messages'), list):
49
+ continue
50
+ out.append(data)
51
+ out.sort(key=lambda s: s.get('importedAt') or '', reverse=True)
52
+ return out
53
+
54
+
55
+ def session_messages(sess, days=None, now=None):
56
+ """一个会话的消息,可选按"最近 days 天"过滤。
57
+
58
+ 时间语义与 chat_notes/semantic_search 的窗口一致:unix 秒、含当天。
59
+ """
60
+ from datetime import datetime, timedelta, timezone
61
+
62
+ msgs = [m for m in sess.get('messages', []) if isinstance(m, dict)]
63
+ if days is None:
64
+ return msgs
65
+ tz = timezone(timedelta(hours=8))
66
+ now = now or datetime.now(tz)
67
+ cutoff = int((now - timedelta(days=days)).timestamp())
68
+ return [m for m in msgs if (m.get('createTime') or 0) >= cutoff]
@@ -24,6 +24,7 @@ except ImportError:
24
24
 
25
25
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
26
26
  from _utils import load_config, decrypt_lock, parse_frontmatter
27
+ from nt_common import decode_content
27
28
 
28
29
  OUTPUT_ROOT = 'output'
29
30
  TZ = timezone(timedelta(hours=8))
@@ -196,7 +197,8 @@ def cmd_search_messages(args):
196
197
  ''', (start_ts, end_ts))
197
198
 
198
199
  for ts, sender, content in c.fetchall():
199
- if not content or not isinstance(content, str):
200
+ content = decode_content(content) # 压缩正文(issue #12)
201
+ if not content:
200
202
  continue
201
203
  if keyword and keyword.lower() not in content.lower():
202
204
  continue
@@ -0,0 +1,150 @@
1
+ """媒体精确匹配:用微信自己的 `hardlink.db` 把 md5 直接对到本地文件,不猜目录。
2
+
3
+ 在此之前,找一张图/一个文件只能**扫描整个 msg 目录**再比对,慢且会漏(同 md5 的
4
+ 转发件、被改名的副本都不认)。`hardlink.db` 是微信自己的索引:三张表分别记
5
+ image / file / video 的 `md5 → (dir1, dir2, file_name)`,`dir2id` 把这两个 id 映射成
6
+ 目录名,路径因此是**确定的**。
7
+
8
+ 实测出来的路径规律(本机 2026-10-26,逐条对过真实文件):
9
+
10
+ msg/attach/<dir2id[dir2]>/<dir2id[dir1]>/Img/<file_name> 图片
11
+ msg/file/<dir2id[dir1]>/<file_name> 文件
12
+ msg/video/<dir2id[dir1]>/<file_name> 视频
13
+
14
+ 用法:
15
+ python scripts/media_index.py --md5 <md5前若干位>
16
+ python scripts/media_index.py --list --type file --limit 20
17
+ python scripts/media_index.py --json
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import pathlib
24
+ import sys
25
+
26
+ SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
27
+ sys.path.insert(0, str(SCRIPT_DIR))
28
+
29
+ from _utils import load_config, decrypt_lock # noqa: E402
30
+ from nt_common import derive_database_key # noqa: E402
31
+ from export_chat_html import connect # noqa: E402
32
+
33
+ #: (表名, 相对 `msg/` 的路径模板)。
34
+ #:
35
+ #: **图片的 dir1/dir2 顺序与另两张表相反**:实测 image 行是 `dir1=会话hash, dir2=月份`
36
+ #: (落在 `attach/<会话>/<月份>/Img/`),而 file/video 行是 `dir1=月份, dir2=0`。
37
+ #: 按统一顺序拼会把图片全指到 `attach/<月份>/<会话>/` 下一个也不存在(2026-10-26 实测)。
38
+ TABLES = {
39
+ 'image': ('image_hardlink_info_v4', 'attach/{d1}/{d2}/Img/{name}'),
40
+ 'file': ('file_hardlink_info_v4', 'file/{d1}/{name}'),
41
+ 'video': ('video_hardlink_info_v4', 'video/{d1}/{name}'),
42
+ }
43
+
44
+
45
+ def hardlink_path(cfg):
46
+ """hardlink.db 与消息库同属 db_storage,用 `ntDbPath` 反推。"""
47
+ msg = pathlib.Path(cfg.get('ntDbPath') or '')
48
+ if not msg.name:
49
+ raise SystemExit('配置里没有 ntDbPath')
50
+ return msg.parent.parent / 'hardlink' / 'hardlink.db'
51
+
52
+
53
+ def account_root(cfg):
54
+ """`msg/` 的父目录(媒体按这种结构摆放)。"""
55
+ return pathlib.Path(cfg.get('ntDbPath') or '').parent.parent.parent
56
+
57
+
58
+ def open_db(cfg):
59
+ path = hardlink_path(cfg)
60
+ if not path.is_file():
61
+ raise SystemExit(f'找不到 {path}')
62
+ key = decrypt_lock(cfg.get('ntKey', '') or '')
63
+ salt = decrypt_lock(cfg.get('ntSalt', '') or '')
64
+ pw = decrypt_lock(cfg.get('favPassphrase', '') or cfg.get('decryptKey', '') or '')
65
+ k, s = derive_database_key(str(path), key, salt, pw)
66
+ return connect(str(path), k, s)
67
+
68
+
69
+ def load_rows(conn, kind):
70
+ table = TABLES[kind][0]
71
+ cur = conn.cursor()
72
+ dirs = {rid: name for rid, name in cur.execute('SELECT rowid, username FROM dir2id')}
73
+ rows = []
74
+ for md5, typ, name, size, d1, d2 in cur.execute(
75
+ 'SELECT md5, type, file_name, file_size, dir1, dir2 FROM "%s"' % table):
76
+ rows.append({'md5': md5 or '', 'type': typ, 'file': name or '',
77
+ 'size': size or 0, 'dir1': dirs.get(d1, ''), 'dir2': dirs.get(d2, ''),
78
+ 'kind': kind})
79
+ return rows
80
+
81
+
82
+ def resolve(cfg, row):
83
+ """一行 → 本地绝对路径(不保证文件还在:微信会清理)。"""
84
+ tmpl = TABLES[row['kind']][1]
85
+ rel = tmpl.format(d1=row['dir1'], d2=row['dir2'], name=row['file'])
86
+ return account_root(cfg) / 'msg' / rel
87
+
88
+
89
+ def main():
90
+ # 手跑时 stdout 是本地代码页(本机 GBK),下面的 ✓/✗ 在 GBK 里编不出来会直接抛
91
+ # UnicodeEncodeError。走 bridge 时父进程已设 PYTHONIOENCODING,两条路表现才会一致。
92
+ try:
93
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
94
+ except Exception:
95
+ pass
96
+ ap = argparse.ArgumentParser(description='媒体精确匹配(读 hardlink.db,本地只读)')
97
+ ap.add_argument('--md5', default='', help='按 md5 前缀查(不区分大小写)')
98
+ ap.add_argument('--type', default='all', choices=['all', 'image', 'file', 'video'])
99
+ ap.add_argument('--list', action='store_true', help='列出行(不给 --md5 时等价于列出)')
100
+ ap.add_argument('--limit', type=int, default=20)
101
+ ap.add_argument('--missing-only', action='store_true', help='只列文件已不在本地的')
102
+ ap.add_argument('--json', action='store_true')
103
+ args = ap.parse_args()
104
+
105
+ cfg = load_config()
106
+ conn, _cur = open_db(cfg)
107
+ try:
108
+ kinds = list(TABLES) if args.type == 'all' else [args.type]
109
+ rows = []
110
+ for kind in kinds:
111
+ rows.extend(load_rows(conn, kind))
112
+ finally:
113
+ try:
114
+ conn.close()
115
+ except Exception:
116
+ pass
117
+
118
+ if args.md5:
119
+ needle = args.md5.lower()
120
+ rows = [r for r in rows if r['md5'].lower().startswith(needle)]
121
+
122
+ for r in rows:
123
+ p = resolve(cfg, r)
124
+ r['path'] = str(p)
125
+ r['exists'] = p.is_file()
126
+ if args.missing_only:
127
+ rows = [r for r in rows if not r['exists']]
128
+
129
+ total = len(rows)
130
+ rows = rows[:args.limit]
131
+
132
+ if args.json:
133
+ print(json.dumps({'success': True, 'total': total, 'rows': rows},
134
+ ensure_ascii=False, indent=2))
135
+ return
136
+ if not rows:
137
+ print('没有匹配的记录')
138
+ return
139
+ present = sum(1 for r in rows if r['exists'])
140
+ print(f'匹配 {total} 条(显示 {len(rows)} 条,其中文件仍在本地的 {present} 条)\n')
141
+ for r in rows:
142
+ mark = '✓' if r['exists'] else '✗'
143
+ print(f"{mark} {r['kind']:<5} {r['md5'][:12]} {r['file'][:44]:<44} {r['size']:>9} 字节")
144
+ print(f" {r['path']}")
145
+ if present < len(rows):
146
+ print(f'\n(✗ 表示 hardlink.db 有记录但文件已被微信清理,不是路径算错)')
147
+
148
+
149
+ if __name__ == '__main__':
150
+ main()
@@ -80,3 +80,35 @@ def table_columns(cursor, table):
80
80
  except Exception:
81
81
  return set()
82
82
  return {row[1] for row in rows}
83
+
84
+
85
+ #: zstd frame magic。`message_content` 在 4.x 里**可能被压缩**(issue #12)。
86
+ ZSTD_MAGIC = bytes([0x28, 0xB5, 0x2F, 0xFD])
87
+
88
+
89
+ def decode_content(value):
90
+ """`message_content` → 文本。**所有读消息的脚本都该走这里。**
91
+
92
+ 三种形态都要接:`str`(老格式,直接返回)、zstd frame(4.x 压缩后存的)、
93
+ 以及普通 bytes。解不出就回空串——调用方拿到的永远是字符串,不需要自己判断类型。
94
+
95
+ 为什么要一个公共函数:这条规则以前散在五个脚本里,其中四个写的是
96
+ `if not isinstance(content, str): continue`,于是**压缩正文被整条跳过**——
97
+ 消息不进索引、不进待办、不进年度统计,而且不报错(issue #12 同源)。
98
+ """
99
+ if isinstance(value, str):
100
+ return value
101
+ if not value:
102
+ return ''
103
+ try:
104
+ data = bytes(value)
105
+ except Exception:
106
+ return ''
107
+ if data[:4] == ZSTD_MAGIC:
108
+ try:
109
+ import zstandard
110
+ return zstandard.ZstdDecompressor().decompress(data).decode('utf-8', 'ignore')
111
+ except Exception:
112
+ # 声称是 zstd 但内容坏了:回退成按字节解码,不抛也不丢整条消息。
113
+ return data.decode('utf-8', 'ignore')
114
+ return data.decode('utf-8', 'ignore')
@@ -989,19 +989,13 @@ QUOTE_CLIP = 120
989
989
 
990
990
  ZSTD_MAGIC = bytes([0x28, 0xB5, 0x2F, 0xFD])
991
991
  def _decode_content(raw):
992
- """消息内容 → 文本。BLOB 先按 zstd 解压(公众号消息同一套机制)。解不出回空串。"""
993
- if isinstance(raw, str):
994
- return raw
995
- if not raw:
996
- return ''
997
- data = bytes(raw)
998
- if data[:4] == ZSTD_MAGIC: # zstd frame header
999
- try:
1000
- import zstandard
1001
- return zstandard.ZstdDecompressor().decompress(data).decode('utf-8', 'ignore')
1002
- except Exception:
1003
- return ''
1004
- return data.decode('utf-8', 'ignore')
992
+ """消息内容 → 文本。实现在 `nt_common.decode_content`(各脚本共用一份)。
993
+
994
+ 保留这个名字是为了不动 `non_text_display` 等既有调用点;行为与公共函数一致:
995
+ str 原样、zstd frame 解压、其它 bytes 按 UTF-8 解码,解不出回空串。
996
+ """
997
+ from nt_common import decode_content
998
+ return decode_content(raw)
1005
999
 
1006
1000
 
1007
1001
  def _xml_block(xml, tag):
@@ -1117,7 +1111,15 @@ def _message_dict(row, sender_id_map, name_map, own_wxid, is_group=False):
1117
1111
 
1118
1112
  # Parse message_content - TEXT column
1119
1113
  raw_content = row.get('message_content')
1120
- content = raw_content if isinstance(raw_content, str) else ""
1114
+ # **普通文本也可能是 zstd 压缩的 BLOB**(issue #12)。这里原来只认 `str`,
1115
+ # 于是 `local_type == 1` 的压缩正文被置空、再交给 `non_text_display()`,
1116
+ # 最终输出 `[未识别的消息类型 1]` —— 消息还在,正文没了,下游按
1117
+ # `parsedContent` 做的关键词 / @所有人 筛选就漏掉这些通知。
1118
+ # `_decode_content()` 已经处理三种形态(str / zstd frame / 普通 bytes),复用它即可。
1119
+ if local_type == 1:
1120
+ content = _decode_content(raw_content)
1121
+ else:
1122
+ content = raw_content if isinstance(raw_content, str) else ""
1121
1123
 
1122
1124
  # `content`/`rawContent` stay exactly as stored; only `parsedContent` - the
1123
1125
  # field every consumer reads first - gets the display-ready form.
@@ -1140,7 +1142,9 @@ def _message_dict(row, sender_id_map, name_map, own_wxid, is_group=False):
1140
1142
  "senderDisplay": sender_display,
1141
1143
  "content": content,
1142
1144
  "rawContent": content,
1143
- "parsedContent": display[:200] if local_type == 1 else display[:200],
1145
+ # 文本不截断:末尾的 `@所有人` 一类关键词正是下游筛选要用的,截掉等于静默漏消息
1146
+ # (issue #12)。非文本仍保留 200 字的展示上限——它们是标签和标题,不是正文。
1147
+ "parsedContent": display if local_type == 1 else display[:200],
1144
1148
  }
1145
1149
 
1146
1150
 
@@ -1275,6 +1279,120 @@ def get_messages(conns, talker, limit=100, offset=0, name_map=None, own_wxid=Non
1275
1279
  return {"messages": collected}
1276
1280
 
1277
1281
 
1282
+ def cursor_probe(db_path, key_hex, salt_hex, passphrase='',
1283
+ tables_per_shard=5, rows_per_table=500):
1284
+ """Measure what a stable incremental cursor would need on this database.
1285
+
1286
+ D-027 keeps `stableCursor: false` until every message backend can honour
1287
+ the same semantics. This probe answers the NT backend's **data-side** half:
1288
+ whether every message table carries the anchor columns, and whether sampled
1289
+ rows actually hold a nonzero `server_id` (the reader's identity falls back
1290
+ to localId+createTime without one, and `local_id` is per-shard, so those
1291
+ rows cannot be resumed across shards). Duplicate server ids are collected
1292
+ across the sample too - an id that appears in two places cannot key a
1293
+ cursor, whatever the column definition says.
1294
+
1295
+ Sampling is bounded and says so (`exhaustive: false`): `tables_per_shard`
1296
+ tables per shard (sorted names, deterministic) read at both rowid ends,
1297
+ `rows_per_table` rows each. A full scan of every shard would cost minutes
1298
+ on a real account; the schema check IS exhaustive - `PRAGMA table_info` is
1299
+ cheap, so every Msg_* table's anchor columns are checked, not sampled.
1300
+
1301
+ Read-only: no write, no network, nothing leaves stdout.
1302
+ """
1303
+ pairs, failures = connect_message_shards_detailed(
1304
+ db_path, key_hex, salt_hex, passphrase)
1305
+ shard_items = []
1306
+ totals = {'tables': 0, 'tablesSampled': 0, 'rowsSampled': 0,
1307
+ 'serverIdMissing': 0}
1308
+ anchor_missing_total = 0
1309
+ # server_id(str) -> {(shard, table)} it was sampled in. A set per id keeps
1310
+ # the two rowid ends of one small table from double-counting the same row
1311
+ # as a "duplicate" - both ends see it, but it is one location.
1312
+ id_locations = {}
1313
+
1314
+ for failure in failures:
1315
+ shard_items.append({
1316
+ 'name': failure['name'], 'opened': False, 'reason': failure['reason'],
1317
+ 'tables': None,
1318
+ })
1319
+
1320
+ for path, conn in pairs:
1321
+ item = {'name': os.path.basename(path), 'opened': True, 'reason': None}
1322
+ try:
1323
+ tables = sorted(msg_tables(conn))
1324
+ item['tables'] = len(tables)
1325
+ totals['tables'] += len(tables)
1326
+
1327
+ # Schema half: exhaustive over every message table.
1328
+ anchor_missing = []
1329
+ for table in tables:
1330
+ cols = table_columns(conn.cursor(), table)
1331
+ missing = [c for c in MESSAGE_ANCHOR_COLUMNS if c not in cols]
1332
+ if missing and missing not in anchor_missing:
1333
+ anchor_missing.append(missing)
1334
+ anchor_missing_total += len(anchor_missing)
1335
+ item['anchorColumnsMissing'] = anchor_missing
1336
+
1337
+ # Data half: bounded sample, both rowid ends per table. A table
1338
+ # shorter than the bound hands the same rows to both ends - they
1339
+ # are one sample, not two, so rowids are deduplicated per table
1340
+ # (and the same id read at both ends must not look "duplicated").
1341
+ sample_targets = tables[:tables_per_shard]
1342
+ item['tablesSampled'] = len(sample_targets)
1343
+ totals['tablesSampled'] += len(sample_targets)
1344
+ sampled = 0
1345
+ missing_ids = 0
1346
+ for table in sample_targets:
1347
+ c = conn.cursor()
1348
+ seen_rowids = set()
1349
+ table_ids = set()
1350
+ for order in ('ASC', 'DESC'):
1351
+ c.execute(
1352
+ 'SELECT rowid, server_id FROM "%s" ORDER BY rowid %s LIMIT ?'
1353
+ % (table, order), (rows_per_table,))
1354
+ for rowid, server_id in c.fetchall():
1355
+ if rowid in seen_rowids:
1356
+ continue
1357
+ seen_rowids.add(rowid)
1358
+ sampled += 1
1359
+ if server_id is None or server_id == 0:
1360
+ missing_ids += 1
1361
+ else:
1362
+ table_ids.add(str(int(server_id)))
1363
+ for sid in table_ids:
1364
+ id_locations.setdefault(sid, set()).add((item['name'], table))
1365
+ item['rowsSampled'] = sampled
1366
+ item['serverIdMissing'] = missing_ids
1367
+ totals['rowsSampled'] += sampled
1368
+ totals['serverIdMissing'] += missing_ids
1369
+ except Exception as exc:
1370
+ item['reason'] = 'READ_FAILED: %s' % str(exc).split('\n')[0][:120]
1371
+ finally:
1372
+ shard_items.append(item)
1373
+ conn.close()
1374
+
1375
+ duplicates = {sid: locs for sid, locs in id_locations.items() if len(locs) > 1}
1376
+ return {
1377
+ 'shards': shard_items,
1378
+ 'schemaAnchorMissingTables': anchor_missing_total,
1379
+ 'sample': {
1380
+ 'tablesPerShard': tables_per_shard,
1381
+ 'rowsPerTable': rows_per_table,
1382
+ 'exhaustive': False,
1383
+ 'tablesTotal': totals['tables'],
1384
+ 'tablesSampled': totals['tablesSampled'],
1385
+ 'rowsSampled': totals['rowsSampled'],
1386
+ 'serverIdMissing': totals['serverIdMissing'],
1387
+ 'duplicateServerIds': len(duplicates),
1388
+ 'duplicateServerIdSamples': [
1389
+ {'serverId': sid, 'locations': sorted('%s/%s' % loc for loc in locs)}
1390
+ for sid, locs in sorted(duplicates.items())[:20]
1391
+ ],
1392
+ },
1393
+ }
1394
+
1395
+
1278
1396
  def get_contacts(conns, limit=200, keyword=None):
1279
1397
  """Get contacts from NT database, merged across every shard.
1280
1398
 
@@ -1627,10 +1745,23 @@ def main():
1627
1745
  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
1746
  fav_list_parser.add_argument('--limit', type=int, default=100)
1629
1747
  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')
1748
+ fav_list_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Filter by keyword')
1631
1749
  fav_list_parser.add_argument('--type', type=int, dest='fav_type',
1632
1750
  help='Filter by type: 1=text 2=image 4=video 5=article 14=chatrecord')
1633
1751
 
1752
+ # cursor-probe command: the data-side half of D-027's stable-cursor answer.
1753
+ cursor_parser = sub.add_parser(
1754
+ 'cursor-probe',
1755
+ help='Probe what a stable incremental cursor would need here (read-only, bounded sample)')
1756
+ 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')
1757
+ 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)')
1758
+ 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)')
1759
+ cursor_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
1760
+ cursor_parser.add_argument('--tables-per-shard', type=int, default=5,
1761
+ help='Message tables sampled per shard for the identity half (schema half is exhaustive)')
1762
+ cursor_parser.add_argument('--rows-per-table', type=int, default=500,
1763
+ help='Rows read at each rowid end per sampled table')
1764
+
1634
1765
  args = parser.parse_args()
1635
1766
 
1636
1767
  if args.command == 'scan':
@@ -1812,6 +1943,13 @@ def main():
1812
1943
  args.keyword, getattr(args, 'fav_type', None))
1813
1944
  print(json.dumps(result, ensure_ascii=True, default=str))
1814
1945
 
1946
+ elif args.command == 'cursor-probe':
1947
+ result = cursor_probe(args.db, args.key, args.salt,
1948
+ getattr(args, 'passphrase', '') or '',
1949
+ tables_per_shard=args.tables_per_shard,
1950
+ rows_per_table=args.rows_per_table)
1951
+ print(json.dumps(result, ensure_ascii=True))
1952
+
1815
1953
  else:
1816
1954
  parser.print_help()
1817
1955