weflow-cli 1.11.1 → 1.13.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 (68) hide show
  1. package/CHANGELOG.md +378 -0
  2. package/OPERATIONS.md +359 -11
  3. package/README.md +4 -0
  4. package/bin/weflow-cli.ts +289 -37
  5. package/dist/bin/weflow-cli.js +300 -35
  6. package/dist/bin/weflow-cli.js.map +1 -1
  7. package/dist/src/core/dbPathService.d.ts.map +1 -1
  8. package/dist/src/core/dbPathService.js +5 -0
  9. package/dist/src/core/dbPathService.js.map +1 -1
  10. package/dist/src/core/ntCore.d.ts +12 -1
  11. package/dist/src/core/ntCore.d.ts.map +1 -1
  12. package/dist/src/core/ntCore.js +23 -3
  13. package/dist/src/core/ntCore.js.map +1 -1
  14. package/dist/src/core/sqlcipherCore.d.ts +8 -0
  15. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  16. package/dist/src/core/sqlcipherCore.js +46 -11
  17. package/dist/src/core/sqlcipherCore.js.map +1 -1
  18. package/dist/src/services/assistantEval.d.ts.map +1 -1
  19. package/dist/src/services/assistantEval.js +3 -1
  20. package/dist/src/services/assistantEval.js.map +1 -1
  21. package/dist/src/services/assistantTools.d.ts.map +1 -1
  22. package/dist/src/services/assistantTools.js +25 -7
  23. package/dist/src/services/assistantTools.js.map +1 -1
  24. package/dist/src/services/configService.d.ts +3 -0
  25. package/dist/src/services/configService.d.ts.map +1 -1
  26. package/dist/src/services/configService.js +6 -3
  27. package/dist/src/services/configService.js.map +1 -1
  28. package/dist/src/services/diagnosticReport.d.ts +80 -0
  29. package/dist/src/services/diagnosticReport.d.ts.map +1 -0
  30. package/dist/src/services/diagnosticReport.js +191 -0
  31. package/dist/src/services/diagnosticReport.js.map +1 -0
  32. package/dist/src/services/exportService.d.ts +7 -0
  33. package/dist/src/services/exportService.d.ts.map +1 -1
  34. package/dist/src/services/exportService.js +1 -1
  35. package/dist/src/services/exportService.js.map +1 -1
  36. package/dist/src/services/wechatClient.d.ts +9 -3
  37. package/dist/src/services/wechatClient.d.ts.map +1 -1
  38. package/dist/src/services/wechatClient.js +55 -22
  39. package/dist/src/services/wechatClient.js.map +1 -1
  40. package/docs/DECISIONS.md +662 -1
  41. package/docs/MCP.md +1 -1
  42. package/docs/NEXT_ACTIONS.md +236 -0
  43. package/docs/POSITIONING.md +68 -0
  44. package/docs/PROJECT_STATE.md +119 -17
  45. package/docs/RELEASING.md +14 -0
  46. package/package.json +88 -86
  47. package/scripts/_utils.py +120 -4
  48. package/scripts/backfill_articles.py +64 -14
  49. package/scripts/biz_daily.py +118 -11
  50. package/scripts/daily_task.ps1 +132 -0
  51. package/scripts/export_chat_html.py +79 -31
  52. package/scripts/install_tasks.cmd +32 -0
  53. package/scripts/install_tasks.ps1 +91 -0
  54. package/scripts/jev_client.py +335 -3
  55. package/scripts/nt_decrypt.py +79 -14
  56. package/scripts/pipeline.py +21 -0
  57. package/scripts/recall_eval.py +247 -0
  58. package/scripts/repro_customer_init.cmd +86 -0
  59. package/scripts/semantic_search.py +926 -51
  60. package/src/core/dbPathService.ts +5 -0
  61. package/src/core/ntCore.ts +34 -3
  62. package/src/core/sqlcipherCore.ts +47 -11
  63. package/src/services/assistantEval.ts +3 -1
  64. package/src/services/assistantTools.ts +2471 -2452
  65. package/src/services/configService.ts +12 -3
  66. package/src/services/diagnosticReport.ts +237 -0
  67. package/src/services/exportService.ts +14 -2
  68. package/src/services/wechatClient.ts +418 -380
@@ -111,10 +111,13 @@ _FILE_ASSET_SUBDIR = 'files'
111
111
  # 文档那半的计数(图片那半的计数在写盘时现算)。放在模块级是为了不改 `write_assets` 的返回形状 ——
