openclaw-diag-cli 1.10.4 → 1.11.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/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.11.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
@@ -323,7 +323,8 @@ _TRACE_EPILOG = """示例:
323
323
  openclaw-diag trace 7e9f3b31 # 该 session 最后一条用户消息
324
324
  openclaw-diag trace 7e9f3b31 --msg-index 0 # 第一条
325
325
  openclaw-diag trace 7e9f3b31 --msg-match deploy # 按内容匹配
326
- openclaw-diag trace 7e9f3b31 --format json # JSON 输出
326
+ openclaw-diag trace 7e9f3b31 --all-messages # 一次跑完全部用户消息(每轮一段)
327
+ openclaw-diag trace 7e9f3b31 -A --format json # 全部用户消息,JSON 输出
327
328
  """
328
329
 
329
330
  _EXTRACT_EPILOG = """示例:
@@ -355,6 +356,10 @@ def _build_trace_parser() -> argparse.ArgumentParser:
355
356
  p.add_argument("--msg-id", default=None, help="Message by id field")
356
357
  p.add_argument("--msg-match", default=None,
357
358
  help="First user message containing TEXT")
359
+ p.add_argument("-A", "--all-messages", action="store_true",
360
+ dest="all_messages",
361
+ help="Trace every user message in the session "
362
+ "(mutually exclusive with --msg-index/--msg-id/--msg-match)")
358
363
  p.add_argument("--no-trajectory", action="store_true")
359
364
  p.add_argument("--no-log", action="store_true")
360
365
  p.add_argument("--show-tool-metas", action="store_true")
@@ -416,11 +421,24 @@ def cmd_inspector(head: str, rest: List[str]) -> int:
416
421
  if head == "trace":
417
422
  parser = _build_trace_parser()
418
423
  ns = parser.parse_args(rest)
424
+ # --all-messages traces every user turn; it is mutually exclusive with
425
+ # the single-turn selectors. Reject the combination at parse time so
426
+ # the inspector never sees an ambiguous request.
427
+ if ns.all_messages and (
428
+ ns.msg_index is not None
429
+ or ns.msg_id is not None
430
+ or ns.msg_match is not None
431
+ ):
432
+ parser.error(
433
+ "--all-messages/-A cannot be combined with "
434
+ "--msg-index/--msg-id/--msg-match",
435
+ )
419
436
  kwargs = {
420
437
  "session_id": ns.session_id,
421
438
  "msg_index": ns.msg_index,
422
439
  "msg_id": ns.msg_id,
423
440
  "msg_match": ns.msg_match,
441
+ "all_messages": ns.all_messages,
424
442
  "no_trajectory": ns.no_trajectory,
425
443
  "no_log": ns.no_log,
426
444
  "show_tool_metas": ns.show_tool_metas,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-diag-cli",
3
- "version": "1.10.4",
3
+ "version": "1.11.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",