openclaw-diag-cli 1.10.4 → 1.12.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,100 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.12.1 — --help / list / examples output switched to English (2026-06-18)
4
+
5
+ `v1.12.0` made the help comprehensive but in Chinese. This patch translates
6
+ every custom help string surfaced by `--help` (top-level + each subcommand +
7
+ each collector/inspector), `openclaw-diag list`, and `openclaw-diag examples`
8
+ to clear English. argparse framework strings (`usage:`, `options:`, `-h, --help`)
9
+ were already English and are unchanged.
10
+
11
+ ### Changes
12
+
13
+ Only `ocdiag/main.py` + `tests/test_cli_help.py` are touched. Collectors /
14
+ inspectors business logic, collector `title` attributes, and report rendering
15
+ are untouched.
16
+
17
+ 1. `_COMMAND_DESC` values translated to English; `_desc()` unchanged.
18
+ 2. `_common_arguments` group renamed to `"global options"`; every flag
19
+ (`--config` / `--log-dir` / `--sessions-base` / `--openclaw-home` /
20
+ `--format` / `--json` / `--no-color` / `--unmask`) carries an English help
21
+ string. The `channel options` group + `--account` help are also English.
22
+ 3. `trace` / `extract` / `panorama` parsers: per-command group headers
23
+ (`trace options`, `extract options`, `panorama options`), every flag's
24
+ help text, and the `Examples:` epilog blocks are all English. Description
25
+ line uses the English `_desc()` only — the Chinese collector `title` no
26
+ longer leaks into help.
27
+ 4. `_print_help()` rewritten in English (intro / Usage / Health checks / Scan
28
+ diagnostics / Object diagnostics / Helper commands / Global options /
29
+ Exit codes / More).
30
+ 5. `cmd_list` pretty + json outputs are fully English. The json `label` field
31
+ now mirrors the English description (no Chinese title leak).
32
+ 6. `cmd_examples()` translated; example commands themselves are unchanged.
33
+ 7. Per-collector `<id> --help` description switched from
34
+ `f"{coll.title} ({coll.id}) — {desc}"` to `f"{desc} ({coll.id})"` so the
35
+ Chinese title is never printed.
36
+ 8. Error messages: `"Error: 未知 ..."` -> `"Error: unknown ..."` and the
37
+ "run `openclaw-diag list`..." hint translated.
38
+
39
+ ### Tests
40
+
41
+ `tests/test_cli_help.py`: every assertion that pinned a Chinese substring is
42
+ swapped to its English equivalent. Test intent is preserved 1:1.
43
+
44
+ ## v1.12.0 — --help 全面改造:命令/参数清晰说明 + 分组 + 退出码 (2026-06-18)
45
+
46
+ 让 `--help`(顶层 + 每个子命令 + 每个 collector/inspector)对**用户和 agent 都
47
+ 清晰、明确、直接**。每个命令、每个参数都有一句话说明,零猜测。
48
+
49
+ ### 改动
50
+
51
+ 唯一业务改动文件:`ocdiag/main.py`(外加 `tests/test_cli_help.py` 新增测试)。
52
+ 不动 collectors/inspectors 业务逻辑、不动默认路径/参数 default 值。
53
+
54
+ 1. 新增集中式命令描述表 `_COMMAND_DESC`(id → 一句话中文)+ helper `_desc(mid)`,
55
+ 供 `list`、`<id> --help` 的 description、顶层 help 复用,未来新增 collector
56
+ 缺失时回退空串、不报错。
57
+ 2. `_common_arguments` 内全局参数放进 `argument group "全局选项 (global options)"`,
58
+ 每个 flag 都补了清晰中文 help(`--config` / `--log-dir` / `--sessions-base` /
59
+ `--openclaw-home` / `--format` / `--json` / `--no-color` / `--unmask`),路径
60
+ 类参数加 `metavar=PATH` 让 usage 行更整齐。`--account` 同样进 `"channel 选项"`
61
+ group。
62
+ 3. `trace --help` 之前 `--no-trajectory` / `--no-log` / `--show-tool-metas` /
63
+ `--show-plugin-snapshot` / `--mask` 都是裸 flag,本次补齐每个的中文说明,
64
+ 并把 trace/extract/panorama 的命令专属参数各自放进 `"trace/extract/panorama 选项"`
65
+ group。
66
+ 4. 顶层 `_print_help()` 重写:工具简介 → 用法 → 体检命令 → 扫描类(动态从
67
+ registry 渲染,对齐排版)→ 对象诊断 → 辅助命令 → 全局选项 → 退出码
68
+ (0/1/2/3) → 更多。
69
+ 5. `cmd_list` pretty 输出每项追加 `— 描述`;`list --format json` 在每项里加
70
+ `description` 字段,便于 agent / 脚本消费。
71
+ 6. 各 collector parser 的 `description` 从 `f"{title} ({id})"` 改为
72
+ `f"{title} ({id}) — {_desc(id)}"`,`<id> --help` 顶部就能看到一句话简介。
73
+
74
+ ### Tests
75
+
76
+ `tests/test_cli_help.py` 追加 8 个用例:
77
+ - 顶层 help 含工具简介 / 体检命令 / 扫描类 / 对象诊断 / 辅助命令 / 全局选项 /
78
+ 退出码(含 0/1/2/3)等关键 section 标题。
79
+ - 顶层 help 遍历 registry 断言每个 state collector id 出现,未来新增不漏排。
80
+ - 顶层 help 含 trace/extract/panorama 三个 inspector。
81
+ - `trace --help` 五个之前的裸 flag 都带中文 help 片段,且分组标题 `trace 选项`
82
+ + description 一句话简介都在。
83
+ - `gateway --help` 含 `全局选项` 分组,`--config` / `--unmask` / `--no-color` /
84
+ `--log-dir` / `--sessions-base` / `--openclaw-home` 都带中文说明。
85
+ - `gateway --help` description 拼上 `_COMMAND_DESC['gateway']` 一句话片段。
86
+ - `list` pretty 含已知 collector 的描述片段;`list --format json` 每项含
87
+ `description` key(已登记 id 描述非空)。
88
+
89
+ `pytest tests/ -q` → 234 passed, 1 skipped。
90
+
91
+ ### 行为不变
92
+
93
+ - 默认参数值(paths.\*)、各 collector/inspector 业务逻辑、退出码定义、
94
+ argv 解析顺序、扫描结果与 v1.11.x 完全一致。
95
+ - 之前 215+ 用例全绿;本次只增不改测试。
96
+
97
+
3
98
  ## v1.10.4 — plugin_diag windowed scan filters to dated-in-window (2026-06-16)
4
99
 
5
100
  Minimal correctness hardening of the v1.10.2 full-cache reuse. The 7d/30d
package/README.md CHANGED
@@ -125,6 +125,7 @@ openclaw-diag examples # Show usage examples
125
125
  openclaw-diag trace <uuid> # Last user message
126
126
  openclaw-diag trace <uuid> --msg-index 0 # First message
127
127
  openclaw-diag trace <uuid> --msg-match "deploy" # Match by content
128
+ openclaw-diag trace <uuid> --all-messages # Every user turn, one block each
128
129
  openclaw-diag trace <uuid> --no-trajectory # Skip trajectory enrichment
129
130
 
130
131
  # Extract: dump session content
@@ -1,3 +1,3 @@
1
1
  """ocdiag — shared library for openclaw-diag-cli scripts."""