112
112
  # 它已经有两个调用方(markdown 导出与 `--media`),与 COVER_STATE/VOICE_STATE 是同一套写法。
113
113
  #
114
- # `exported` 与 `written` 是两件事:`written` 只数**这次新拷**的,`exported` 数"这条消息的文档确实在
115
- # assets 里"。分开是因为第二次跑同一个目录时新拷贝是 0 —— 只报 `written` 会打印成"0 document(s)",
116
- # 而实际上 67 份都在,那是给自己制造假象(2026-10-13 真的这么打印过一次)。
117
- FILE_ASSET_STATE = {'exported': 0, 'written': 0, 'unavailable': 0}
114
+ # **三个词各管一件事,别混**(媒体状态统一后,报告/清单/JSON 三处都按这套说法):
115
+ # `written` = 现在躺在导出目录里的份数("可用数",同一目录跑第二遍时它不为 0)
116
+ # `copied` = **这一轮**新拷进去的份数(第二遍跑就是 0)
117
+ # `unavailable` = 没能落盘的份数(本地没有那份文件 / 太大 / 拷贝失败)
118
+ # 分开是因为只报 `copied` 会把第二遍打印成"0 document(s)",而实际上 67 份都在 —— 那是给自己制造
119
+ # 假象(2026-10-13 真这么打印过一次)。
120
+ FILE_ASSET_STATE = {'written': 0, 'copied': 0, 'unavailable': 0}
118
121
 
119
122
  # Off by default: full-resolution originals are 5-24MB / 3000-5700px each, and
120
123
  # downscaling ~500 of them costs minutes (PIL decode dominates). The cache's
