weflow-cli 1.6.3 → 1.7.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 (43) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/OPERATIONS.md +36 -1
  3. package/README.en.md +1 -1
  4. package/README.md +1 -1
  5. package/bin/weflow-cli.ts +312 -0
  6. package/dist/bin/weflow-cli.js +352 -0
  7. package/dist/bin/weflow-cli.js.map +1 -1
  8. package/dist/src/core/ntCore.d.ts +26 -0
  9. package/dist/src/core/ntCore.d.ts.map +1 -1
  10. package/dist/src/core/ntCore.js +21 -0
  11. package/dist/src/core/ntCore.js.map +1 -1
  12. package/dist/src/services/chatService.d.ts +13 -0
  13. package/dist/src/services/chatService.d.ts.map +1 -1
  14. package/dist/src/services/chatService.js +20 -0
  15. package/dist/src/services/chatService.js.map +1 -1
  16. package/dist/src/services/messageQuery.d.ts +35 -0
  17. package/dist/src/services/messageQuery.d.ts.map +1 -1
  18. package/dist/src/services/messageQuery.js +61 -0
  19. package/dist/src/services/messageQuery.js.map +1 -1
  20. package/dist/src/services/syncService.d.ts +121 -0
  21. package/dist/src/services/syncService.d.ts.map +1 -0
  22. package/dist/src/services/syncService.js +244 -0
  23. package/dist/src/services/syncService.js.map +1 -0
  24. package/dist/src/services/syncState.d.ts +113 -0
  25. package/dist/src/services/syncState.d.ts.map +1 -0
  26. package/dist/src/services/syncState.js +214 -0
  27. package/dist/src/services/syncState.js.map +1 -0
  28. package/docs/DECISIONS.md +60 -20
  29. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +338 -0
  30. package/docs/HEALTH-CHECK.md +6 -5
  31. package/docs/MCP.md +1 -1
  32. package/docs/NPM-PUBLISH.md +12 -4
  33. package/docs/PROJECT_STATE.md +8 -6
  34. package/docs/SETUP.md +1 -1
  35. package/docs/SYNC_CONTRACT.md +152 -0
  36. package/package.json +3 -3
  37. package/scripts/export_chat_html.py +242 -26
  38. package/scripts/nt_decrypt.py +123 -15
  39. package/src/core/ntCore.ts +42 -0
  40. package/src/services/chatService.ts +26 -0
  41. package/src/services/messageQuery.ts +97 -0
  42. package/src/services/syncService.ts +335 -0
  43. package/src/services/syncState.ts +275 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,44 @@ The npm package is published separately from GitHub. It may lag behind the `mast
4
4
 
5
5
  All notable user-facing changes are recorded here. This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## 1.7.0
8
+
9
+ ### Added
10
+
11
+ - `sync run|status|verify`: a local message-sync checkpoint. `sync run` reads a time window, deduplicates against the previous checkpoint and records what it covered; `sync status` reports coverage without touching the database, so it still works while the database is locked. It does **not** advertise a stable cursor - overlapping windows plus local deduplication is what it offers, per D-027.
12
+ - Per-shard read reporting. Shard open and read failures used to be swallowed by a bare `except: continue`, so "read 31 messages" and "read one shard and silently skipped three" were indistinguishable from the outside. `--report-shards` (opt-in; without it the JSON is byte-identical) surfaces `scanned/opened/failed` and one entry per shard. On the machine this was developed against the report reads `message_0.db` 31 rows and `message_3.db` 1258 - exactly the shape of the shard-read bug fixed in 1.6.4, which was completely invisible at the time.
13
+ - A media coverage report on HTML export: `<prefix>_media.json` beside the parts, with a status and a reason for every media item (`embedded | cached | remote-fetched | missing | unsupported`). The exporter had been computing `COVER_STATE` counters and discarding them, and a media miss showed up only as a bare `[图片]` with no reason recorded. A real 120-message export reports 54 items: 35 embedded, 15 missing, 2 unsupported, 2 remote-fetched, with reasons `not-in-local-cache` and `voice-not-in-media-index`.
14
+ - `docs/SYNC_CONTRACT.md` freezing the `weflow-sync/v1` and `weflow-job/v1` state schemas, and `D-029` recording the additive-only boundary, why the identity omits `shard`, and why `sync retry` is deliberately not implemented.
15
+
16
+ ### Changed
17
+
18
+ - The HTML exporter now tries the conversation cache before the network when resolving an article thumbnail; it had been downloading covers it already had on disk.
19
+ - `collectMessagesInRangeDetailed` distinguishes why paging stopped. The original loop ended on a short page exactly as it ended on an exhausted conversation, so a short page caused by offset shifting on a live database looked identical to having reached the end. The original function is unchanged; the variant is used by the new sync path and available to the export path next.
20
+
21
+ ### Fixed
22
+
23
+ - A leaked database handle when a shard opened but its key was rejected: the connection was left open, which on Windows keeps the file locked.
24
+
25
+ ## 1.6.4
26
+
27
+ ### Fixed
28
+
29
+ - Restored four pieces of matching logic in the HTML exporter that the `90b165a` clone-consolidation merge had silently dropped, all of which lowered media coverage without failing loudly:
30
+ - the `unique:<local_id>` fallback, which matches an image when that id resolves to exactly one distinct picture in the conversation (content-deduped, so the shard-collision risk that rules out a bare `local_id` does not apply);
31
+ - `is_encoded_media_type`, needed because WeChat stores some forwards as high-bit variants of type 49 — the mask turns those into a plain 49, which is a registered type, so the branch guarding them had become unreachable;
32
+ - the guard that keeps a type-49 row carrying a title or url on the link-card path instead of hiding it behind a cached thumbnail;
33
+ - the cache-first lookup in the article branch, which had been downloading covers it already had on disk.
34
+ - Seeded `COVER_STATE`'s budgets with their limits instead of 0. They were only ever set by `main()`, so any caller reaching the fetchers another way silently skipped every remote fetch and got `None` back with no error.
35
+ - `pipeline_security_test` no longer reads the developer's real `~/.weflow-cli/config.json`; it points `CONFIG_PATH` at an empty temp file. The test only passed on a machine that happened to have a config, and reading a real config from a unit test is exactly what the repo rules forbid.
36
+ - CI actually runs, for the first time. `npm test` passed locally and failed on every push: the script used `test/**/*.test.ts`, which Git Bash expands locally but CI's bash does not (globstar is off by default), so Node received the literal pattern and reported `Could not find ...`. The glob is now quoted so Node expands it. The single combined job was also split into `node` / `python` / `release-consistency`, because a Node failure previously stopped the Python tests from running at all — they had been failing unnoticed.
37
+ - Raised the Node floor to 22.13.0. `src/core/sqlcipherCore.ts` imports `node:sqlite` at module scope; that builtin arrives in 22.5.0 and stops needing `--experimental-sqlite` at 22.13.0, so on Node 18/20 every command died at load while `engines` still claimed `>=18`. Both are past end-of-life, so the floor moved rather than adding a lazy-load path for dead runtimes.
38
+ - Rebuilt `package-lock.json`, which still said 1.6.1 and still listed `lz4` as an ordinary dependency after the 1.6.3 change moved it to optional. `npm audit fix` then took production vulnerabilities from 17 to 6; the remainder (`@xmldom/xmldom`, `exceljs`, `@wenyan-md/core`, `mermaid`, `speech-rule-engine`, `uuid`) have no non-breaking fix — `exceljs@4.4.0` and `@wenyan-md/core@3.0.11` are already the newest releases and the advisories' suggested "fix" is to downgrade them.
39
+
40
+ ### Added
41
+
42
+ - `docs/PROJECT_STATE.md` no longer carries a hardcoded version — it points at `package.json`, which is what drifted to `1.5.1`. Example version numbers in `docs/NPM-PUBLISH.md` and `docs/HEALTH-CHECK.md` became placeholders for the same reason.
43
+ - Git tags and GitHub Releases for `1.6.0`, `1.6.1` and `1.6.3`, so npm versions map to commits. `1.6.2` has neither: it was published, but its version bump was never committed.
44
+
7
45
  ## 1.6.3
8
46
 
9
47
  ### Fixed
package/OPERATIONS.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  | 依赖 | 用途 | 检查 |
8
8
  | --- | --- | --- |
9
- | Node.js 18+ | CLI、MCP、构建 | `node --version` |
9
+ | Node.js 22.13+ | CLI、MCP、构建 | `node --version` |
10
10
  | Python 3.10+ | NT 数据、日报、阅读器 | `python --version` |
11
11
  | `requirements.txt` | 标准 4.x 工作流 | `python -m pip install -r requirements.txt` |
12
12
  | `requirements-3x.txt` | 旧版 3.x 数据,可选 | `python -m pip install -r requirements-3x.txt` |
@@ -103,6 +103,41 @@ HTML 导出的时间几乎全部花在远程媒体上(公众号封面、B 站
103
103
  - 图片显示为 `[图片]` 且无图:该图片从未在这台设备上下载过。
104
104
  - `[位置]` 只有地名、没有地图:位置消息本身不携带地图图片。
105
105
 
106
+ 每次 HTML 导出还会在输出目录里写一份 **`<会话名>_media.json`**,逐项记录每个媒体的状态与原因,不用再靠肉眼比对:
107
+
108
+ | status | 含义 |
109
+ | --- | --- |
110
+ | `embedded` | 已内嵌进页面,`mediaKey` 说明按哪个键命中 |
111
+ | `cached` | 用了本地缓存 |
112
+ | `remote-fetched` | 从网络取回的(封面/链接图) |
113
+ | `missing` | 没有可用媒体,`reason` 说明为什么 |
114
+ | `unsupported` | 该类型暂不支持(如语音不在媒体索引里) |
115
+
116
+ `missing` 的常见 reason:`not-in-local-cache`(本机没有)、`no-reliable-identity`(这条消息没有可靠身份可匹配——按 D-014 宁缺毋滥,不会拿相邻消息或裸 localId 去猜)、`budget-exhausted`(本次远程抓取配额用完)、`download-failed`(链接失效)。
117
+
118
+ ### 同步检查点与覆盖范围
119
+
120
+ `sync` 用重叠时间窗增量读取,并在本机记下覆盖到哪:
121
+
122
+ ```powershell
123
+ weflow-cli sync run <会话> --since 2026-09-01 # 首次需要 --since 或 --full
124
+ weflow-cli sync run <会话> # 之后从上次检查点继续
125
+ weflow-cli sync status # 不访问数据库,数据库被占用时也能看
126
+ weflow-cli sync verify <会话> # 重读记录范围并与当前数据比对
127
+ ```
128
+
129
+ 每次运行都给出 `coverage`:
130
+
131
+ | coverage | 含义 |
132
+ | --- | --- |
133
+ | `complete` | 扫到的分片全部打开、打开的都读成功 |
134
+ | `unverified` | 有分片**打不开**——读到的部分可信,但那几个分片里有没有本会话消息**无法判断** |
135
+ | `partial` | 已打开的分片读失败、或还有更多未读。**不算成功**,退出码 1,且不推进"上次成功时间" |
136
+
137
+ 看到 `unverified` 时,`sync status` 的 `warnings` 会点名是哪个分片。这通常意味着该分片的密钥没解开——先排查 `init` 是否覆盖了全部分片。
138
+
139
+ **没有"稳定游标"**:`sync` 提供的是重叠窗口 + 本地去重,不是可直接续传的游标(D-027 要求所有后端都支持后才能宣称)。边界消息被重复读取是**预期行为**,会被去重吸收。
140
+
106
141
  ### 语音消息转文字
107
142
 
108
143
  HTML 里放不出语音:微信语音是 **SILK v3** 格式,浏览器不支持,ffmpeg 也没有 SILK 解码器(只有 AMR)。所以语音转文字是让语音消息在导出里能携带信息的唯一办法。
package/README.en.md CHANGED
@@ -78,7 +78,7 @@ weflow-cli mcp-config # one-shot MCP client integration
78
78
 
79
79
  ### 1. Install and Check
80
80
 
81
- Requires Node.js 18+, Python 3.10+, and a signed-in Windows WeChat. After installation, check your environment first:
81
+ Requires Node.js 22.13+, Python 3.10+, and a signed-in Windows WeChat. After installation, check your environment first:
82
82
 
83
83
  ```powershell
