weflow-cli 1.6.4 → 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 (37) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/OPERATIONS.md +35 -0
  3. package/bin/weflow-cli.ts +312 -0
  4. package/dist/bin/weflow-cli.js +352 -0
  5. package/dist/bin/weflow-cli.js.map +1 -1
  6. package/dist/src/core/ntCore.d.ts +26 -0
  7. package/dist/src/core/ntCore.d.ts.map +1 -1
  8. package/dist/src/core/ntCore.js +21 -0
  9. package/dist/src/core/ntCore.js.map +1 -1
  10. package/dist/src/services/chatService.d.ts +13 -0
  11. package/dist/src/services/chatService.d.ts.map +1 -1
  12. package/dist/src/services/chatService.js +20 -0
  13. package/dist/src/services/chatService.js.map +1 -1
  14. package/dist/src/services/messageQuery.d.ts +35 -0
  15. package/dist/src/services/messageQuery.d.ts.map +1 -1
  16. package/dist/src/services/messageQuery.js +61 -0
  17. package/dist/src/services/messageQuery.js.map +1 -1
  18. package/dist/src/services/syncService.d.ts +121 -0
  19. package/dist/src/services/syncService.d.ts.map +1 -0
  20. package/dist/src/services/syncService.js +244 -0
  21. package/dist/src/services/syncService.js.map +1 -0
  22. package/dist/src/services/syncState.d.ts +113 -0
  23. package/dist/src/services/syncState.d.ts.map +1 -0
  24. package/dist/src/services/syncState.js +214 -0
  25. package/dist/src/services/syncState.js.map +1 -0
  26. package/docs/DECISIONS.md +60 -20
  27. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +338 -0
  28. package/docs/PROJECT_STATE.md +5 -3
  29. package/docs/SYNC_CONTRACT.md +152 -0
  30. package/package.json +1 -1
  31. package/scripts/export_chat_html.py +177 -13
  32. package/scripts/nt_decrypt.py +123 -15
  33. package/src/core/ntCore.ts +42 -0
  34. package/src/services/chatService.ts +26 -0
  35. package/src/services/messageQuery.ts +97 -0
  36. package/src/services/syncService.ts +335 -0
  37. package/src/services/syncState.ts +275 -0
@@ -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';
@@ -309,6 +311,26 @@ program
309
311
  previewExposesPaths: false,
310
312
  },
311
313
  evidencePackage: { cli: 'evidence <talker> --json --non-interactive', localOnly: true },
314
+ sync: {
315
+ preview: 'sync run <talker> --dry-run --json',
316
+ execute: 'sync run <talker> --yes --json',
317
+ status: 'sync status [<talker>] --json',
318
+ verify: 'sync verify <talker> --json',
319
+ confirmationRequired: true,
320
+ readsLocalData: true,
321
+ invokesAI: false,
322
+ sideEffects: ['local-checkpoint-file'],
323
+ stateSchema: 'weflow-sync/v1',
324
+ jobSchema: 'weflow-job/v1',
325
+ stateLocation: 'local-user-config-dir',
326
+ previewResolvesTalker: false,
327
+ previewExposesLocalPaths: false,
328
+ // D-027: a stable cursor must be backed by every database backend
329
+ // before it is advertised. This exposes overlapping windows only.
330
+ stableCursor: false,
331
+ coverageValues: ['complete', 'unverified', 'partial'],
332
+ partialCompletesSuccessfully: false,
333
+ },
312
334
  aiAnalysis: {
313
335
  preview: 'evidence-review <talker> --dry-run --json',
314
336
  execute: 'evidence-review <talker> --yes --json',
@@ -1217,6 +1239,336 @@ program
1217
1239
  console.log(chalk.gray(`[${time}]`) + ` ${senderName}: ${content}`);
1218
1240
  }
1219
1241
  });