@@ -242,17 +245,28 @@ def fetch_messages_from_shards(db_path, key_hex, salt_hex, talker, date='', pass
242
245
  # 原先的 `if not shards: shards = [db_path]` 是给"可能返回空"的那一份实现擦屁股的。
243
246
  shards = discover_message_shards(db_path)
244
247
  messages = []
248
+ failed = []
245
249
  for shard in shards:
246
250
  shard_conn = None
247
251
  try:
248
252
  shard_key, shard_salt = derive_database_key(shard, key_hex, salt_hex, passphrase)
249
253
  shard_conn, _ = connect(shard, shard_key, shard_salt)
250
254
  messages.extend(fetch_messages(shard_conn, talker, date))
251
- except Exception:
255
+ except Exception as exc:
256
+ # **少读一个分片 = 导出里少一段聊天,而用户看到的是"导出成功"**。
257
+ # 必须留下分片名与原因:只有知道是哪个分片,才分得清"这个分片本来就空"、
258
+ # "密钥/权限不对"还是"文件损坏"。原先直接 continue,等于静默丢数据。
259
+ failed.append((os.path.basename(shard), '%s: %s' % (type(exc).__name__, exc)))
252
260
  continue
253
261
  finally:
254
262
  if shard_conn is not None:
255
263
  shard_conn.close()
264
+ if failed:
265
+ print('[WARN] %d/%d 个消息分片读不了,导出内容会缺这几段(不是"没有消息"):'
266
+ % (len(failed), len(shards)), file=sys.stderr)
267
+ for name, why in failed:
268
+ print(' - %s (%s)' % (name, why), file=sys.stderr)
269
+ sys.stderr.flush()
256
270
  messages.sort(key=lambda row: (int(row[5] or 0), int(row[0] or 0)))
257
271
  return messages
258
272
 
@@ -2145,10 +2159,15 @@ def build_media_record(local_type, image_b64, image_map, local_id, create_time,
2145
2159
  if file_info is not None:
2146
2160
  # 文档本体(appmsg type 6)走的是另一条路:没有缩略图、也不在图片索引里,能不能导出只看
2147
2161
  # 本地 `msg/file/` 有没有那份文件。不并入图片那条链,否则报告会把"文件没下载"写成"没有可靠标识"。
2162
+ #
2163
+ # 状态词是 `found` 而**不是** `exported`:这份报告是在**写 assets 之前**生成的,它答的是
2164
+ # "能不能在源数据里定位到"(`found` / `missing`),答不了"有没有写进导出目录"。
2165
+ # 后者由清单的 `documents.written` 回答(`write_assets` 写的)。两件事用两个词,
2166
+ # 免得"这份文件已导出"这种话在拷贝失败时变成假的。
2148
2167
  record['kind'] = 'file'
2149
2168
  record['bytes'] = file_info.get('bytes') or None
2150
2169
  if file_hit:
2151
- record['status'] = 'exported'
2170
+ record['status'] = 'found'
2152
2171
  record['mediaKey'] = 'file'
2153
2172
  else:
2154
2173
  record['status'] = 'missing'
@@ -2609,15 +2628,15 @@ def write_assets(out_dir, file_prefix, formatted, assets_dir=''):
2609
2628
  'bytes': size,
2610
2629
  }
2611
2630
 
2612
- # `exported` 数的是**合并后清单里真实存在的文档条目**(第二遍跑同一目录时它不为 0,而新拷贝是 0);
2631
+ # `written` 数的是**合并后清单里真实存在的文档条目**(第二遍跑同一目录时它不为 0,而新拷贝是 0);
2613
2632
  # `copied` 才是"这次新拷了几个"。两个都写出来,免得读清单的人把后者当前者。
2614
- file_exported = sum(1 for record in manifest.values() if record.get('kind') == 'file')
2615
- FILE_ASSET_STATE['exported'] = file_exported
2616
- FILE_ASSET_STATE['written'] = file_written
2633
+ file_written_total = sum(1 for record in manifest.values() if record.get('kind') == 'file')
2634
+ FILE_ASSET_STATE['written'] = file_written_total
2635
+ FILE_ASSET_STATE['copied'] = file_written
2617
2636
  FILE_ASSET_STATE['unavailable'] = file_unavailable
2618
2637
  with open(manifest_path, 'w', encoding='utf-8') as fh:
2619
2638
  json.dump({'schema': 'weflow-media-manifest/v2', 'messages': manifest,
2620
- 'documents': {'exported': file_exported, 'copied': file_written,
2639
+ 'documents': {'written': file_written_total, 'copied': file_written,
2621
2640
  'unavailable': file_unavailable}},
2622
2641
  fh, ensure_ascii=False, indent=2)
2623
2642
  return os.path.basename(os.path.normpath(assets)), manifest, written + file_written
@@ -2641,6 +2660,35 @@ def _markdown_link_target(rel):
2641
2660
  return ''.join(out)
2642
2661
 
2643
2662
 
2663
+ def media_json_summary(media_items, media_report_path=None, manifest=None, asset_new=0, assets_dir=''):
2664
+ """命令行最后那行 JSON 里的媒体摘要 —— **三种格式共用同一份词汇与同一份计数逻辑**。
2665
+
2666
+ 每个词只回答一个问题,互不覆盖:
2667
+
2668
+ * `reportStatus`:按**能不能在源数据里定位到**分类(图片是 `embedded`/`remote-fetched`/`missing`,
2669
+ 文档是 `found`/`missing`)。这是"这份媒体在源数据里有没有"。
2670
+ * `assets.written / copied`:**有没有写进导出目录**(written 是现在那里的总数,copied 是这一轮新写的)。
2671
+ 与上一条是两回事:源数据里找得到的,可能因为太大或拷贝失败而没写进去。
2672
+ * `documents.unavailable`:没能落盘的份数(没找到 / 太大 / 拷贝失败)。
2673
+
2674
+ 三处原本各拼一份 JSON(还各写了不同的键名),同一个事实就有三种说法;这里收成一份,
2675
+ 顺带把**报告文件名**一起给出来(此前只有 HTML 那条给)。不含路径、正文或密钥。
2676
+ """
2677
+ counts = {}
2678
+ for item in media_items or []:
2679
+ status = item.get('status') or 'unknown'
2680
+ counts[status] = counts.get(status, 0) + 1
2681
+ return {
2682
+ 'report': os.path.basename(media_report_path) if media_report_path else None,
2683
+ 'reportItems': len(media_items or []),
2684
+ 'reportStatus': counts,
2685
+ 'assets': {'items': len(manifest or {}), 'new': asset_new, 'dir': assets_dir or None},
2686
+ 'documents': {'written': FILE_ASSET_STATE['written'],
2687
+ 'copied': FILE_ASSET_STATE['copied'],
2688
+ 'unavailable': FILE_ASSET_STATE['unavailable']},
2689
+ }
2690
+
2691
+
2644
2692
  def write_markdown(out_dir, file_prefix, formatted, assets_dir=''):
2645
2693
  """markdown 正文 + 媒体落盘(落盘那半在 `write_assets`,两者共用同一份清单)。"""
2646
2694
  assets_name, manifest, written = write_assets(out_dir, file_prefix, formatted, assets_dir)
@@ -2917,35 +2965,39 @@ def main():
2917
2965
  if args.assets_only:
2918
2966
  # 只抽媒体:其它格式的 `--media` 会先跑这一步,再各自消费清单。
2919
2967
  assets_name, manifest, asset_new = write_assets(args.out, file_prefix, formatted, args.assets_dir)
2920
- print(f"Assets: {len(manifest)} media item(s), {FILE_ASSET_STATE['exported']} document(s) "
2921
- f"({FILE_ASSET_STATE['unavailable']} unavailable, {FILE_ASSET_STATE['written']} new) "
2968
+ # 这一步也写媒体报告:三种形态的调用方应当拿到同一份东西(状态计数 + 报告文件名),
2969
+ # 少写这一份的话,`--media` 的调用者只能看到文档计数、看不到"哪些媒体没嵌上、为什么"。
2970
+ media_report_path = None
2971
+ if media_items:
2972
+ media_report_path = os.path.join(args.out, f'{file_prefix}_media.json')
2973
+ with open(media_report_path, 'w', encoding='utf-8') as handle:
2974
+ json.dump(build_media_report(media_items, display_name, args.talker, len(formatted)),
2975
+ handle, ensure_ascii=False, indent=2)
2976
+ print(f"Assets: {len(manifest)} media item(s), {FILE_ASSET_STATE['written']} document(s) "
2977
+ f"({FILE_ASSET_STATE['unavailable']} unavailable, {FILE_ASSET_STATE['copied']} new) "
2922
2978
  f"({asset_new} new) -> {assets_name}")
2923
2979
  print(json.dumps({"success": True, "total": len(formatted),
2924
2980
  "files": [os.path.join(args.out, assets_name)], "format": "assets",
2925
- "assets": len(manifest), "assetsNew": asset_new,
2926
- "documents": FILE_ASSET_STATE['exported'],
2927
- "documentsNew": FILE_ASSET_STATE['written'],
2928
- "documentsUnavailable": FILE_ASSET_STATE['unavailable'],
2929
- "assetsDir": assets_name}, ensure_ascii=False))
2981
+ "media": media_json_summary(media_items, media_report_path, manifest,
2982
+ asset_new, assets_name)},
2983
+ ensure_ascii=False))
2930
2984
  conn.close()
