openclaw-diag-cli 0.1.2 → 0.2.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.
@@ -37,17 +37,6 @@ def fmt_duration(sec) -> str:
37
37
  return f"{s/3600:.1f}h"
38
38
 
39
39
 
40
- def fmt_duration_ms(ms) -> str:
41
- if ms is None:
42
- return "?"
43
- s = float(ms) / 1000.0
44
- if s < 60:
45
- return f"{s:.1f}s"
46
- if s < 3600:
47
- return f"{s/60:.1f}min"
48
- return f"{s/3600:.1f}h"
49
-
50
-
51
40
  def fmt_age(ms_delta) -> str:
52
41
  s = abs(float(ms_delta)) / 1000
53
42
  if s < 60:
package/ocdiag/tokens.py CHANGED
@@ -16,10 +16,6 @@ def fmt_tokens(n) -> str:
16
16
  return str(n)
17
17
 
18
18
 
19
- def fmt_k(n) -> str:
20
- return fmt_tokens(n)
21
-
22
-
23
19
  def percentile(sorted_list: List[float], p: float) -> Optional[float]:
24
20
  if not sorted_list:
25
21
  return None
package/package.json CHANGED
@@ -1,10 +1,9 @@
1
1
  {
2
2
  "name": "openclaw-diag-cli",
3
- "version": "0.1.2",
4
- "description": "OpenClaw / ArkClaw read-only diagnostic CLI. Zero-dependency Python scripts wrapped in Node for npx-friendly install.",
3
+ "version": "0.2.1",
4
+ "description": "OpenClaw observer-only diagnostic CLI. Zero-dependency Python scripts wrapped in Node for npx-friendly install.",
5
5
  "keywords": [
6
6
  "openclaw",
7
- "arkclaw",
8
7
  "diagnostic",
9
8
  "cli",
10
9
  "ops",
@@ -9,11 +9,12 @@ import json
9
9
  import os
10
10
  import sys
11
11
  from pathlib import Path
12
- from typing import Iterator, List, Optional, TextIO, Tuple
12
+ from typing import List, Optional, TextIO, Tuple
13
13
 
14
14
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
15
15
 
16
16
  from ocdiag import paths
17
+ from ocdiag.sensitive import sanitize_text
17
18
 
18
19
 
19
20
  DEFAULT_BASE_DIR = paths.SESSIONS_BASE
@@ -40,6 +41,29 @@ def classify_state(filename: str) -> str:
40
41
  return "unknown"
41
42
 
42
43
 
44
+ def _recent_session_ids(base_dir, limit=5):
45
+ """Return the most-recently-modified active session UUIDs."""
46
+ found: List[Tuple[float, str]] = []
47
+ for ad in glob.glob(os.path.join(base_dir, "*")):
48
+ sd = os.path.join(ad, "sessions")
49
+ if not os.path.isdir(sd):
50
+ continue
51
+ for entry in os.listdir(sd):
52
+ if not entry.endswith(".jsonl"):
53
+ continue
54
+ if ".trajectory" in entry or ".jsonl.reset." in entry:
55
+ continue
56
+ path = os.path.join(sd, entry)
57
+ try:
58
+ mtime = os.path.getmtime(path)
59
+ except OSError:
60
+ continue
61
+ sid = entry[:-len(".jsonl")]
62
+ found.append((mtime, sid))
63
+ found.sort(reverse=True)
64
+ return [sid for _, sid in found[:limit]]
65
+
66
+
43
67
  def find_session_files(session_id, base_dir=DEFAULT_BASE_DIR, agent=None):
44
68
  if agent:
45
69
  agent_dirs = [os.path.join(base_dir, agent)]
@@ -85,23 +109,57 @@ def write_header(out, path, state):
85
109
  out.write(SEPARATOR + "\n\n")
86
110
 
87
111
 
88
- def extract_file(path, state, out, pretty=True, type_filter=None):
112
+ def _sanitize_record(obj):
113
+ """Walk a session record and scrub free-form text content fields.
114
+
115
+ Sessions store user/assistant messages under ``message.content``. We don't
116
+ rewrite tool args or metadata: those keep structure that matters for
117
+ diagnosis. We only scrub free-form prose where secrets typically live
118
+ (user-pasted tokens, error tracebacks).
119
+ """
120
+ if not isinstance(obj, dict):
121
+ return obj
122
+ msg = obj.get("message")
123
+ if isinstance(msg, dict):
124
+ content = msg.get("content")
125
+ if isinstance(content, str):
126
+ msg["content"] = sanitize_text(content)
127
+ elif isinstance(content, list):
128
+ for part in content:
129
+ if isinstance(part, dict):
130
+ for k in ("text", "content"):
131
+ v = part.get(k)
132
+ if isinstance(v, str):
133
+ part[k] = sanitize_text(v)
134
+ # Also scrub any top-level text-ish fields the gateway may have set.
135
+ for k in ("text", "summary"):
136
+ v = msg.get(k)
137
+ if isinstance(v, str):
138
+ msg[k] = sanitize_text(v)
139
+ return obj
140
+
141
+
142
+ def extract_file(path, state, out, pretty=True, type_filter=None, sanitize=True):
89
143
  write_header(out, path, state)
90
144
  written = 0
91
145
  for line_no, obj, raw, err in stream_records(path):
92
146
  if err is not None:
93
147
  out.write(f"--- Record {line_no} [PARSE ERROR: {err}] ---\n")
94
- out.write(raw + "\n\n")
148
+ out.write((sanitize_text(raw) if sanitize else raw) + "\n\n")
95
149
  written += 1
96
150
  continue
97
151
  rtype = obj.get("type", "?") if isinstance(obj, dict) else "?"
98
152
  if type_filter is not None and rtype not in type_filter:
99
153
  continue
100
154
  out.write(f"--- Record {line_no} [type: {rtype}] ---\n")
155
+ if sanitize:
156
+ obj = _sanitize_record(obj)
101
157
  if pretty:
102
158
  out.write(json.dumps(obj, indent=2, ensure_ascii=False))
103
159
  else:
104
- out.write(raw)
160
+ # Non-pretty mode: emit the (possibly sanitized) JSON or fall back
161
+ # to the original raw line if we didn't touch it.
162
+ out.write(json.dumps(obj, ensure_ascii=False) if sanitize else raw)
105
163
  out.write("\n\n")
106
164
  written += 1
107
165
  return written
@@ -178,6 +236,7 @@ def select_files(files, extract_all, _out):
178
236
 
179
237
  def main() -> int:
180
238
  p = argparse.ArgumentParser(
239
+ prog=os.environ.get("OPENCLAW_DIAG_PROG") or None,
181
240
  description="Extract OpenClaw session JSONL files into human-readable format.",
182
241
  formatter_class=argparse.ArgumentDefaultsHelpFormatter,
183
242
  )
@@ -192,15 +251,24 @@ def main() -> int:
192
251
  p.add_argument("--types", help="Filter by record type (comma-separated, e.g. 'message,toolCall')")
193
252
  p.add_argument("--summary", action="store_true",
194
253
  help="Show record-count summary instead of full extraction")
254
+ p.add_argument("--unmask", action="store_true",
255
+ help="Disable default sanitization of secret-shaped substrings "
256
+ "in message content (off = scrubbed)")
195
257
  args = p.parse_args()
196
258
 
197
259
  files = find_session_files(args.session_id, args.base_dir, args.agent)
198
260
  if not files:
199
261
  sys.stderr.write(
200
- f"Error: no files found for session ID '{args.session_id}' under {args.base_dir}"
201
- + (f" (agent={args.agent})" if args.agent else "")
262
+ f"Error: 找不到 session '{args.session_id}'(在 {args.base_dir} 下)"
263
+ + (f" agent={args.agent}" if args.agent else "")
202
264
  + "\n"
203
265
  )
266
+ suggestions = _recent_session_ids(args.base_dir, limit=5)
267
+ if suggestions:
268
+ sys.stderr.write(" 最近的 5 个 session:\n")
269
+ for sid in suggestions:
270
+ sys.stderr.write(f" {sid}\n")
271
+ sys.stderr.write(" 提示:完整 UUID 或前缀(至少 8 位)都可。\n")
204
272
  return 1
205
273
 
206
274
  if args.list:
@@ -231,7 +299,7 @@ def main() -> int:
231
299
  summarize_file(path, state, out_fp)
232
300
  else:
233
301
  extract_file(path, state, out_fp, pretty=not args.no_pretty,
234
- type_filter=type_filter)
302
+ type_filter=type_filter, sanitize=not args.unmask)
235
303
  except BrokenPipeError:
236
304
  try:
237
305
  sys.stdout.flush()
@@ -52,14 +52,6 @@ def fmt_duration(ms: float) -> str:
52
52
  return f"{m}m{s:.1f}s"
53
53
 
54
54
 
55
- def human_size(n: int) -> str:
56
- for unit in ("B", "KB", "MB", "GB"):
57
- if n < 1024:
58
- return f"{n:.1f} {unit}" if unit != "B" else f"{n} {unit}"
59
- n /= 1024
60
- return f"{n:.1f} TB"
61
-
62
-
63
55
  def extract_text(content: Any) -> str:
64
56
  if isinstance(content, str):
65
57
  return content
@@ -112,6 +104,29 @@ def find_session_file(
112
104
  return candidates[0][0] if candidates else None
113
105
 
114
106
 
107
+ def _recent_session_ids(base_dir: str, limit: int = 5) -> List[str]:
108
+ """Return the most-recently-modified active session UUIDs (no .reset/.bak/.deleted)."""
109
+ found: List[Tuple[float, str]] = []
110
+ for ad in glob.glob(os.path.join(base_dir, "*")):
111
+ sd = os.path.join(ad, "sessions")
112
+ if not os.path.isdir(sd):
113
+ continue
114
+ for entry in os.listdir(sd):
115
+ if not entry.endswith(".jsonl"):
116
+ continue
117
+ if ".trajectory" in entry or ".jsonl.reset." in entry:
118
+ continue
119
+ path = os.path.join(sd, entry)
120
+ try:
121
+ mtime = os.path.getmtime(path)
122
+ except OSError:
123
+ continue
124
+ sid = entry[:-len(".jsonl")]
125
+ found.append((mtime, sid))
126
+ found.sort(reverse=True)
127
+ return [sid for _, sid in found[:limit]]
128
+
129
+
115
130
  def find_trajectory_file(session_file: str) -> Optional[str]:
116
131
  d = os.path.dirname(session_file)
117
132
  base = os.path.basename(session_file).split(".jsonl")[0]
@@ -634,6 +649,7 @@ def format_json(session_id, session_file, user_msg_index, user_msg_id, analysis,
634
649
 
635
650
  def main():
636
651
  parser = argparse.ArgumentParser(
652
+ prog=os.environ.get("OPENCLAW_DIAG_PROG") or None,
637
653
  description="Trace the processing timeline of a user message in an OpenClaw session.",
638
654
  formatter_class=argparse.ArgumentDefaultsHelpFormatter,
639
655
  )
@@ -652,8 +668,14 @@ def main():
652
668
 
653
669
  session_file = find_session_file(args.session_id, args.base_dir, args.agent)
654
670
  if not session_file:
655
- print(f"Error: no session file found for '{args.session_id}' under {args.base_dir}",
671
+ print(f"Error: 找不到 session '{args.session_id}'(在 {args.base_dir} 下)",
656
672
  file=sys.stderr)
673
+ suggestions = _recent_session_ids(args.base_dir, limit=5)
674
+ if suggestions:
675
+ print(f" 最近的 5 个 session:", file=sys.stderr)
676
+ for sid in suggestions:
677
+ print(f" {sid}", file=sys.stderr)
678
+ print(f" 提示:UUID 完整 36 位,前缀也可(至少 8 位)。", file=sys.stderr)
657
679
  sys.exit(1)
658
680
 
659
681
  records = load_records(session_file)