1242
+ // ==================== sync ====================
1243
+ // 增量同步检查点。契约见 docs/SYNC_CONTRACT.md, 边界见 D-029。
1244
+ // 不宣称「稳定游标」: D-027 要求它在所有后端支持前不得对外宣称,
1245
+ // 这里只做重叠时间窗 + 本地去重。
1246
+ const syncCmd = program.command('sync').description('本地消息同步检查点(重叠窗口读取与覆盖报告)');
1247
+ syncCmd
1248
+ .command('run <talker>')
1249
+ .description('执行一次同步并写入检查点')
1250
+ .option('--since <date>', '起始日期或 ISO 时间(首次运行需要)')
1251
+ .option('--full', '读取全部历史(首次运行需要;耗时可能较长)')
1252
+ .option('--overlap <seconds>', '重叠窗口秒数', '300')
1253
+ .option('-n, --limit <number>', '本次最多读取条数(0=不限)', '0')
1254
+ .option('--dry-run', '仅预览,不读取消息、不写状态')
1255
+ .option('--yes', '确认执行')
1256
+ .option('--json', '输出机器可读结果')
1257
+ .option('--non-interactive', '禁止交互确认')
1258
+ .action(async (talker, opts) => {
1259
+ const overlap = parseCliInteger(opts.overlap, 'overlap', 0, 86400, !!opts.json);
1260
+ const limit = parseCliInteger(opts.limit, 'limit', 0, 1_000_000, !!opts.json);
1261
+ let since;
1262
+ if (opts.since) {
1263
+ try {
1264
+ since = parseLocalDateOrIso(opts.since);
1265
+ }
1266
+ catch (error) {
1267
+ const message = error instanceof DateRangeError ? error.message : '日期格式无效';
1268
+ if (opts.json) {
1269
+ console.log(JSON.stringify({ success: false, code: 'INVALID_DATE', error: message }));
1270
+ process.exit(1);
1271
+ }
1272
+ console.error(chalk.red(`✗ ${message}`));
1273
+ process.exit(1);
1274
+ }
1275
+ }
1276
+ if (opts.full && since !== undefined) {
1277
+ if (opts.json) {
1278
+ console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error: '--since 与 --full 不能同时使用' }));
1279
+ process.exit(1);
1280
+ }
1281
+ console.error(chalk.red('✗ --since 与 --full 不能同时使用'));
1282
+ process.exit(1);
1283
+ }
1284
+ // Preview deliberately does not resolve the talker: that reads the
1285
+ // database, and a preview must not touch local data (D-025).
1286
+ const prior = (() => {
1287
+ try {
1288
+ return syncStateStore.read(SYNC_SOURCE, talker);
1289
+ }
1290
+ catch {
1291
+ return null;
1292
+ }
1293
+ })();
1294
+ const preview = {
1295
+ success: true,
1296
+ dryRun: true,
1297
+ action: 'sync.run',
1298
+ schema: SYNC_SCHEMA,
1299
+ source: SYNC_SOURCE,
1300
+ talkerResolved: false,
1301
+ hasCheckpoint: !!prior,
1302
+ window: {
1303
+ from: since ?? prior?.checkpoint.newestCreateTime ?? null,
1304
+ overlapSeconds: overlap,
1305
+ full: !!opts.full,
1306
+ },
1307
+ estimatedRecords: prior?.recordsRead ?? null,
1308
+ readsLocalChat: true,
1309
+ writesLocalState: true,
1310
+ stableCursor: false,
1311
+ };
1312
+ if (opts.dryRun) {
1313
+ if (opts.json)
1314
+ console.log(JSON.stringify(preview));
1315
+ else {
1316
+ console.log(chalk.cyan('\n同步预览'));
1317
+ console.log(chalk.gray(` 范围: ${opts.full ? '全部历史' : since ? new Date(since * 1000).toLocaleString() : '上次检查点'}`));
1318
+ console.log(chalk.gray(` 重叠窗口: ${overlap} 秒`));
1319
+ console.log(chalk.gray(' 将读取本地聊天记录并写入同步检查点,不调用 AI。\n'));
1320
+ }
1321
+ return;
1322
+ }
1323
+ if (!opts.yes) {
1324
+ if (opts.json) {
1325
+ console.log(JSON.stringify({ ...preview, success: false, dryRun: false, code: 'CONFIRMATION_REQUIRED' }));
1326
+ process.exit(1);
1327
+ }
1328
+ if (opts.nonInteractive) {
1329
+ console.error(chalk.red('✗ 非交互模式需要 --yes'));
1330
+ process.exit(1);
1331
+ }
1332
+ const { confirmed } = await inquirer.prompt([{
1333
+ type: 'confirm',
1334
+ name: 'confirmed',
1335
+ message: opts.full
1336
+ ? '将读取该会话的全部历史消息,可能耗时数分钟。继续吗?'
1337
+ : '将读取指定范围内的消息并写入本地同步检查点。继续吗?',
1338
+ default: false,
1339
+ }]);
1340
+ if (!confirmed) {
1341
+ console.log(chalk.gray('已取消'));
1342
+ return;
1343
+ }
1344
+ }
1345
+ if (!configService.isConfigured()) {
1346
+ if (opts.json) {
1347
+ console.log(JSON.stringify({ success: false, code: 'NOT_INITIALIZED', error: '未完成初始化' }));
1348
+ process.exit(1);
1349
+ }
1350
+ console.error(chalk.red('✗ 未完成初始化,请先运行 weflow-cli init'));
1351
+ process.exit(1);
1352
+ }
1353
+ let resolvedTalker = '';
1354
+ try {
1355
+ resolvedTalker = await resolveTalkerCore(talker, { interactive: !opts.nonInteractive });
1356
+ }
1357
+ catch {
1358
+ resolvedTalker = '';
1359
+ }
1360
+ if (!resolvedTalker) {
1361
+ if (opts.json) {
1362
+ console.log(JSON.stringify({ success: false, code: 'INVALID_ARGUMENT', error: '未找到该会话' }));
1363
+ process.exit(1);
1364
+ }
1365
+ console.error(chalk.red('✗ 未找到该会话'));
1366
+ process.exit(1);
1367
+ }
1368
+ // A display name is nicety only; the state is keyed by the resolved id.
1369
+ let displayName = resolvedTalker;
1370
+ try {
1371
+ const match = (await chatService.listSessions()).find(s => s.username === resolvedTalker);
1372
+ if (match?.displayName && match.displayName !== resolvedTalker)
1373
+ displayName = match.displayName;
1374
+ }
1375
+ catch { /* keep the id */ }
1376
+ try {
1377
+ const result = await runSync(resolvedTalker, {
1378
+ since, full: !!opts.full, overlapSeconds: overlap, limit,
1379
+ scope: displayName,
1380
+ read: (who, howMany) => chatService.getMessagesWithShards(who, howMany, 0),
1381
+ });
1382
+ if (opts.json) {
1383
+ console.log(JSON.stringify({
1384
+ success: result.success,
1385
+ ...(result.code ? { code: result.code } : {}),
1386
+ action: result.action,
1387
+ schema: SYNC_SCHEMA,
1388
+ source: SYNC_SOURCE,
1389
+ scope: result.scope,
1390
+ talker: resolvedTalker,
1391
+ window: result.window,
1392
+ recordsRead: result.recordsRead,
1393
+ recordsDeduplicated: result.recordsDeduplicated,
1394
+ recordsReturned: result.recordsReturned,
1395
+ shards: result.shards,
1396
+ coverage: result.coverage,
1397
+ mayHaveMore: result.mayHaveMore,
1398
+ partial: result.partial,
1399
+ warnings: result.warnings,
1400
+ jobId: result.jobId,
1401
+ }));
1402
+ if (!result.success)
1403
+ process.exit(1);
1404
+ return;
1405
+ }
1406
+ const mark = result.coverage === 'complete' ? '✓' : result.coverage === 'unverified' ? '!' : '✗';
1407
+ console.log(chalk.green(`\n${mark} 同步完成: ${result.recordsReturned}/${result.recordsRead} 条(去重 ${result.recordsDeduplicated})`));
1408
+ console.log(chalk.gray(` 覆盖: ${result.coverage} · 分片: ${result.shards?.opened ?? '?'} 读 / ${result.shards?.failed ?? 0} 失败`));
1409
+ for (const warning of result.warnings)
1410
+ console.log(chalk.yellow(` ⚠ ${warning}`));
1411
+ if (result.partial) {
1412
+ console.log(chalk.yellow(' 本次为部分完成,未推进成功时间;用 sync status 查看详情。'));
1413
+ }
1414
+ console.log('');
1415
+ if (!result.success)
1416
+ process.exit(1);
1417
+ }
1418
+ catch (error) {
1419
+ if (error instanceof SyncRangeRequiredError) {
1420
+ if (opts.json) {
1421
+ console.log(JSON.stringify({ success: false, code: 'SYNC_RANGE_REQUIRED', error: error.message }));
1422
+ process.exit(1);
1423
+ }
1424
+ console.error(chalk.red(`✗ ${error.message}`));
1425
+ process.exit(1);
1426
+ }
1427
+ if (opts.json) {
1428
+ console.log(JSON.stringify({ success: false, code: 'SYNC_FAILED', error: '同步失败' }));
1429
+ process.exit(1);
1430
+ }
1431
+ console.error(chalk.red(`✗ 同步失败: ${error?.message || error}`));
1432
+ process.exit(1);
1433
+ }
1434
+ });
1435
+ syncCmd
1436
+ .command('status [talker]')
1437
+ .description('查看同步检查点与最近任务')
1438
+ .option('--json', '输出 JSON 格式')
1439
+ .action(async (talker, opts) => {
1440
+ // No database access: this has to work while the database is locked.
1441
+ let states = [];
1442
+ try {
1443
+ states = syncStateStore.list();
1444
+ }
1445
+ catch {
1446
+ states = [];
1447
+ }
1448
+ const lastJob = syncStateStore.recentJobs(1)[0] ?? null;
1449
+ if (talker) {
1450
+ const match = states.find(s => s.talker === talker || s.scope === talker);
1451
+ if (!match) {
1452
+ if (opts.json) {
1453
+ console.log(JSON.stringify({ success: false, code: 'SYNC_STATE_NOT_FOUND', error: '没有该会话的同步记录' }));
1454
+ process.exit(1);
1455
+ }
1456
+ console.error(chalk.red('✗ 没有该会话的同步记录'));
1457
+ process.exit(1);
1458
+ }
1459
+ if (opts.json) {
1460
+ console.log(JSON.stringify({ success: true, schema: SYNC_SCHEMA, state: match, lastJob }));
1461
+ return;
1462
+ }
1463
+ console.log(chalk.cyan(`\n同步记录 — ${match.scope}`));
1464
+ console.log(chalk.gray(` 覆盖: ${match.coveredFrom || '—'} ~ ${match.coveredTo || '—'}`));
1465
+ console.log(chalk.gray(` 可信度: ${match.coverage} · 分片 ${match.shardsRead} 读 / ${match.shardsFailed} 失败`));
1466
+ console.log(chalk.gray(` 上次成功: ${match.lastSuccessfulRun || '—'} · 上次尝试: ${match.lastAttempt || '—'}\n`));
1467
+ return;
1468
+ }
1469
+ if (opts.json) {
1470
+ console.log(JSON.stringify({
1471
+ success: true,
1472
+ schema: SYNC_SCHEMA,
1473
+ stableCursor: false,
1474
+ scopes: states.map(s => ({
1475
+ source: s.source, scope: s.scope, talker: s.talker,
1476
+ lastSuccessfulRun: s.lastSuccessfulRun, lastAttempt: s.lastAttempt,
1477
+ coveredFrom: s.coveredFrom, coveredTo: s.coveredTo,
1478
+ recordsRead: s.recordsRead, recordsDeduplicated: s.recordsDeduplicated,
1479
+ coverage: s.coverage, shardsRead: s.shardsRead, shardsFailed: s.shardsFailed,
1480
+ mayHaveMore: s.mayHaveMore, warnings: s.warnings,
1481
+ })),
1482
+ lastJob,
1483
+ }));
1484
+ return;
1485
+ }
1486
+ if (!states.length) {
1487
+ console.log(chalk.gray('\n还没有任何同步记录。首次运行: weflow-cli sync run <会话> --since <日期>\n'));
1488
+ return;
1489
+ }
1490
+ console.log(chalk.cyan(`\n同步记录 (${states.length} 个会话):\n`));
1491
+ for (const s of states) {
1492
+ const mark = s.coverage === 'complete' ? '✓' : s.coverage === 'unverified' ? '!' : '✗';
1493
+ console.log(` ${mark} ${s.scope} ${chalk.gray(`${s.coveredFrom || '—'} ~ ${s.coveredTo || '—'} 分片 ${s.shardsRead}/${s.shardsFailed} 失败`)}`);
1494
+ }
1495
+ console.log('');
1496
+ });
1497
+ syncCmd
1498
+ .command('verify <talker>')
1499
+ .description('核对检查点与当前数据的边界')
1500
+ .option('--json', '输出 JSON 格式')
1501
+ .action(async (talker, opts) => {
1502
+ // Read-only: it writes nothing, so it needs no confirmation - the same
1503
+ // posture as `messages`.
1504
+ if (!configService.isConfigured()) {
1505
+ if (opts.json) {
1506
+ console.log(JSON.stringify({ success: false, code: 'NOT_INITIALIZED', error: '未完成初始化' }));
1507
+ process.exit(1);
1508
+ }
1509
+ console.error(chalk.red('✗ 未完成初始化'));
1510
+ process.exit(1);
1511
+ }
1512
+ let state = null;
1513
+ try {
1514
+ state = syncStateStore.read(SYNC_SOURCE, talker);
1515
+ }
1516
+ catch {
1517
+ state = null;
1518
+ }
1519
+ if (!state)
1520
+ state = syncStateStore.list().find(s => s.scope === talker) ?? null;
1521
+ if (!state) {
1522
+ if (opts.json) {
1523
+ console.log(JSON.stringify({ success: false, code: 'SYNC_STATE_NOT_FOUND', error: '没有该会话的同步记录' }));
1524
+ process.exit(1);
1525
+ }
1526
+ console.error(chalk.red('✗ 没有该会话的同步记录'));
1527
+ process.exit(1);
1528
+ }
1529
+ const checks = [{ name: 'checkpoint-present', ok: true }];
1530
+ // Re-read and compare. This is the count summary of §4.4 in the absence
1531
+ // of a local index.
1532
+ const read = await chatService.getMessagesWithShards(state.talker, 0, 0);
1533
+ const fromSeconds = state.coveredFrom ? Math.floor(Date.parse(state.coveredFrom) / 1000) : null;
1534
+ const inWindow = fromSeconds === null
1535
+ ? read.messages
1536
+ : read.messages.filter(m => Number(m.createTime) >= fromSeconds);
1537
+ checks.push({ name: 'window-re-readable', ok: inWindow.length > 0, returned: inWindow.length });
1538
+ const newest = read.messages.length
1539
+ ? Math.max(...read.messages.map(m => Number(m.createTime) || 0))
1540
+ : null;
1541
+ checks.push({
1542
+ name: 'newest-message-present',
1543
+ ok: newest !== null && newest >= (state.checkpoint?.newestCreateTime ?? 0),
1544
+ recorded: state.checkpoint?.newestCreateTime ?? null,
1545
+ observed: newest,
1546
+ });
1547
+ if (read.shards) {
1548
+ checks.push({ name: 'shards-readable', ok: read.shards.failed === 0,
1549
+ scanned: read.shards.scanned, failed: read.shards.failed });
1550
+ }
1551
+ else {
1552
+ // Neither a pass nor a failure: this backend simply cannot say.
1553
+ checks.push({ name: 'shards-readable', ok: true, skipped: true,
1554
+ reason: 'backend-does-not-report-shards' });
1555
+ }
1556
+ const success = checks.every(c => c.ok || c.skipped);
1557
+ if (opts.json) {
1558
+ console.log(JSON.stringify({ success, schema: SYNC_SCHEMA, scope: state.scope, coverage: state.coverage, checks }));
1559
+ if (!success)
1560
+ process.exit(1);
1561
+ return;
1562
+ }
1563
+ console.log(chalk.cyan(`\n核对 — ${state.scope}\n`));
1564
+ for (const check of checks) {
1565
+ const mark = check.skipped ? '–' : check.ok ? '✓' : '✗';
1566
+ console.log(` ${mark} ${check.name}${check.skipped ? chalk.gray(` (跳过: ${check.reason})`) : ''}`);
1567
+ }
1568
+ console.log('');
1569
+ if (!success)
1570
+ process.exit(1);
1571
+ });
1220
1572
  // ==================== contacts ====================
1221
1573
  program
1222
1574
  .command('contacts')