2931
2985
  return
2932
2986
  if args.format == 'md':
2933
2987
  md_path, asset_total, asset_new, assets_name = write_markdown(args.out, file_prefix, formatted, args.assets_dir)
2988
+ media_report_path = None # 没有媒体时下面那段不跑,但 JSON 里仍要如实给出 None
2934
2989
  if media_items:
2935
2990
  media_report_path = os.path.join(args.out, f'{file_prefix}_media.json')
2936
2991
  with open(media_report_path, 'w', encoding='utf-8') as handle:
2937
2992
  json.dump(build_media_report(media_items, display_name, args.talker, len(formatted)),
2938
2993
  handle, ensure_ascii=False, indent=2)
2939
2994
  print(f"Markdown: {len(formatted)} message(s), {asset_total} media item(s), "
2940
- f"{FILE_ASSET_STATE['exported']} document(s) ({FILE_ASSET_STATE['unavailable']} unavailable, "
2941
- f"{FILE_ASSET_STATE['written']} new) ({asset_new} new) -> {os.path.basename(md_path)}")
2995
+ f"{FILE_ASSET_STATE['written']} document(s) ({FILE_ASSET_STATE['unavailable']} unavailable, "
2996
+ f"{FILE_ASSET_STATE['copied']} new) ({asset_new} new) -> {os.path.basename(md_path)}")
2942
2997
  print(json.dumps({"success": True, "total": len(formatted), "parts": 1,
2943
2998
  "files": [md_path], "format": "md",
2944
- "assets": asset_total, "assetsNew": asset_new,
2945
- "documents": FILE_ASSET_STATE['exported'],
2946
- "documentsNew": FILE_ASSET_STATE['written'],
2947
- "documentsUnavailable": FILE_ASSET_STATE['unavailable'],
2948
- "assetsDir": assets_name},
2999
+ "media": media_json_summary(media_items, media_report_path, manifest=None,
3000
+ asset_new=asset_new, assets_dir=assets_name)},
2949
3001
  ensure_ascii=False))