84
84
  npm install -g weflow-cli
package/README.md CHANGED
@@ -108,7 +108,7 @@ Linux 的自动初始化能力取决于微信发行版、Python 依赖和当前
108
108
 
109
109
  > npm 包与 GitHub 发布分开进行;需要最新修复时,请先确认 npm 包版本,或直接使用 GitHub `master` 分支代码。
110
110
 
111
- 需要 Node.js 18+、Python 3.10+,以及已登录的 Windows 微信。安装后先检查本机环境:
111
+ 需要 Node.js 22.13+、Python 3.10+,以及已登录的 Windows 微信。安装后先检查本机环境:
112
112
 
113
113
  ```powershell
114
114
  npm install -g weflow-cli
package/bin/weflow-cli.ts CHANGED
@@ -5,6 +5,8 @@ import inquirer from 'inquirer'
5
5
  import { basename, join } from 'path'
6
6
  import { existsSync, readFileSync, statSync, writeFileSync } from 'fs'
7
7
  import { homedir } from 'os'
8
+ import { SYNC_SCHEMA, SYNC_SOURCE, syncStateStore } from '../src/services/syncState.js'
9
+ import { SyncRangeRequiredError, runSync } from '../src/services/syncService.js'
8
10
  import { dbPathService } from '../src/core/dbPathService.js'
9
11
  import { keyService } from '../src/core/keyService.js'
10
12
  import { NtCore } from '../src/core/ntCore.js'
@@ -327,6 +329,26 @@ program
327
329
  previewExposesPaths: false,
328
330
  },
329
331
  evidencePackage: { cli: 'evidence <talker> --json --non-interactive', localOnly: true },
332
+ sync: {
333
+ preview: 'sync run <talker> --dry-run --json',
334
+ execute: 'sync run <talker> --yes --json',
335
+ status: 'sync status [<talker>] --json',
336
+ verify: 'sync verify <talker> --json',
337
+ confirmationRequired: true,
338
+ readsLocalData: true,
339
+ invokesAI: false,
340
+ sideEffects: ['local-checkpoint-file'],
341
+ stateSchema: 'weflow-sync/v1',
342
+ jobSchema: 'weflow-job/v1',
343
+ stateLocation: 'local-user-config-dir',
344
+ previewResolvesTalker: false,
345
+ previewExposesLocalPaths: false,
346
+ // D-027: a stable cursor must be backed by every database backend
347
+ // before it is advertised. This exposes overlapping windows only.
348
+ stableCursor: false,
349
+ coverageValues: ['complete', 'unverified', 'partial'],
350
+ partialCompletesSuccessfully: false,
351
+ },
330
352
  aiAnalysis: {
331
353
  preview: 'evidence-review <talker> --dry-run --json',
332
354
  execute: 'evidence-review <talker> --yes --json',
@@ -1237,6 +1259,296 @@ program
1237
1259
  }
1238
1260
  })
1239
1261
 
1262
+ // ==================== sync ====================
1263
+ // 增量同步检查点。契约见 docs/SYNC_CONTRACT.md, 边界见 D-029。
1264
+ // 不宣称「稳定游标」: D-027 要求它在所有后端支持前不得对外宣称,
1265
+ // 这里只做重叠时间窗 + 本地去重。
1266
+ const syncCmd = program.command('sync').description('本地消息同步检查点(重叠窗口读取与覆盖报告)')
1267
+
1268
+ syncCmd
1269
+ .command('run <talker>')
1270
+ .description('执行一次同步并写入检查点')
1271
+ .option('--since <date>', '起始日期或 ISO 时间(首次运行需要)')
1272
+ .option('--full', '读取全部历史(首次运行需要;耗时可能较长)')
1273
+ .option('--overlap <seconds>', '重叠窗口秒数', '300')
1274
+ .option('-n, --limit <number>', '本次最多读取条数(0=不限)', '0')
1275
+ .option('--dry-run', '仅预览,不读取消息、不写状态')
1276
+ .option('--yes', '确认执行')
1277
+ .option('--json', '输出机器可读结果')
1278
+ .option('--non-interactive', '禁止交互确认')
1279
+ .action(async (talker: string, opts) => {
1280
+ const overlap = parseCliInteger(opts.overlap, 'overlap', 0, 86400, !!opts.json)
1281
+ const limit = parseCliInteger(opts.limit, 'limit', 0, 1_000_000, !!opts.json)
1282
+
1283
+ let since: number | undefined
1284
+ if (opts.since) {
1285
+ try {
1286
+ since = parseLocalDateOrIso(opts.since)
1287
+ } catch (error) {
1288
+ const message = error instanceof DateRangeError ? error.message : '日期格式无效'
1289
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'INVALID_DATE', error: message })); process.exit(1) }
1290
+ console.error(chalk.red(`✗ ${message}`))
1291
+ process.exit(1)
1292
+ }
1293
+ }
1294
+ if (opts.full && since !== undefined) {
1295
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error: '--since 与 --full 不能同时使用' })); process.exit(1) }
1296
+ console.error(chalk.red('✗ --since 与 --full 不能同时使用'))
1297
+ process.exit(1)
1298
+ }
1299
+
1300
+ // Preview deliberately does not resolve the talker: that reads the
1301
+ // database, and a preview must not touch local data (D-025).
1302
+ const prior = (() => {
1303
+ try { return syncStateStore.read(SYNC_SOURCE, talker) } catch { return null }
1304
+ })()
1305
+ const preview = {
1306
+ success: true,
1307
+ dryRun: true,
1308
+ action: 'sync.run',
1309
+ schema: SYNC_SCHEMA,
1310
+ source: SYNC_SOURCE,
1311
+ talkerResolved: false,
1312
+ hasCheckpoint: !!prior,
1313
+ window: {
1314
+ from: since ?? prior?.checkpoint.newestCreateTime ?? null,
1315
+ overlapSeconds: overlap,
1316
+ full: !!opts.full,
1317
+ },
1318
+ estimatedRecords: prior?.recordsRead ?? null,
1319
+ readsLocalChat: true,
1320
+ writesLocalState: true,
1321
+ stableCursor: false,
1322
+ }
1323
+
1324
+ if (opts.dryRun) {
1325
+ if (opts.json) console.log(JSON.stringify(preview))
1326
+ else {
1327
+ console.log(chalk.cyan('\n同步预览'))
1328
+ console.log(chalk.gray(` 范围: ${opts.full ? '全部历史' : since ? new Date(since * 1000).toLocaleString() : '上次检查点'}`))
1329
+ console.log(chalk.gray(` 重叠窗口: ${overlap} 秒`))
1330
+ console.log(chalk.gray(' 将读取本地聊天记录并写入同步检查点,不调用 AI。\n'))
1331
+ }
1332
+ return
1333
+ }
1334
+
1335
+ if (!opts.yes) {
1336
+ if (opts.json) {
1337
+ console.log(JSON.stringify({ ...preview, success: false, dryRun: false, code: 'CONFIRMATION_REQUIRED' }))
1338
+ process.exit(1)
1339
+ }
1340
+ if (opts.nonInteractive) {
1341
+ console.error(chalk.red('✗ 非交互模式需要 --yes'))
1342
+ process.exit(1)
1343
+ }
1344
+ const { confirmed } = await inquirer.prompt([{
1345
+ type: 'confirm',
1346
+ name: 'confirmed',
1347
+ message: opts.full
1348
+ ? '将读取该会话的全部历史消息,可能耗时数分钟。继续吗?'
1349
+ : '将读取指定范围内的消息并写入本地同步检查点。继续吗?',
1350
+ default: false,
1351
+ }])
1352
+ if (!confirmed) {
1353
+ console.log(chalk.gray('已取消'))
1354
+ return
1355
+ }
1356
+ }
1357
+
1358
+ if (!configService.isConfigured()) {
1359
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'NOT_INITIALIZED', error: '未完成初始化' })); process.exit(1) }
1360
+ console.error(chalk.red('✗ 未完成初始化,请先运行 weflow-cli init'))
1361
+ process.exit(1)
1362
+ }
1363
+
1364
+ let resolvedTalker = ''
1365
+ try {
1366
+ resolvedTalker = await resolveTalkerCore(talker, { interactive: !opts.nonInteractive })
1367
+ } catch {
1368
+ resolvedTalker = ''
1369
+ }
1370
+ if (!resolvedTalker) {
1371
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error: '未找到该会话' })); process.exit(1) }
1372
+ console.error(chalk.red('✗ 未找到该会话'))
1373
+ process.exit(1)
1374
+ }
1375
+ // A display name is nicety only; the state is keyed by the resolved id.
1376
+ let displayName = resolvedTalker
1377
+ try {
1378
+ const match = (await chatService.listSessions()).find(s => s.username === resolvedTalker)
1379
+ if (match?.displayName && match.displayName !== resolvedTalker) displayName = match.displayName
1380
+ } catch { /* keep the id */ }
1381
+
1382
+ try {
1383
+ const result = await runSync(resolvedTalker, {
1384
+ since, full: !!opts.full, overlapSeconds: overlap, limit,
1385
+ scope: displayName,
1386
+ read: (who, howMany) => chatService.getMessagesWithShards(who, howMany, 0),
1387
+ })
1388
+ if (opts.json) {
1389
+ console.log(JSON.stringify({
1390
+ success: result.success,
1391
+ ...(result.code ? { code: result.code } : {}),
1392
+ action: result.action,
1393
+ schema: SYNC_SCHEMA,
1394
+ source: SYNC_SOURCE,
1395
+ scope: result.scope,
1396
+ talker: resolvedTalker,
1397
+ window: result.window,
1398
+ recordsRead: result.recordsRead,
1399
+ recordsDeduplicated: result.recordsDeduplicated,
1400
+ recordsReturned: result.recordsReturned,
1401
+ shards: result.shards,
1402
+ coverage: result.coverage,
1403
+ mayHaveMore: result.mayHaveMore,
1404
+ partial: result.partial,
1405
+ warnings: result.warnings,
1406
+ jobId: result.jobId,
1407
+ }))
1408
+ if (!result.success) process.exit(1)
1409
+ return
1410
+ }
1411
+ const mark = result.coverage === 'complete' ? '✓' : result.coverage === 'unverified' ? '!' : '✗'
1412
+ console.log(chalk.green(`\n${mark} 同步完成: ${result.recordsReturned}/${result.recordsRead} 条(去重 ${result.recordsDeduplicated})`))
1413
+ console.log(chalk.gray(` 覆盖: ${result.coverage} · 分片: ${result.shards?.opened ?? '?'} 读 / ${result.shards?.failed ?? 0} 失败`))
1414
+ for (const warning of result.warnings) console.log(chalk.yellow(` ⚠ ${warning}`))
1415
+ if (result.partial) {
1416
+ console.log(chalk.yellow(' 本次为部分完成,未推进成功时间;用 sync status 查看详情。'))
1417
+ }
1418
+ console.log('')
1419
+ if (!result.success) process.exit(1)
1420
+ } catch (error: any) {
1421
+ if (error instanceof SyncRangeRequiredError) {
1422
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'SYNC_RANGE_REQUIRED', error: error.message })); process.exit(1) }
1423
+ console.error(chalk.red(`✗ ${error.message}`))
1424
+ process.exit(1)
1425
+ }
1426
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'SYNC_FAILED', error: '同步失败' })); process.exit(1) }
1427
+ console.error(chalk.red(`✗ 同步失败: ${error?.message || error}`))
1428
+ process.exit(1)
1429
+ }
1430
+ })
1431
+
1432
+ syncCmd
1433
+ .command('status [talker]')
1434
+ .description('查看同步检查点与最近任务')
1435
+ .option('--json', '输出 JSON 格式')
1436
+ .action(async (talker: string | undefined, opts) => {
1437
+ // No database access: this has to work while the database is locked.
1438
+ let states: any[] = []
1439
+ try { states = syncStateStore.list() } catch { states = [] }
1440
+ const lastJob = syncStateStore.recentJobs(1)[0] ?? null
1441
+
1442
+ if (talker) {
1443
+ const match = states.find(s => s.talker === talker || s.scope === talker)
1444
+ if (!match) {
1445
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'SYNC_STATE_NOT_FOUND', error: '没有该会话的同步记录' })); process.exit(1) }
1446
+ console.error(chalk.red('✗ 没有该会话的同步记录'))
1447
+ process.exit(1)
1448
+ }
1449
+ if (opts.json) { console.log(JSON.stringify({ success: true, schema: SYNC_SCHEMA, state: match, lastJob })); return }
1450
+ console.log(chalk.cyan(`\n同步记录 — ${match.scope}`))
1451
+ console.log(chalk.gray(` 覆盖: ${match.coveredFrom || '—'} ~ ${match.coveredTo || '—'}`))
1452
+ console.log(chalk.gray(` 可信度: ${match.coverage} · 分片 ${match.shardsRead} 读 / ${match.shardsFailed} 失败`))
1453
+ console.log(chalk.gray(` 上次成功: ${match.lastSuccessfulRun || '—'} · 上次尝试: ${match.lastAttempt || '—'}\n`))
1454
+ return
1455
+ }
1456
+
1457
+ if (opts.json) {
1458
+ console.log(JSON.stringify({
1459
+ success: true,
1460
+ schema: SYNC_SCHEMA,
1461
+ stableCursor: false,
1462
+ scopes: states.map(s => ({
1463
+ source: s.source, scope: s.scope, talker: s.talker,
1464
+ lastSuccessfulRun: s.lastSuccessfulRun, lastAttempt: s.lastAttempt,
1465
+ coveredFrom: s.coveredFrom, coveredTo: s.coveredTo,
1466
+ recordsRead: s.recordsRead, recordsDeduplicated: s.recordsDeduplicated,
1467
+ coverage: s.coverage, shardsRead: s.shardsRead, shardsFailed: s.shardsFailed,
1468
+ mayHaveMore: s.mayHaveMore, warnings: s.warnings,
1469
+ })),
1470
+ lastJob,
1471
+ }))
1472
+ return
1473
+ }
1474
+ if (!states.length) {
1475
+ console.log(chalk.gray('\n还没有任何同步记录。首次运行: weflow-cli sync run <会话> --since <日期>\n'))
1476
+ return
1477
+ }
1478
+ console.log(chalk.cyan(`\n同步记录 (${states.length} 个会话):\n`))
1479
+ for (const s of states) {
1480
+ const mark = s.coverage === 'complete' ? '✓' : s.coverage === 'unverified' ? '!' : '✗'
1481
+ console.log(` ${mark} ${s.scope} ${chalk.gray(`${s.coveredFrom || '—'} ~ ${s.coveredTo || '—'} 分片 ${s.shardsRead}/${s.shardsFailed} 失败`)}`)
1482
+ }
1483
+ console.log('')
1484
+ })
1485
+
1486
+ syncCmd
1487
+ .command('verify <talker>')
1488
+ .description('核对检查点与当前数据的边界')
1489
+ .option('--json', '输出 JSON 格式')
1490
+ .action(async (talker: string, opts) => {
1491
+ // Read-only: it writes nothing, so it needs no confirmation - the same
1492
+ // posture as `messages`.
1493
+ if (!configService.isConfigured()) {
1494
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'NOT_INITIALIZED', error: '未完成初始化' })); process.exit(1) }
1495
+ console.error(chalk.red('✗ 未完成初始化'))
1496
+ process.exit(1)
1497
+ }
1498
+ let state: any = null
1499
+ try { state = syncStateStore.read(SYNC_SOURCE, talker) } catch { state = null }
1500
+ if (!state) state = syncStateStore.list().find(s => s.scope === talker) ?? null
1501
+ if (!state) {
1502
+ if (opts.json) { console.log(JSON.stringify({ success: false, code: 'SYNC_STATE_NOT_FOUND', error: '没有该会话的同步记录' })); process.exit(1) }
1503
+ console.error(chalk.red('✗ 没有该会话的同步记录'))
1504
+ process.exit(1)
1505
+ }
1506
+
1507
+ const checks: Array<Record<string, unknown>> = [{ name: 'checkpoint-present', ok: true }]
1508
+
1509
+ // Re-read and compare. This is the count summary of §4.4 in the absence
1510
+ // of a local index.
1511
+ const read = await chatService.getMessagesWithShards(state.talker, 0, 0)
1512
+ const fromSeconds = state.coveredFrom ? Math.floor(Date.parse(state.coveredFrom) / 1000) : null
1513
+ const inWindow = fromSeconds === null
1514
+ ? read.messages
1515
+ : read.messages.filter(m => Number(m.createTime) >= fromSeconds)
1516
+ checks.push({ name: 'window-re-readable', ok: inWindow.length > 0, returned: inWindow.length })
1517
+
1518
+ const newest = read.messages.length
1519
+ ? Math.max(...read.messages.map(m => Number(m.createTime) || 0))
1520
+ : null
1521
+ checks.push({
1522
+ name: 'newest-message-present',
1523
+ ok: newest !== null && newest >= (state.checkpoint?.newestCreateTime ?? 0),
1524
+ recorded: state.checkpoint?.newestCreateTime ?? null,
1525
+ observed: newest,
1526
+ })
1527
+
1528
+ if (read.shards) {
1529
+ checks.push({ name: 'shards-readable', ok: read.shards.failed === 0,
1530
+ scanned: read.shards.scanned, failed: read.shards.failed })
1531
+ } else {
1532
+ // Neither a pass nor a failure: this backend simply cannot say.
1533
+ checks.push({ name: 'shards-readable', ok: true, skipped: true,
1534
+ reason: 'backend-does-not-report-shards' })
1535
+ }
1536
+
1537
+ const success = checks.every(c => c.ok || c.skipped)
1538
+ if (opts.json) {
1539
+ console.log(JSON.stringify({ success, schema: SYNC_SCHEMA, scope: state.scope, coverage: state.coverage, checks }))
1540
+ if (!success) process.exit(1)
1541
+ return
1542
+ }
1543
+ console.log(chalk.cyan(`\n核对 — ${state.scope}\n`))
1544
+ for (const check of checks) {
1545
+ const mark = check.skipped ? '–' : check.ok ? '✓' : '✗'
1546
+ console.log(` ${mark} ${check.name}${check.skipped ? chalk.gray(` (跳过: ${check.reason})`) : ''}`)
1547
+ }
1548
+ console.log('')
1549
+ if (!success) process.exit(1)
1550
+ })
1551
+
1240
1552
  // ==================== contacts ====================
1241
1553
  program
1242
1554
  .command('contacts')