2
2
 
3
- __version__ = "1.10.4"
3
+ __version__ = "1.12.1"
@@ -26,6 +26,7 @@ from ..core.types import Report, Section, Verdict
26
26
  from ..tracing import (
27
27
  analyze_phases,
28
28
  build_system_prompt_info,
29
+ extract_text,
29
30
  extract_trace_records,
30
31
  find_first_message,
31
32
  find_gateway_logs,
@@ -370,6 +371,152 @@ def _section_gateway(s: Section, gw: Dict[str, Any]) -> None:
370
371
  )
371
372
 
372
373
 
374
+ def _user_text_snippet(user_rec: Dict[str, Any], max_chars: int = 80) -> str:
375
+ """One-line preview of a user message's text content.
376
+
377
+ Used in the per-turn meta header when --all-messages is on so the reader
378
+ can tell turns apart at a glance. Whitespace is collapsed to single
379
+ spaces; the result is truncated with an ellipsis.
380
+ """
381
+ msg = user_rec.get("message") or {}
382
+ text = extract_text(msg.get("content", "")) or ""
383
+ text = " ".join(text.split())
384
+ if len(text) > max_chars:
385
+ text = text[:max_chars - 1] + "…"
386
+ return text
387
+
388
+
389
+ def _build_turn_sections(
390
+ report: Report,
391
+ *,
392
+ analysis: Dict[str, Any],
393
+ user_msg_ordinal: int,
394
+ user_msg_id: str,
395
+ total_user_msgs: int,
396
+ turn_label: str,
397
+ user_snippet: str,
398
+ ) -> None:
399
+ """Append the per-turn timeline / summary / model / tool sections.
400
+
401
+ ``turn_label`` is prepended to each section title so multi-turn output
402
+ keeps the per-turn blocks visually delimited (e.g. ``"Turn #2/5 · "``).
403
+
404
+ Per-turn vs session-level enrichment split (see TraceInspector.collect):
405
+ Per-turn here = timeline + summary + model breakdown + tool breakdown.
406
+ Trajectory + System Prompt + Gateway are session-scoped (one per file)
407
+ and are emitted ONCE by the caller, not per turn.
408
+ """
409
+ s_meta = report.section(f"{turn_label}Trace · 元信息")
410
+ s_meta.ok(
411
+ "user_message",
412
+ f"user message #{user_msg_ordinal} of {total_user_msgs} "
413
+ f"(id: {user_msg_id}) {user_snippet}",
414
+ data={
415
+ "index": user_msg_ordinal,
416
+ "id": user_msg_id,
417
+ "total": total_user_msgs,
418
+ "snippet": user_snippet,
419
+ },
420
+ )
421
+
422
+ s_timeline = report.section(f"{turn_label}Trace · 时间轴")
423
+ _section_timeline(s_timeline, analysis)
424
+
425
+ s_summary = report.section(f"{turn_label}Trace · 汇总")
426
+ _section_summary(s_summary, analysis)
427
+
428
+ if analysis["model_calls"]:
429
+ s_models = report.section(f"{turn_label}Trace · Model 拆解")
430
+ _section_model_breakdown(s_models, analysis)
431
+
432
+ if analysis["tool_execs"]:
433
+ s_tools = report.section(f"{turn_label}Trace · 工具拆解")
434
+ _section_tool_breakdown(s_tools, analysis)
435
+
436
+ # Slow E2E → WARN. Attach to this turn's summary section so the verdict
437
+ # surfaces without inventing a new section.
438
+ total_ms = analysis["summary"]["total_ms"]
439
+ if total_ms > SLOW_THRESHOLD_MS:
440
+ s_summary.warn(
441
+ "trace.slow",
442
+ f"E2E elapsed {fmt_duration(total_ms)} > "
443
+ f"{SLOW_THRESHOLD_MS // 1000}s",
444
+ data={"total_ms": total_ms, "threshold_ms": SLOW_THRESHOLD_MS},
445
+ )
446
+
447
+
448
+ def _section_aggregate(
449
+ s: Section, per_turn: List[Dict[str, Any]],
450
+ ) -> None:
451
+ """Render the cross-turn summary at the end of an --all-messages run.
452
+
453
+ Aggregates totals across every turn (count, cumulative E2E / model /
454
+ tool time, cumulative tokens) and surfaces any turn whose E2E exceeded
455
+ SLOW_THRESHOLD_MS as a per-turn WARN line.
456
+ """
457
+ n = len(per_turn)
458
+ total_e2e = sum(t["summary"]["total_ms"] for t in per_turn)
459
+ total_model = sum(t["summary"]["model_total_ms"] for t in per_turn)
460
+ total_tool = sum(t["summary"]["tool_total_ms"] for t in per_turn)
461
+ total_in = sum(t["summary"]["total_input_tokens"] for t in per_turn)
462
+ total_out = sum(t["summary"]["total_output_tokens"] for t in per_turn)
463
+ total_cr = sum(t["summary"]["total_cache_read"] for t in per_turn)
464
+ total_cw = sum(t["summary"]["total_cache_write"] for t in per_turn)
465
+ total_models = sum(t["summary"]["model_count"] for t in per_turn)
466
+ total_tools = sum(t["summary"]["tool_count"] for t in per_turn)
467
+
468
+ s.ok(
469
+ "agg.turns", f"Turns: {n}",
470
+ data={"count": n},
471
+ )
472
+ s.ok(
473
+ "agg.total", f"Cumulative E2E: {fmt_duration(total_e2e)}",
474
+ data={"total_ms": total_e2e},
475
+ )
476
+ s.ok(
477
+ "agg.model",
478
+ f"Cumulative model time: {fmt_duration(total_model)} "
479
+ f"across {total_models} call(s)",
480
+ data={"total_ms": total_model, "count": total_models},
481
+ )
482
+ s.ok(
483
+ "agg.tool",
484
+ f"Cumulative tool time: {fmt_duration(total_tool)} "
485
+ f"across {total_tools} call(s)",
486
+ data={"total_ms": total_tool, "count": total_tools},
487
+ )
488
+ tok_msg = f"Cumulative tokens: in={total_in} out={total_out}"
489
+ if total_cr:
490
+ tok_msg += f" cache_read={total_cr}"
491
+ if total_cw:
492
+ tok_msg += f" cache_write={total_cw}"
493
+ s.ok(
494
+ "agg.tokens", tok_msg,
495
+ data={
496
+ "input": total_in,
497
+ "output": total_out,
498
+ "cache_read": total_cr,
499
+ "cache_write": total_cw,
500
+ },
501
+ )
502
+
503
+ for t in per_turn:
504
+ if t["summary"]["total_ms"] > SLOW_THRESHOLD_MS:
505
+ s.warn(
506
+ f"agg.slow.{t['index']}",
507
+ f"Turn #{t['index'] + 1} slow: "
508
+ f"{fmt_duration(t['summary']['total_ms'])} > "
509
+ f"{SLOW_THRESHOLD_MS // 1000}s "
510
+ f"(id: {t['user_message_id']})",
511
+ data={
512
+ "index": t["index"],
513
+ "user_message_id": t["user_message_id"],
514
+ "total_ms": t["summary"]["total_ms"],
515
+ "threshold_ms": SLOW_THRESHOLD_MS,
516
+ },
517
+ )
518
+
519
+
373
520
  @register
374
521
  class TraceInspector:
375
522
  id = "trace"