2950
3002
  conn.close()
2951
3003
  return
@@ -2995,17 +3047,13 @@ def main():
2995
3047
  # Print JSON summary for CLI integration.
2996
3048
  # The four original keys are untouched; `media` is purely additive and the
2997
3049
  # one existing consumer (src/services/exportService.ts) ignores it.
3050
+ # 三种格式(html/md/assets)用**同一个** media_json_summary,词汇与计数不再各说各话。
2998
3051
  print(json.dumps({
2999
3052
  "success": True,
3000
3053
  "total": total,
3001
3054
  "parts": len(html_files),
3002
3055
  "files": html_files,
3003
- "media": ({
3004
- "schema": "weflow-media-report/v1",
3005
- "records": len(media_items),
3006
- "status": media_report['counts'],
3007
- "report": os.path.basename(media_report_path) if media_report_path else None,
3008
- } if media_report else None),
3056
+ "media": media_json_summary(media_items, media_report_path),
3009
3057
  }))
3010
3058
 
3011
3059
 
@@ -0,0 +1,32 @@
1
+ @echo off
2
+ chcp 65001 >nul
3
+ setlocal
4
+ rem One-click installer for the two daily-report scheduled tasks.
5
+ rem
6
+ rem Why this .cmd exists: agent sandboxes and restricted shells refuse Task Scheduler writes
7
+ rem as a class (schtasks /create, the Schedule.Service COM API, and even the in-memory
8
+ rem New-ScheduledTask* builders all return Access is denied while reads keep working).
9
+ rem A process started from your own desktop is outside that boundary, so this asks for
10
+ rem elevation itself and runs install_tasks.ps1 there.
11
+
12
+ set "PS1=%~dp0install_tasks.ps1"
13
+ if not exist "%PS1%" (
14
+ echo [ERROR] install_tasks.ps1 not found next to this file.
15
+ pause
16
+ exit /b 1
17
+ )
18
+
19
+ echo Requesting administrator privileges...
20
+ powershell -NoProfile -ExecutionPolicy Bypass -Command "try { Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-NoExit','-File','\"%PS1%\"') -Verb RunAs -ErrorAction Stop; exit 0 } catch { Write-Host ('Could not elevate: ' + $_.Exception.Message) -ForegroundColor Red; exit 1 }"
21
+ if errorlevel 1 (
22
+ echo.
23
+ echo If the elevation prompt was cancelled: right-click this file and pick "Run as administrator".
24
+ pause
25
+ exit /b 1
26
+ )
27
+
28
+ echo.
29
+ echo A new window opened and is registering the tasks. It stays open so you can read the result.
30
+ echo To remove them later: install_tasks.ps1 -Uninstall
31
+ echo.
32
+ pause
@@ -0,0 +1,91 @@
1
+ # Register / update the two scheduled tasks for the official-account daily report.
2
+ # Idempotent: running it again overwrites the definitions with the same values.
3
+ #
4
+ # Why this cannot always be done for you: writing to Task Scheduler is refused in a
5
+ # restricted shell / agent sandbox, and refused as a *class* - measured on 2026-10-19 with
6
+ # an elevated Administrator token, all three write paths said "Access is denied" while
7
+ # reads kept working:
8
+ # schtasks /create ... -> Access is denied
9
+ # (New-Object -ComObject Schedule.Service) -> RegisterTaskDefinition 0x80070005
10
+ # New-ScheduledTaskAction / -SettingsSet ... -> 拒绝访问 (even the in-memory builders)
11
+ # So "I can see the tasks but cannot create them" is not an illusion, and it is not fixed
12
+ # by "run as administrator". The one reliable route is a terminal the sandbox does not
13
+ # supervise: double-click scripts\install_tasks.cmd (it asks for elevation itself), or run
14
+ # this file from your own PowerShell window.
15
+ #
16
+ # Usage:
17
+ # powershell -NoProfile -ExecutionPolicy Bypass -File <repo>\scripts\install_tasks.ps1
18
+ # powershell -NoProfile -ExecutionPolicy Bypass -File <repo>\scripts\install_tasks.ps1 -Uninstall
19
+ #
20
+ # The two tasks:
21
+ # WeFlow Daily 00:05 + at logon -> YESTERDAY (the last complete day)
22
+ # WeFlow Daily Noon 13:00 -> TODAY so far (half a day of pushes)
23
+ #
24
+ # Why 00:05 the next day instead of 24:00 tonight: it is the same instant, but "yesterday"
25
+ # then lines up with the date directory the run writes into, and it does not collide with
26
+ # other jobs on the hour. Official accounts keep pushing until 24:00, so this is the only
27
+ # moment that has the complete day.
28
+ #
29
+ # This file is intentionally ASCII-only: Windows PowerShell 5.1 reads a BOM-less file as
30
+ # the ANSI codepage, so non-ASCII text here turns into mojibake *and* a parse error.
31
+ param(
32
+ [switch]$Uninstall
33
+ )
34
+
35
+ $ErrorActionPreference = 'Stop'
36
+
37
+ $repo = Split-Path -Parent $PSScriptRoot
38
+ $script = Join-Path $repo 'scripts\daily_task.ps1'
39
+ if (-not (Test-Path $script)) { throw "not found: $script (this script belongs in the repo's scripts\ folder)" }
40
+
41
+ $isAdmin = (New-Object Security.Principal.WindowsPrincipal(
42
+ [Security.Principal.WindowsIdentity]::GetCurrent())
43
+ ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
44
+ if (-not $isAdmin) {
45
+ Write-Host 'Need elevation: reopen PowerShell as administrator, then run this again.' -ForegroundColor Yellow
46
+ exit 1
47
+ }
48
+
49
+ if ($Uninstall) {
50
+ foreach ($name in @('WeFlow Daily Noon', 'WeFlow Daily')) {
51
+ if (Get-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue) {
52
+ Unregister-ScheduledTask -TaskName $name -Confirm:$false
53
+ Write-Host "removed: $name"
54
+ }
55
+ }
56
+ exit 0
57
+ }
58
+
59
+ $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew
60
+ $psExe = 'powershell.exe'
61
+ $common = '-NoProfile -ExecutionPolicy Bypass -File ' + $script
62
+
63
+ # End of day: yesterday. The at-logon trigger is the catch-up: this machine slept for
64
+ # 37 hours and StartWhenAvailable did NOT actually start the missed run (NumberOfMissedRuns=2,
65
+ # no run record), so "it will catch up by itself" is not something to rely on.
66
+ Register-ScheduledTask -TaskName 'WeFlow Daily' `
67
+ -Action (New-ScheduledTaskAction -Execute $psExe -Argument $common) `
68
+ -Trigger @((New-ScheduledTaskTrigger -Daily -At '00:05'),
69
+ (New-ScheduledTaskTrigger -AtLogOn)) `
70
+ -Settings $settings `
71
+ -Description 'WeChat daily report for yesterday (00:05 + at logon, idempotent)' -Force | Out-Null
72
+
73
+ # Noon: today so far. Deliberately NO at-logon trigger: a morning logon would produce a
74
+ # very thin "noon" report and write the .noon_done marker, which would then make the real
75
+ # 13:00 run skip itself (the guard keys on that marker).
76
+ Register-ScheduledTask -TaskName 'WeFlow Daily Noon' `
77
+ -Action (New-ScheduledTaskAction -Execute $psExe -Argument ($common + ' noon')) `
78
+ -Trigger (New-ScheduledTaskTrigger -Daily -At '13:00') `
79
+ -Settings $settings `
80
+ -Description 'WeChat daily report for today so far (13:00)' -Force | Out-Null
81
+
82
+ foreach ($name in @('WeFlow Daily', 'WeFlow Daily Noon')) {
83
+ $t = Get-ScheduledTask -TaskName $name
84
+ $i = Get-ScheduledTaskInfo -TaskName $name
85
+ Write-Host ("{0,-20} {1,-8} next {2}" -f $t.TaskName, $t.State, $i.NextRunTime) -ForegroundColor Green
86
+ }
87
+ Write-Host ''
88
+ Write-Host 'Run one now instead of waiting:'
89
+ Write-Host ' Start-ScheduledTask -TaskName "WeFlow Daily Noon" # today so far'
90
+ Write-Host ' Start-ScheduledTask -TaskName "WeFlow Daily" # yesterday, complete'
91
+ Write-Host 'Logs: <repo>\logs\daily-<date>[-noon].log'