@@ -402,6 +549,28 @@ class TraceInspector:
402
549
  report.elapsed_ms = (time.time() - t0) * 1000
403
550
  return report
404
551
 
552
+ # --all-messages is mutually exclusive with the single-turn selectors.
553
+ # The CLI parser already rejects the combination, but inspectors can
554
+ # also be called programmatically (tests, future SDK callers); guard
555
+ # here so the contract holds end-to-end.
556
+ all_messages = bool(kwargs.get("all_messages"))
557
+ if all_messages and (
558
+ kwargs.get("msg_index") is not None
559
+ or kwargs.get("msg_id") is not None
560
+ or kwargs.get("msg_match") is not None
561
+ ):
562
+ report.error = (
563
+ "--all-messages cannot be combined with "
564
+ "--msg-index/--msg-id/--msg-match"
565
+ )
566
+ report.diag_error = DiagError(
567
+ code="INVALID_ARGUMENT",
568
+ message=report.error,
569
+ hint="pick either --all-messages OR a single-turn selector",
570
+ )
571
+ report.elapsed_ms = (time.time() - t0) * 1000
572
+ return report
573
+
405
574
  files, candidates = sessions.resolve(
406
575
  session_id,
407
576
  base_dir=str(ctx.sessions_base),
@@ -466,41 +635,69 @@ class TraceInspector:
466
635
  report.elapsed_ms = (time.time() - t0) * 1000
467
636
  return report
468
637
 
469
- rec_idx, user_rec = select_user_message(
470
- records,
471
- kwargs.get("msg_index"),
472
- kwargs.get("msg_id"),
473
- kwargs.get("msg_match"),
474
- )
475
- try:
476
- user_msg_ordinal = next(
477
- i for i, (ri, _) in enumerate(user_msgs) if ri == rec_idx
638
+ # ── Pick which user turn(s) to analyze ──
639
+ # Single-turn (default, or any --msg-* selector) → one analysis.
640
+ # All-messages → analyze every user turn from find_user_messages.
641
+ # We still call analyze_phases per turn so each block has its own
642
+ # base_epoch_ms / timeline / summary identical to a single-turn run.
643
+ total_user_msgs = len(user_msgs)
644
+ if all_messages:
645
+ selected: List[tuple] = list(user_msgs)
646
+ else:
647
+ rec_idx, user_rec = select_user_message(
648
+ records,
649
+ kwargs.get("msg_index"),
650
+ kwargs.get("msg_id"),
651
+ kwargs.get("msg_match"),
478
652
  )
479
- except StopIteration:
480
- user_msg_ordinal = 0
481
- user_msg_id = user_rec.get("id", "?")
482
- report.data["user_message_index"] = user_msg_ordinal
483
- report.data["user_message_id"] = user_msg_id
484
-
485
- trace_records = extract_trace_records(records, rec_idx)
486
- analysis = analyze_phases(trace_records)
487
- report.data["base_epoch_ms"] = analysis["base_epoch_ms"]
488
- report.data["timeline"] = analysis["events"]
489
- report.data["model_calls"] = analysis["model_calls"]
490
- report.data["tool_execs"] = analysis["tool_execs"]
491
- report.data["summary"] = analysis["summary"]
653
+ selected = [(rec_idx, user_rec)]
654
+
655
+ # Run analyze_phases for each selected turn. The first turn's
656
+ # analysis seeds the session-level enrichment (trajectory / system
657
+ # prompt / gateway) — those data sources are session-scoped, not
658
+ # per-turn, so they are computed ONCE using the first turn's
659
+ # base_epoch_ms (which is the earliest timestamp in selected turns).
660
+ per_turn_analyses: List[Dict[str, Any]] = []
661
+ for ordinal, (rec_idx, user_rec) in enumerate(selected):
662
+ try:
663
+ turn_index = next(
664
+ i for i, (ri, _) in enumerate(user_msgs) if ri == rec_idx
665
+ )
666
+ except StopIteration:
667
+ turn_index = ordinal
668
+ turn_records = extract_trace_records(records, rec_idx)
669
+ analysis = analyze_phases(turn_records)
670
+ per_turn_analyses.append({
671
+ "index": turn_index,
672
+ "user_message_id": user_rec.get("id", "?"),
673
+ "user_record": user_rec,
674
+ "analysis": analysis,
675
+ })
676
+
677
+ # ── Session-level enrichment (computed once) ──
678
+ # Trajectory / system prompt / gateway timing reflect properties of
679
+ # the whole run/file, not a single user turn. Computing them once
680
+ # keeps cost down on long sessions and avoids duplicating identical
681
+ # blocks per turn. The base epoch we hand to load_trajectory_info /
682
+ # load_gateway_timing is the FIRST selected turn's base_epoch_ms;
683
+ # for single-turn this matches the legacy behaviour exactly, and for
684
+ # all-messages it puts the trajectory offsets on the same axis as
685
+ # the first turn block (the most useful anchor point).
686
+ first_analysis = per_turn_analyses[0]["analysis"]
687
+ base_epoch_ms = first_analysis["base_epoch_ms"]
492
688
 
493
689
  traj_info: Optional[Dict[str, Any]] = None
494
690
  if not kwargs.get("no_trajectory"):
495
691
  traj_path = find_trajectory_file(session_file)
496
692
  if traj_path:
497
- traj_info = load_trajectory_info(traj_path, analysis["base_epoch_ms"])
693
+ traj_info = load_trajectory_info(traj_path, base_epoch_ms)
498
694
  if traj_info is not None:
499
695
  _apply_traj_redaction(
500
696
  traj_info,
501
697
  mask=bool(kwargs.get("mask")),
502
698
  show_tool_metas=bool(kwargs.get("show_tool_metas")),
503
- show_plugin_snapshot=bool(kwargs.get("show_plugin_snapshot")),
699
+ show_plugin_snapshot=bool(
700
+ kwargs.get("show_plugin_snapshot")),
504
701
  )
505
702
 
506
703
  gw_info: Optional[Dict[str, Any]] = None
@@ -508,15 +705,15 @@ class TraceInspector:
508
705
  log_files = find_gateway_logs(str(ctx.log_dir))
509
706
  if log_files:
510
707
  gw_info = load_gateway_timing(
511
- log_files, full_session_id, analysis["base_epoch_ms"],
708
+ log_files, full_session_id, base_epoch_ms,
512
709
  )
513
710
 
514
711
  store_report = sessions.lookup_system_prompt_report(
515
712
  session_file, full_session_id,
516
713
  )
517
714
  system_prompt = build_system_prompt_info(store_report, traj_info)
518
- if system_prompt is not None and analysis.get("model_calls"):
519
- first_call = analysis["model_calls"][0]
715
+ if system_prompt is not None and first_analysis.get("model_calls"):
716
+ first_call = first_analysis["model_calls"][0]
520
717
  actual_input = (
521
718
  (first_call.get("tokens_in") or 0)
522
719
  + (first_call.get("cache_read") or 0)
@@ -532,58 +729,179 @@ class TraceInspector:
532
729
  if system_prompt is not None:
533
730
  report.data["systemPrompt"] = system_prompt
534
731
 
535
- # ── Sections ──
536
- s_meta = report.section("Trace · 元信息")
537
- s_meta.ok(
538
- "session", f"session: {full_session_id}",
539
- data={"session_id": full_session_id, "file": session_file},
540
- )
541
- total_user_msgs = len(user_msgs)
542
- msg_hint = (
543
- f"user message #{user_msg_ordinal} of {total_user_msgs} (id: {user_msg_id})"
544
- )
545
- if total_user_msgs > 1:
546
- msg_hint += f" [use --msg-index 0~{total_user_msgs - 1} to select]"
547
- s_meta.ok(
548
- "user_message", msg_hint,
549
- data={"index": user_msg_ordinal, "id": user_msg_id, "total": total_user_msgs},
550
- )
551
-
552
- s_timeline = report.section("Trace · 时间轴")
553
- _section_timeline(s_timeline, analysis)
554
-
555
- s_summary = report.section("Trace · 汇总")
556
- _section_summary(s_summary, analysis)
557
-
558
- if analysis["model_calls"]:
559
- s_models = report.section("Trace · Model 拆解")
560
- _section_model_breakdown(s_models, analysis)
561
-
562
- if analysis["tool_execs"]:
563
- s_tools = report.section("Trace · 工具拆解")
564
- _section_tool_breakdown(s_tools, analysis)
565
-
566
- if traj_info:
567
- s_traj = report.section("Trace · Trajectory")
568
- _section_trajectory(s_traj, traj_info)
732
+ # ── report.data wiring ──
733
+ # Single-turn path: keep the legacy keys (timeline, model_calls,
734
+ # tool_execs, summary, base_epoch_ms, user_message_*) at the top
735
+ # level so existing JSON consumers see no schema change.
736
+ # All-messages path: add report.data["all_messages"] (per-turn list)
737
+ # and report.data["aggregate"] (cumulative). The legacy single-turn
738
+ # keys are NOT populated in this mode — consumers that asked for
739
+ # all-messages should read the new keys.
740
+ if not all_messages:
741
+ single = per_turn_analyses[0]
742
+ report.data["base_epoch_ms"] = single["analysis"]["base_epoch_ms"]
743
+ report.data["timeline"] = single["analysis"]["events"]
744
+ report.data["model_calls"] = single["analysis"]["model_calls"]
745
+ report.data["tool_execs"] = single["analysis"]["tool_execs"]
746
+ report.data["summary"] = single["analysis"]["summary"]
747
+ report.data["user_message_index"] = single["index"]
748
+ report.data["user_message_id"] = single["user_message_id"]
749
+ else:
750
+ all_msgs_payload: List[Dict[str, Any]] = []
751
+ for t in per_turn_analyses:
752
+ a = t["analysis"]
753
+ all_msgs_payload.append({
754
+ "index": t["index"],
755
+ "user_message_id": t["user_message_id"],
756
+ "user_message_snippet": _user_text_snippet(t["user_record"]),
757
+ "base_epoch_ms": a["base_epoch_ms"],
758
+ "timeline": a["events"],
759
+ "model_calls": a["model_calls"],
760
+ "tool_execs": a["tool_execs"],
761
+ "summary": a["summary"],
762
+ })
763
+ report.data["all_messages"] = all_msgs_payload
764
+ report.data["aggregate"] = {
765
+ "turns": len(per_turn_analyses),
766
+ "total_ms": sum(
767
+ t["analysis"]["summary"]["total_ms"]
768
+ for t in per_turn_analyses
769
+ ),
770
+ "model_total_ms": sum(
771
+ t["analysis"]["summary"]["model_total_ms"]
772
+ for t in per_turn_analyses
773
+ ),
774
+ "tool_total_ms": sum(
775
+ t["analysis"]["summary"]["tool_total_ms"]
776
+ for t in per_turn_analyses
777
+ ),
778
+ "total_input_tokens": sum(
779
+ t["analysis"]["summary"]["total_input_tokens"]
780
+ for t in per_turn_analyses
781
+ ),
782
+ "total_output_tokens": sum(
783
+ t["analysis"]["summary"]["total_output_tokens"]
784
+ for t in per_turn_analyses
785
+ ),
786
+ "total_cache_read": sum(
787
+ t["analysis"]["summary"]["total_cache_read"]
788
+ for t in per_turn_analyses
789
+ ),
790
+ "total_cache_write": sum(
791
+ t["analysis"]["summary"]["total_cache_write"]
792
+ for t in per_turn_analyses
793
+ ),
794
+ }
569
795
 
570
- if system_prompt:
571
- s_sp = report.section("Trace · System Prompt")
572
- _section_system_prompt(s_sp, system_prompt)
796
+ # ── Sections ──
797
+ if not all_messages:
798
+ # Single-turn: emit one "session" line under 元信息 followed by
799
+ # the per-turn block, byte-identical to pre-1.11.0 output.
800
+ single = per_turn_analyses[0]
801
+ s_meta = report.section("Trace · 元信息")
802
+ s_meta.ok(
803
+ "session", f"session: {full_session_id}",
804
+ data={"session_id": full_session_id, "file": session_file},
805
+ )
806
+ msg_hint = (
807
+ f"user message #{single['index']} of {total_user_msgs} "
808
+ f"(id: {single['user_message_id']})"
809
+ )
810
+ if total_user_msgs > 1:
811
+ msg_hint += (
812
+ f" [use --msg-index 0~{total_user_msgs - 1} to select]"
813
+ )
814
+ s_meta.ok(
815
+ "user_message", msg_hint,
816
+ data={
817
+ "index": single["index"],
818
+ "id": single["user_message_id"],
819
+ "total": total_user_msgs,
820
+ },
821
+ )
822
+ s_timeline = report.section("Trace · 时间轴")
823
+ _section_timeline(s_timeline, single["analysis"])
824
+ s_summary = report.section("Trace · 汇总")
825
+ _section_summary(s_summary, single["analysis"])
826
+ if single["analysis"]["model_calls"]:
827
+ s_models = report.section("Trace · Model 拆解")
828
+ _section_model_breakdown(s_models, single["analysis"])
829
+ if single["analysis"]["tool_execs"]:
830
+ s_tools = report.section("Trace · 工具拆解")
831
+ _section_tool_breakdown(s_tools, single["analysis"])
832
+
833
+ if traj_info:
834
+ s_traj = report.section("Trace · Trajectory")
835
+ _section_trajectory(s_traj, traj_info)
836
+ if system_prompt:
837
+ s_sp = report.section("Trace · System Prompt")
838
+ _section_system_prompt(s_sp, system_prompt)
839
+ if gw_info:
840
+ s_gw = report.section("Trace · Gateway 计时")
841
+ _section_gateway(s_gw, gw_info)
842
+
843
+ total_ms = single["analysis"]["summary"]["total_ms"]
844
+ if total_ms > SLOW_THRESHOLD_MS:
845
+ s_summary.warn(
846
+ "trace.slow",
847
+ f"E2E elapsed {fmt_duration(total_ms)} > "
848
+ f"{SLOW_THRESHOLD_MS // 1000}s",
849
+ data={
850
+ "total_ms": total_ms,
851
+ "threshold_ms": SLOW_THRESHOLD_MS,
852
+ },
853
+ )
854
+ else:
855
+ # All-messages: a session-wide overview, then per-turn blocks
856
+ # delimited by a "Turn #i/N" prefix, then session-level
857
+ # enrichment ONCE, then a final aggregate section.
858
+ n = len(per_turn_analyses)
859
+ s_overview = report.section("Trace · 元信息 (全部消息)")
860
+ s_overview.ok(
861
+ "session", f"session: {full_session_id}",
862
+ data={"session_id": full_session_id, "file": session_file},
863
+ )
864
+ s_overview.ok(
865
+ "all_messages",
866
+ f"tracing all {n} user message(s) in this session",
867
+ data={"count": n},
868
+ )
573
869
 
574
- if gw_info:
575
- s_gw = report.section("Trace · Gateway 计时")
576
- _section_gateway(s_gw, gw_info)
870
+ for t in per_turn_analyses:
871
+ turn_label = f"Turn #{t['index'] + 1}/{total_user_msgs} · "
872
+ _build_turn_sections(
873
+ report,
874
+ analysis=t["analysis"],
875
+ user_msg_ordinal=t["index"],
876
+ user_msg_id=t["user_message_id"],
877
+ total_user_msgs=total_user_msgs,
878
+ turn_label=turn_label,
879
+ user_snippet=_user_text_snippet(t["user_record"]),
880
+ )
577
881
 
578
- # Slow E2E WARN. Add as a synthetic check on the summary section so
579
- # the verdict surfaces without inventing a new section.
580
- total_ms = analysis["summary"]["total_ms"]
581
- if total_ms > SLOW_THRESHOLD_MS:
582
- s_summary.warn(
583
- "trace.slow",
584
- f"E2E elapsed {fmt_duration(total_ms)} > {SLOW_THRESHOLD_MS//1000}s",
585
- data={"total_ms": total_ms, "threshold_ms": SLOW_THRESHOLD_MS},
586
- )
882
+ # Session-scoped enrichment is rendered ONCE emitting it per
883
+ # turn would duplicate large, identical blocks (system prompt
884
+ # alone can be 10k+ chars). Title-prefixed with "Session · " so
885
+ # the reader sees it is run-wide, not turn-specific.
886
+ if traj_info:
887
+ s_traj = report.section("Session · Trajectory")
888
+ _section_trajectory(s_traj, traj_info)
889
+ if system_prompt:
890
+ s_sp = report.section("Session · System Prompt")
891
+ _section_system_prompt(s_sp, system_prompt)
892
+ if gw_info:
893
+ s_gw = report.section("Session · Gateway 计时")
894
+ _section_gateway(s_gw, gw_info)
895
+
896
+ s_agg = report.section("Trace · 跨消息汇总")
897
+ _section_aggregate(s_agg, [
898
+ {
899
+ "index": t["index"],
900
+ "user_message_id": t["user_message_id"],
901
+ "summary": t["analysis"]["summary"],
902
+ }
903
+ for t in per_turn_analyses
904
+ ])
587
905
 
588
906
  report.elapsed_ms = (time.time() - t0) * 1000
589
907
  return report
package/ocdiag/main.py CHANGED
@@ -36,6 +36,37 @@ from .render.ndjson import NdjsonRenderer
36
36
  _FORMAT_CHOICES = ("pretty", "json", "ndjson")
37
37
 
38
38
 
39
+ # Centralized command description table (id -> one-line English summary).
40
+ # Reused by `list`, `<id> --help`, and the top-level help. When adding a new
41
+ # collector/inspector, add a row here; missing ids fall back to "" via _desc().
42
+ _COMMAND_DESC = {
43
+ # Scan-type collectors (state)
44
+ "channel": "Scan channel (Feishu/Telegram/etc.) connect + message logs; flag disconnects, expired-discard, auth failures",
45
+ "configuration": "Parse key openclaw.json settings (agents/models/plugins/channels) and flag missing or risky values",
46
+ "cron_jobs": "List every cron job's full config (schedule/payload/sessionTarget/delivery/enabled); detect no-fire / delivery failures",
47
+ "doctor": "Check Node / Python / openclaw-diag / OpenClaw install and versions are ready",
48
+ "environment": "Collect host environment, Gateway process, and OpenClaw version basics",
49
+ "gateway": "Analyze Gateway process lifecycle logs (start/restart/WS connect/crash)",
50
+ "performance": "Measure model-call latency (E2E/TTFT), tool durations, and availability",
51
+ "plugin_diag": "Check plugin load status, hook subscriptions, trust gate, and plugin errors",
52
+ "recent_errors": "Extract and categorize recent errors/exceptions from logs",
53
+ "run_health": "Assess agent run completion rate, stalls, and interruptions",
54
+ "sessions_diag": "Scan session.jsonl; count toolCall/toolResult pairing, orphans, anomalies",
55
+ "shell_history": "Summarize shell commands the agent has executed",
56
+ "sys_health": "Check system-level health: CPU/memory/disk/OOM/processes",
57
+ "task_health": "Assess background task (openclaw tasks) status and health",
58
+ # Object-type inspectors
59
+ "extract": "Export a session file to readable form (incl. active/reset/deleted/backup versions)",
60
+ "panorama": "360° diagnosis: correlate trajectory + app logs + subtasks + cron",
61
+ "trace": "Trace one user message's full lifecycle (prompt->toolCall->toolResult->reply)",
62
+ }
63
+
64
+
65
+ def _desc(mid: str) -> str:
66
+ """Return the one-line English description for a command id, or ""."""
67
+ return _COMMAND_DESC.get(mid, "")
68
+
69
+
39
70
  def _paged_print(text: str) -> None:
40
71
  """Print text through a pager when stdout is a TTY and output is long.
41
72
 
@@ -99,28 +130,53 @@ def _build_context(args) -> DiagContext:
99
130
 
100
131
 
101
132
  def _common_arguments(p: argparse.ArgumentParser) -> None:
102
- p.add_argument("--config", default=paths.CONFIG)
103
- p.add_argument("--log-dir", default=paths.LOG_DIR)
104
- p.add_argument("--sessions-base", default=paths.SESSIONS_BASE)
105
- p.add_argument("--openclaw-home", default=paths.OPENCLAW_HOME)
106
- p.add_argument(
133
+ """Register the global options shared by every subcommand, in a dedicated
134
+ argument group so --help output stays aligned.
135
+ """
136
+ g = p.add_argument_group("global options")
137
+ g.add_argument(
138
+ "--config", metavar="PATH", default=paths.CONFIG,
139
+ help="Path to openclaw.json (default: ~/.openclaw/openclaw.json)",
140
+ )
141
+ g.add_argument(
142
+ "--log-dir", metavar="PATH", default=paths.LOG_DIR,
143
+ help="OpenClaw log directory (default: /tmp/openclaw)",
144
+ )
145
+ g.add_argument(
146
+ "--sessions-base", metavar="PATH", default=paths.SESSIONS_BASE,
147
+ help="Sessions root directory (default: ~/.openclaw/agents)",
148
+ )
149
+ g.add_argument(
150
+ "--openclaw-home", metavar="PATH", default=paths.OPENCLAW_HOME,
151
+ help="OpenClaw home directory (default: ~/.openclaw)",
152
+ )
153
+ g.add_argument(
107
154
  "--format",
108
155
  choices=list(_FORMAT_CHOICES),
109
156
  default=None,
110
- help="Output format (pretty|json|ndjson). Default: pretty.",
157
+ help="Output format pretty|json|ndjson (default: pretty; json/ndjson suit agents/scripts)",
158
+ )
159
+ g.add_argument(
160
+ "--json", action="store_true",
161
+ help="Alias for --format json",
162
+ )
163
+ g.add_argument(
164
+ "--no-color", action="store_true",
165
+ help="Disable ANSI color (use when writing to a file or pipe)",
166
+ )
167
+ g.add_argument(
168
+ "--unmask", action="store_true",
169
+ help="Do not mask; show raw sensitive content (tokens/message bodies). Masked by default",
111
170
  )
112
- p.add_argument("--json", action="store_true", help="Alias for --format json.")
113
- p.add_argument("--no-color", action="store_true")
114
- p.add_argument("--unmask", action="store_true")
115
171
 
116
172
 
117
173
  def _channel_arguments(p: argparse.ArgumentParser) -> None:
118
- p.add_argument(
174
+ g = p.add_argument_group("channel options")
175
+ g.add_argument(
119
176
  "--account", default=None,
120
177
  help="Filter channel signals by account substring "
121
178
  "(matched against the channel-prefix portion of the message body, "
122
- "e.g. ``--account default`` to keep only ``feishu[default]:`` lines). "
123
- "Default: no filter.",
179
+ "e.g. --account default keeps only feishu[default]: lines). Default: no filter",
124
180
  )
125
181
 
126
182
 
@@ -159,63 +215,67 @@ def cmd_list(args) -> int:
159
215
  if fmt != "pretty":
160
216
  payload = {
161
217
  "state_collectors": [
162
- {"id": c.id, "label": c.title} for c in state
218
+ {"id": c.id, "label": _desc(c.id), "description": _desc(c.id)}
219
+ for c in state
163
220
  ],
164
221
  "object_inspectors": [
165
- {"id": c.id, "label": c.title} for c in inspectors
222
+ {"id": c.id, "label": _desc(c.id), "description": _desc(c.id)}
223
+ for c in inspectors
166
224
  ],
167
225
  }
168
226
  print(json.dumps(payload, ensure_ascii=False))
169
227
  return 0
170
- print("openclaw-diag — 可用诊断 (v2)")
228
+ print("openclaw-diag — available diagnostics (v2)")
171
229
  print()
172
- print(" 扫描类(无需参数):")
230
+ print(" Scan type (no args required):")
173
231
  for c in state:
174
- print(f" {c.id:<16s} {c.title}")
232
+ d = _desc(c.id)
233
+ print(f" {c.id:<16s} {d}")
175
234
  print()
176
235
  if inspectors:
177
- print(" 对象类(需要 session uuid):")
236
+ print(" Object type (require session uuid):")
178
237
  for c in inspectors:
179
- print(f" {c.id:<16s} {c.title}")
238
+ d = _desc(c.id)
239
+ print(f" {c.id:<16s} {d}")
180
240
  print()
181
- print(" 其它命令:")
182
- print(" all 一次跑完所有扫描类")
183
- print(" doctor 检查 Node / Python / openclaw-diag / OpenClaw 环境")
184
- print(" examples 打印常用使用示例")
241
+ print(" Other commands:")
242
+ print(" all Run all scan-type diagnostics at once")
243
+ print(" doctor Check Node / Python / openclaw-diag / OpenClaw environment")
244
+ print(" examples Print common usage examples")
185
245
  return 0
186
246
 
187
247
 
188
248
  def cmd_examples() -> int:
189
- print("""openclaw-diag — 常用场景
249
+ print("""openclaw-diag — common scenarios
190
250
 
191
- # 全面体检
251
+ # Full health check
192
252
  openclaw-diag all
193
253
 
194
- # JSON 输出(Agent / 脚本)
254
+ # JSON output (for agents / scripts)
195
255
  openclaw-diag all --format json
196
256
 
197
- # Gateway 状态
257
+ # Check Gateway status
198
258
  openclaw-diag gateway
199
259
 
200
- # 追踪一条消息的完整生命周期
260
+ # Trace one message's full lifecycle
201
261
  openclaw-diag trace <uuid>
202
262
  openclaw-diag trace abc12345 --msg-index 0
203
263
 
204
- # 导出 session 对话内容
264
+ # Export session conversation content
205
265
  openclaw-diag extract <uuid>
206
266
  openclaw-diag extract abc12345 --summary
207
267
 
208
- # session 全景诊断(关联到 trajectory + 日志 + 子任务 + cron
268
+ # Session panorama diagnosis (correlates trajectory + logs + subtasks + cron)
209
269
  openclaw-diag panorama <uuid>
210
270
  openclaw-diag panorama abc12345 --strict-correlation --format json
211
271
 
212
- # 模型性能
272
+ # Model performance
213
273
  openclaw-diag performance
214
274
 
215
- # 定时任务状态
275
+ # Cron job status
216
276
  openclaw-diag cron_jobs
217
277
 
218
- # jq 快速看 verdict
278
+ # Quick verdict via jq
219
279
  openclaw-diag all --format json | jq '.data.verdict'
220
280
  """)
221
281
  return 0
@@ -300,7 +360,7 @@ def cmd_all(args, skip_ids: List[str]) -> int:
300
360
  def cmd_run_collector(args, mid: str) -> int:
301
361
  c = registry.get(mid)
302
362
  if c is None:
303
- print(f"Error: 未知 collector '{mid}'", file=sys.stderr)
363
+ print(f"Error: unknown collector '{mid}'", file=sys.stderr)
304
364
  return EXIT_INPUT_ERROR
305
365
  ctx = _build_context(args)
306
366
  t0 = time.time()
@@ -319,21 +379,22 @@ def cmd_run_collector(args, mid: str) -> int:
319
379
  return _exit_code(report)
320
380
 
321
381
 
322
- _TRACE_EPILOG = """示例:
323
- openclaw-diag trace 7e9f3b31 # session 最后一条用户消息
324
- openclaw-diag trace 7e9f3b31 --msg-index 0 # 第一条
325
- openclaw-diag trace 7e9f3b31 --msg-match deploy # 按内容匹配
326
- openclaw-diag trace 7e9f3b31 --format json # JSON 输出
382
+ _TRACE_EPILOG = """Examples:
383
+ openclaw-diag trace 7e9f3b31 # last user message in the session
384
+ openclaw-diag trace 7e9f3b31 --msg-index 0 # the first one
385
+ openclaw-diag trace 7e9f3b31 --msg-match deploy # match by content
386
+ openclaw-diag trace 7e9f3b31 --all-messages # trace every user message in one run (one block per turn)
387
+ openclaw-diag trace 7e9f3b31 -A --format json # all user messages, JSON output
327
388
  """
328
389
 
329
- _EXTRACT_EPILOG = """示例:
330
- openclaw-diag extract 7e9f3b31 # 默认导出 active 文件
331
- openclaw-diag extract 7e9f3b31 --summary # 只看统计
332
- openclaw-diag extract 7e9f3b31 --all # reset / deleted / backup
390
+ _EXTRACT_EPILOG = """Examples:
391
+ openclaw-diag extract 7e9f3b31 # export the active file by default
392
+ openclaw-diag extract 7e9f3b31 --summary # stats only
393
+ openclaw-diag extract 7e9f3b31 --all # include reset / deleted / backup
333
394
  openclaw-diag extract 7e9f3b31 --format json
334
395
  """
335
396
 
336
- _PANORAMA_EPILOG = """示例:
397
+ _PANORAMA_EPILOG = """Examples:
337
398
  openclaw-diag panorama 7e9f3b31 # latest run
338
399
  openclaw-diag panorama 7e9f3b31 --all-runs # every run
339
400
  openclaw-diag panorama 7e9f3b31 --run-index 0 # first run
@@ -343,67 +404,139 @@ _PANORAMA_EPILOG = """示例:
343
404
 
344
405
 
345
406
  def _build_trace_parser() -> argparse.ArgumentParser:
407
+ desc_text = _desc("trace")
408
+ description = f"{desc_text} (trace)" if desc_text else "(trace)"
346
409
  p = argparse.ArgumentParser(
347
410
  prog="openclaw-diag trace",
411
+ description=description,
348
412
  add_help=True,
349
413
  epilog=_TRACE_EPILOG,
350
414
  formatter_class=argparse.RawDescriptionHelpFormatter,
351
415
  )
352
- p.add_argument("session_id", help="Session UUID (full or 8+ char prefix)")
353
- p.add_argument("--msg-index", type=int, default=None,
354
- help="Nth user message (0-based)")
355
- p.add_argument("--msg-id", default=None, help="Message by id field")
356
- p.add_argument("--msg-match", default=None,
357
- help="First user message containing TEXT")
358
- p.add_argument("--no-trajectory", action="store_true")
359
- p.add_argument("--no-log", action="store_true")
360
- p.add_argument("--show-tool-metas", action="store_true")
361
- p.add_argument("--show-plugin-snapshot", action="store_true")
362
- p.add_argument("--mask", action="store_true")
363
- p.add_argument("--agent", default=None, help="Limit to specific agent")
416
+ g = p.add_argument_group("trace options")
417
+ g.add_argument(
418
+ "session_id",
419
+ help="Target session UUID (full or 8+ char prefix)",
420
+ )
421
+ g.add_argument(
422
+ "--msg-index", type=int, default=None,
423
+ help="Nth user message (0-based)",
424
+ )
425
+ g.add_argument(
426
+ "--msg-id", default=None,
427
+ help="User message by id field",
428
+ )
429
+ g.add_argument(
430
+ "--msg-match", default=None,
431
+ help="First user message containing TEXT",
432
+ )
433
+ g.add_argument(
434
+ "-A", "--all-messages", action="store_true", dest="all_messages",
435
+ help="Trace every user message in the session "
436
+ "(mutually exclusive with --msg-index/--msg-id/--msg-match)",
437
+ )
438
+ g.add_argument(
439
+ "--no-trajectory", action="store_true",
440
+ help="Do not read trajectory.jsonl; analyze from session.jsonl only",
441
+ )
442
+ g.add_argument(
443
+ "--no-log", action="store_true",
444
+ help="Do not correlate openclaw application logs",
445
+ )
446
+ g.add_argument(
447
+ "--show-tool-metas", action="store_true",
448
+ help="Show full meta for each toolCall",
449
+ )
450
+ g.add_argument(
451
+ "--show-plugin-snapshot", action="store_true",
452
+ help="Show plugin snapshot (hooks/status)",
453
+ )
454
+ g.add_argument(
455
+ "--mask", action="store_true",
456
+ help="Force masking (trace does not mask by default)",
457
+ )
458
+ g.add_argument(
459
+ "--agent", default=None,
460
+ help="Limit to a specific agent",
461
+ )
364
462
  _common_arguments(p)
365
463
  return p
366
464
 
367
465
 
368
466
  def _build_extract_parser() -> argparse.ArgumentParser:
467
+ desc_text = _desc("extract")
468
+ description = f"{desc_text} (extract)" if desc_text else "(extract)"
369
469
  p = argparse.ArgumentParser(
370
470
  prog="openclaw-diag extract",
471
+ description=description,
371
472
  add_help=True,
372
473
  epilog=_EXTRACT_EPILOG,
373
474
  formatter_class=argparse.RawDescriptionHelpFormatter,
374
475
  )
375
- p.add_argument("session_id", help="Session UUID (full or 8+ char prefix)")
376
- p.add_argument("--summary", action="store_true",
377
- help="Per-file record-count summary, no record dump")
378
- p.add_argument("-a", "--all", action="store_true", dest="all_versions",
379
- help="Extract all versions (active + reset + deleted + backup)")
380
- p.add_argument("--list", action="store_true", dest="list_only",
381
- help="List matching files; do not extract")
382
- p.add_argument("--types", default=None,
383
- help="Filter by record type (comma-separated)")
384
- p.add_argument("--agent", default=None, help="Limit to specific agent")
476
+ g = p.add_argument_group("extract options")
477
+ g.add_argument(
478
+ "session_id",
479
+ help="Target session UUID (full or 8+ char prefix)",
480
+ )
481
+ g.add_argument(
482
+ "--summary", action="store_true",
483
+ help="Per-file record-count summary; do not dump record bodies",
484
+ )
485
+ g.add_argument(
486
+ "-a", "--all", action="store_true", dest="all_versions",
487
+ help="Export all versions (active + reset + deleted + backup)",
488
+ )
489
+ g.add_argument(
490
+ "--list", action="store_true", dest="list_only",
491
+ help="List matching files only; do not extract content",
492
+ )
493
+ g.add_argument(
494
+ "--types", default=None,
495
+ help="Filter by record type (comma-separated, e.g. user,assistant,toolCall)",
496
+ )
497
+ g.add_argument(
498
+ "--agent", default=None,
499
+ help="Limit to a specific agent",
500
+ )
385
501
  _common_arguments(p)
386
502
  return p
387
503
 
388
504
 
389
505
  def _build_panorama_parser() -> argparse.ArgumentParser:
506
+ desc_text = _desc("panorama")
507
+ description = f"{desc_text} (panorama)" if desc_text else "(panorama)"
390
508
  p = argparse.ArgumentParser(
391
509
  prog="openclaw-diag panorama",
510
+ description=description,
392
511
  add_help=True,
393
512
  epilog=_PANORAMA_EPILOG,
394
513
  formatter_class=argparse.RawDescriptionHelpFormatter,
395
514
  )
396
- p.add_argument("session_id", help="Session UUID (full or 8+ char prefix)")
397
- p.add_argument("--mask", action="store_true",
398
- help="Sanitize tool args / message content / api keys")
399
- p.add_argument("--run-index", type=int, default=None,
400
- help="Pick the Nth run (default: -1 = latest)")
401
- p.add_argument("--all-runs", action="store_true",
402
- help="Include every run in the session")
403
- p.add_argument("--strict-correlation", action="store_true",
404
- help="Match only on sessionId / runIds (drops sessionKey "
405
- "and toolCallId hits)")
406
- p.add_argument("--agent", default=None, help="Limit to specific agent")
515
+ g = p.add_argument_group("panorama options")
516
+ g.add_argument(
517
+ "session_id",
518
+ help="Target session UUID (full or 8+ char prefix)",
519
+ )
520
+ g.add_argument(
521
+ "--mask", action="store_true",
522
+ help="Sanitize tool args / message content / api keys",
523
+ )
524
+ g.add_argument(
525
+ "--run-index", type=int, default=None,
526
+ help="Pick the Nth run (default: -1 = latest)",
527
+ )
528
+ g.add_argument(
529
+ "--all-runs", action="store_true",
530
+ help="Include every run in the session",
531
+ )
532
+ g.add_argument(
533
+ "--strict-correlation", action="store_true",
534
+ help="Match only on sessionId / runIds (drop sessionKey and toolCallId hits)",
535
+ )
536
+ g.add_argument(
537
+ "--agent", default=None,
538
+ help="Limit to a specific agent",
539
+ )
407
540
  _common_arguments(p)
408
541
  return p
409
542
 
@@ -411,16 +544,29 @@ def _build_panorama_parser() -> argparse.ArgumentParser:
411
544
  def cmd_inspector(head: str, rest: List[str]) -> int:
412
545
  inspector = registry.get(head)
413
546
  if inspector is None or inspector.kind != "inspector":
414
- print(f"Error: 未知 inspector '{head}'", file=sys.stderr)
547
+ print(f"Error: unknown inspector '{head}'", file=sys.stderr)
415
548
  return EXIT_INPUT_ERROR
416
549
  if head == "trace":
417
550
  parser = _build_trace_parser()
418
551
  ns = parser.parse_args(rest)
552
+ # --all-messages traces every user turn; it is mutually exclusive with
553
+ # the single-turn selectors. Reject the combination at parse time so
554
+ # the inspector never sees an ambiguous request.
555
+ if ns.all_messages and (
556
+ ns.msg_index is not None
557
+ or ns.msg_id is not None
558
+ or ns.msg_match is not None
559
+ ):
560
+ parser.error(
561
+ "--all-messages/-A cannot be combined with "
562
+ "--msg-index/--msg-id/--msg-match",
563
+ )
419
564
  kwargs = {
420
565
  "session_id": ns.session_id,
421
566
  "msg_index": ns.msg_index,
422
567
  "msg_id": ns.msg_id,
423
568
  "msg_match": ns.msg_match,
569
+ "all_messages": ns.all_messages,
424
570
  "no_trajectory": ns.no_trajectory,
425
571
  "no_log": ns.no_log,
426
572
  "show_tool_metas": ns.show_tool_metas,
@@ -527,19 +673,61 @@ def _split_skip(rest: List[str]):
527
673
 
528
674
 
529
675
  def _print_help() -> None:
530
- print(f"openclaw-diag v{__version__} (v2)")
531
- print()
532
- print("用法:")
533
- print(" openclaw-diag <id> [args...] 跑单个诊断")
534
- print(" openclaw-diag all [--skip a,b] 跑全部 state collectors")
535
- print(" openclaw-diag list [--format X] 列出所有诊断")
536
- print(" openclaw-diag doctor 检查环境")
537
- print(" openclaw-diag trace <uuid> 追踪一条用户消息")
538
- print(" openclaw-diag extract <uuid> 导出 session 为可读格式")
539
- print(" openclaw-diag panorama <uuid> 360° session 全景诊断")
540
- print(" openclaw-diag examples 打印常用示例")
541
- print()
542
- print("通用 flag:--format pretty|json|ndjson --json (alias) --no-color --unmask --version")
676
+ """Render the top-level --help.
677
+
678
+ Structure: intro -> usage -> health checks -> scan diagnostics (rendered
679
+ dynamically from the registry) -> object diagnostics -> helper commands
680
+ -> global options -> exit codes -> more. Scan-list ids/descriptions are
681
+ pulled from registry + _COMMAND_DESC so newly registered collectors are
682
+ never missed.
683
+ """
684
+ state_ids = [c.id for c in registry.all_state()]
685
+ width = 16
686
+ lines: List[str] = []
687
+ lines.append(f"openclaw-diag v{__version__} — OpenClaw operations diagnostics CLI")
688
+ lines.append("")
689
+ lines.append("Scans config / logs / sessions to pinpoint connection, performance, cron,")
690
+ lines.append("plugin, and run issues in an OpenClaw deployment.")
691
+ lines.append("")
692
+ lines.append("Usage:")
693
+ lines.append(" openclaw-diag <command> [args...]")
694
+ lines.append(" openclaw-diag <command> --help Show detailed args for a command")
695
+ lines.append("")
696
+ lines.append("Health checks:")
697
+ lines.append(f" {'all':<{width}s}Run every scan-type diagnostic at once (recommended first step)")
698
+ lines.append(f" {'doctor':<{width}s}Check the runtime (Node / Python / OpenClaw readiness)")
699
+ lines.append("")
700
+ lines.append("Scan diagnostics (no args required):")
701
+ for sid in state_ids:
702
+ if sid == "doctor":
703
+ # doctor is already listed under Health checks; skip the duplicate.
704
+ continue
705
+ lines.append(f" {sid:<{width}s}{_desc(sid)}")
706
+ lines.append("")
707
+ lines.append("Object diagnostics (require a session uuid):")
708
+ lines.append(f" {'trace <uuid>':<{width}s}{_desc('trace')}")
709
+ lines.append(f" {'extract <uuid>':<{width}s}{_desc('extract')}")
710
+ lines.append(f" {'panorama <uuid>':<{width}s}{_desc('panorama')}")
711
+ lines.append("")
712
+ lines.append("Helper commands:")
713
+ lines.append(f" {'list':<{width}s}List all available diagnostics (supports --format json)")
714
+ lines.append(f" {'examples':<{width}s}Print common usage examples")
715
+ lines.append("")
716
+ lines.append("Global options (all commands):")
717
+ lines.append(" --format pretty|json|ndjson Output format (default: pretty)")
718
+ lines.append(" --json Alias for --format json")
719
+ lines.append(" --no-color Disable colored output")
720
+ lines.append(" --unmask Show raw (unmasked) sensitive content")
721
+ lines.append(" --config / --log-dir / --sessions-base / --openclaw-home Override default paths")
722
+ lines.append("")
723
+ lines.append("Exit codes:")
724
+ lines.append(" 0 OK (no warn/fail)")
725
+ lines.append(" 1 Warn or fail present")
726
+ lines.append(" 2 Input error (bad args/uuid)")
727
+ lines.append(" 3 Runtime error")
728
+ lines.append("")
729
+ lines.append("More: `openclaw-diag list` for all diagnostics, `openclaw-diag <command> --help` for details.")
730
+ print("\n".join(lines))
543
731
 
544
732
 
545
733
  def main(argv: Optional[List[str]] = None) -> int:
@@ -600,9 +788,11 @@ def main(argv: Optional[List[str]] = None) -> int:
600
788
  # parse_known_args (which would otherwise execute the diagnostic).
601
789
  # parse_known_args is preserved to keep the existing lenient handling
602
790
  # of unrecognized flags from external callers.
791
+ d = _desc(coll.id)
792
+ desc = f"{d} ({coll.id})" if d else f"({coll.id})"
603
793
  cparser = argparse.ArgumentParser(
604
794
  prog=f"openclaw-diag {head}",
605
- description=f"{coll.title} ({coll.id})",
795
+ description=desc,
606
796
  add_help=True,
607
797
  )
608
798
  _common_arguments(cparser)
@@ -611,8 +801,8 @@ def main(argv: Optional[List[str]] = None) -> int:
611
801
  args, _ = cparser.parse_known_args(rest)
612
802
  return cmd_run_collector(args, head)
613
803
 
614
- print(f"Error: 未知命令 '{head}'", file=sys.stderr)
615
- print("运行 `openclaw-diag list` 查看全部诊断。", file=sys.stderr)
804
+ print(f"Error: unknown command '{head}'", file=sys.stderr)
805
+ print("Run `openclaw-diag list` to see all diagnostics.", file=sys.stderr)
616
806
  return EXIT_INPUT_ERROR
617
807
 
618
808
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-diag-cli",
3
- "version": "1.10.4",
3
+ "version": "1.12.1",
4
4
  "description": "OpenClaw observer-only diagnostic CLI. Zero-dependency Python scripts wrapped in Node for npx-friendly install.",
5
5
  "keywords": [
6
6
  "openclaw",