openclaw-diag-cli 0.1.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.
@@ -0,0 +1,41 @@
1
+ """Mask sensitive config values (keys, secrets, tokens)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ SENSITIVE_PATTERN = re.compile(
8
+ r"(key|secret|token|password|credential|auth|private|signing)",
9
+ re.IGNORECASE,
10
+ )
11
+
12
+ SENSITIVE_KEY_NAMES = {
13
+ "apikey", "appkey", "appsecret", "secret",
14
+ "token", "password", "encryptkey", "verificationtoken",
15
+ "webhook", "accesstoken", "refreshtoken", "signingsecret",
16
+ "clientsecret",
17
+ }
18
+
19
+
20
+ def mask(val) -> str:
21
+ """Mask a sensitive value, preserving first/last 4 chars for long strings."""
22
+ s = str(val)
23
+ if len(s) <= 4:
24
+ return "****"
25
+ if len(s) <= 10:
26
+ return s[:2] + "****"
27
+ return s[:4] + "****" + s[-4:]
28
+
29
+
30
+ def is_sensitive_key(key_path: str) -> bool:
31
+ """Check if a dotted config key path is sensitive."""
32
+ last = key_path.rsplit(".", 1)[-1].lower().replace("-", "").replace("_", "")
33
+ return last in SENSITIVE_KEY_NAMES or SENSITIVE_PATTERN.search(last) is not None
34
+
35
+
36
+ def safe_val(key: str, val, max_len: int = 300) -> str:
37
+ """Return display-safe value: mask if sensitive, truncate if long."""
38
+ if SENSITIVE_PATTERN.search(key):
39
+ return mask(val) if val else '""'
40
+ s = str(val)
41
+ return s[:max_len] + "..." if len(s) > max_len else s
@@ -0,0 +1,77 @@
1
+ """Timestamp parsing and duration formatting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Optional
7
+
8
+
9
+ def parse_obj_ts(ts_str: Optional[str]) -> Optional[datetime]:
10
+ """obj.timestamp is ISO 8601."""
11
+ if not ts_str:
12
+ return None
13
+ try:
14
+ return datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
15
+ except Exception:
16
+ return None
17
+
18
+
19
+ def parse_msg_ts(ms) -> Optional[datetime]:
20
+ """msg.timestamp is epoch milliseconds."""
21
+ if ms is None:
22
+ return None
23
+ try:
24
+ return datetime.fromtimestamp(int(ms) / 1000, tz=timezone.utc)
25
+ except Exception:
26
+ return None
27
+
28
+
29
+ def fmt_duration(sec) -> str:
30
+ if sec is None:
31
+ return "?"
32
+ s = float(sec)
33
+ if s < 60:
34
+ return f"{s:.0f}s"
35
+ if s < 3600:
36
+ return f"{s/60:.1f}m"
37
+ return f"{s/3600:.1f}h"
38
+
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
+ def fmt_age(ms_delta) -> str:
52
+ s = abs(float(ms_delta)) / 1000
53
+ if s < 60:
54
+ return f"{s:.0f}秒"
55
+ if s < 3600:
56
+ return f"{s/60:.0f}分钟"
57
+ if s < 86400:
58
+ return f"{s/3600:.1f}小时"
59
+ return f"{s/86400:.1f}天"
60
+
61
+
62
+ def fmt_ts(ms) -> str:
63
+ if not ms:
64
+ return "?"
65
+ try:
66
+ return datetime.fromtimestamp(int(ms) / 1000).strftime("%Y-%m-%d %H:%M:%S")
67
+ except Exception:
68
+ return str(ms)
69
+
70
+
71
+ def fmt_hms(ts: Optional[str]) -> str:
72
+ if not ts:
73
+ return "?"
74
+ try:
75
+ return ts.split("T", 1)[1][:8]
76
+ except Exception:
77
+ return ts[:19]
@@ -0,0 +1,46 @@
1
+ """Token / size formatters and percentile helper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List, Optional
6
+
7
+
8
+ def fmt_tokens(n) -> str:
9
+ if n is None:
10
+ return "?"
11
+ n = int(n)
12
+ if n >= 1_000_000:
13
+ return f"{n/1_000_000:.1f}M"
14
+ if n >= 1_000:
15
+ return f"{n/1_000:.1f}K"
16
+ return str(n)
17
+
18
+
19
+ def fmt_k(n) -> str:
20
+ return fmt_tokens(n)
21
+
22
+
23
+ def percentile(sorted_list: List[float], p: float) -> Optional[float]:
24
+ if not sorted_list:
25
+ return None
26
+ k = max(0, min(len(sorted_list) - 1, int(len(sorted_list) * p)))
27
+ return sorted_list[k]
28
+
29
+
30
+ def pct(sorted_vals: List[float], p: float) -> float:
31
+ if not sorted_vals:
32
+ return 0.0
33
+ n = len(sorted_vals)
34
+ idx = min(n - 1, int(n * p))
35
+ return sorted_vals[idx]
36
+
37
+
38
+ def human_size(b) -> str:
39
+ b = int(b)
40
+ if b < 1024:
41
+ return f"{b}B"
42
+ if b < 1048576:
43
+ return f"{b/1024:.1f}KB"
44
+ if b < 1073741824:
45
+ return f"{b/1048576:.1f}MB"
46
+ return f"{b/1073741824:.1f}GB"
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "openclaw-diag-cli",
3
+ "version": "0.1.0",
4
+ "description": "OpenClaw / ArkClaw read-only diagnostic CLI. Zero-dependency Python scripts wrapped in Node for npx-friendly install.",
5
+ "keywords": [
6
+ "openclaw",
7
+ "arkclaw",
8
+ "diagnostic",
9
+ "cli",
10
+ "ops",
11
+ "troubleshooting",
12
+ "ai-agent"
13
+ ],
14
+ "homepage": "https://github.com/wujiaming88/openclaw-diag-cli#readme",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/wujiaming88/openclaw-diag-cli.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/wujiaming88/openclaw-diag-cli/issues"
21
+ },
22
+ "license": "MIT",
23
+ "author": "wujiaming88",
24
+ "bin": {
25
+ "openclaw-diag": "./bin/openclaw-diag.js"
26
+ },
27
+ "files": [
28
+ "bin/",
29
+ "lib/",
30
+ "ocdiag/",
31
+ "diag/",
32
+ "tools/",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "scripts": {
40
+ "test": "node bin/openclaw-diag.js list && echo OK"
41
+ }
42
+ }
File without changes
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env python3
2
+ """Extract OpenClaw session JSONL files into human-readable format."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import glob
8
+ import json
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Iterator, List, Optional, TextIO, Tuple
13
+
14
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
15
+
16
+ from ocdiag import paths
17
+
18
+
19
+ DEFAULT_BASE_DIR = paths.SESSIONS_BASE
20
+ SEPARATOR = "═" * 63
21
+
22
+
23
+ def human_size(n: int) -> str:
24
+ for unit in ("B", "KB", "MB", "GB", "TB"):
25
+ if n < 1024:
26
+ return f"{n:.1f} {unit}" if unit != "B" else f"{n} {unit}"
27
+ n /= 1024
28
+ return f"{n:.1f} PB"
29
+
30
+
31
+ def classify_state(filename: str) -> str:
32
+ if filename.endswith(".jsonl"):
33
+ return "active"
34
+ if ".jsonl.deleted." in filename:
35
+ return "deleted"
36
+ if ".jsonl.reset." in filename:
37
+ return "reset"
38
+ if ".jsonl.bak-" in filename:
39
+ return "backup"
40
+ return "unknown"
41
+
42
+
43
+ def find_session_files(session_id, base_dir=DEFAULT_BASE_DIR, agent=None):
44
+ if agent:
45
+ agent_dirs = [os.path.join(base_dir, agent)]
46
+ else:
47
+ agent_dirs = sorted(glob.glob(os.path.join(base_dir, "*")))
48
+ found = []
49
+ for agent_dir in agent_dirs:
50
+ sessions_dir = os.path.join(agent_dir, "sessions")
51
+ if not os.path.isdir(sessions_dir):
52
+ continue
53
+ pattern = os.path.join(sessions_dir, f"{session_id}.jsonl*")
54
+ for path in sorted(glob.glob(pattern)):
55
+ name = os.path.basename(path)
56
+ if ".trajectory" in name:
57
+ continue
58
+ state = classify_state(name)
59
+ found.append((path, state))
60
+ return found
61
+
62
+
63
+ def stream_records(path):
64
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
65
+ for i, line in enumerate(f, start=1):
66
+ stripped = line.rstrip("\n")
67
+ if not stripped.strip():
68
+ continue
69
+ try:
70
+ obj = json.loads(stripped)
71
+ yield i, obj, stripped, None
72
+ except json.JSONDecodeError as e:
73
+ yield i, None, stripped, str(e)
74
+
75
+
76
+ def write_header(out, path, state):
77
+ try:
78
+ size = os.path.getsize(path)
79
+ except OSError:
80
+ size = 0
81
+ out.write(SEPARATOR + "\n")
82
+ out.write(f"File: {path}\n")
83
+ out.write(f"Size: {human_size(size)}\n")
84
+ out.write(f"State: {state}\n")
85
+ out.write(SEPARATOR + "\n\n")
86
+
87
+
88
+ def extract_file(path, state, out, pretty=True, type_filter=None):
89
+ write_header(out, path, state)
90
+ written = 0
91
+ for line_no, obj, raw, err in stream_records(path):
92
+ if err is not None:
93
+ out.write(f"--- Record {line_no} [PARSE ERROR: {err}] ---\n")
94
+ out.write(raw + "\n\n")
95
+ written += 1
96
+ continue
97
+ rtype = obj.get("type", "?") if isinstance(obj, dict) else "?"
98
+ if type_filter is not None and rtype not in type_filter:
99
+ continue
100
+ out.write(f"--- Record {line_no} [type: {rtype}] ---\n")
101
+ if pretty:
102
+ out.write(json.dumps(obj, indent=2, ensure_ascii=False))
103
+ else:
104
+ out.write(raw)
105
+ out.write("\n\n")
106
+ written += 1
107
+ return written
108
+
109
+
110
+ def summarize_file(path, state, out):
111
+ write_header(out, path, state)
112
+ counts: dict = {}
113
+ total = 0
114
+ earliest: Optional[str] = None
115
+ latest: Optional[str] = None
116
+ parse_errors = 0
117
+ for _, obj, _, err in stream_records(path):
118
+ total += 1
119
+ if err is not None:
120
+ parse_errors += 1
121
+ continue
122
+ if not isinstance(obj, dict):
123
+ counts["<non-object>"] = counts.get("<non-object>", 0) + 1
124
+ continue
125
+ rtype = obj.get("type", "<no-type>")
126
+ counts[rtype] = counts.get(rtype, 0) + 1
127
+ ts = obj.get("timestamp")
128
+ if isinstance(ts, str):
129
+ if earliest is None or ts < earliest:
130
+ earliest = ts
131
+ if latest is None or ts > latest:
132
+ latest = ts
133
+ out.write(f"Total records: {total}\n")
134
+ if parse_errors:
135
+ out.write(f"Parse errors: {parse_errors}\n")
136
+ out.write("By type:\n")
137
+ for k in sorted(counts, key=lambda k: -counts[k]):
138
+ out.write(f" {k}: {counts[k]}\n")
139
+ if earliest or latest:
140
+ out.write(f"Time range: {earliest or '?'} → {latest or '?'}\n")
141
+ out.write("\n")
142
+
143
+
144
+ def list_files(files, out):
145
+ out.write(f"Found {len(files)} file(s):\n\n")
146
+ for i, (path, state) in enumerate(files, start=1):
147
+ try:
148
+ size_s = human_size(os.path.getsize(path))
149
+ except OSError:
150
+ size_s = "?"
151
+ out.write(f" [{i}] {state:8s} {size_s:>10s} {path}\n")
152
+ out.write("\n")
153
+
154
+
155
+ def select_files(files, extract_all, _out):
156
+ if len(files) <= 1 or extract_all:
157
+ return files
158
+ list_files(files, sys.stderr)
159
+ sys.stderr.write("Multiple files found. Enter index (1-based), 'a' for all, or 'q' to quit: ")
160
+ sys.stderr.flush()
161
+ try:
162
+ choice = sys.stdin.readline().strip().lower()
163
+ except (KeyboardInterrupt, EOFError):
164
+ return []
165
+ if choice in ("q", ""):
166
+ return []
167
+ if choice == "a":
168
+ return files
169
+ try:
170
+ idx = int(choice)
171
+ if 1 <= idx <= len(files):
172
+ return [files[idx - 1]]
173
+ except ValueError:
174
+ pass
175
+ sys.stderr.write(f"Invalid choice: {choice}\n")
176
+ return []
177
+
178
+
179
+ def main() -> int:
180
+ p = argparse.ArgumentParser(
181
+ description="Extract OpenClaw session JSONL files into human-readable format.",
182
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
183
+ )
184
+ p.add_argument("session_id", help="Session UUID to extract")
185
+ p.add_argument("-o", "--output", help="Write output to FILE instead of stdout")
186
+ p.add_argument("-a", "--all", action="store_true",
187
+ help="Extract all versions found (active + deleted + reset + backup)")
188
+ p.add_argument("--list", action="store_true", help="List found files; do not extract")
189
+ p.add_argument("--agent", help="Limit search to specific agent directory")
190
+ p.add_argument("--base-dir", default=DEFAULT_BASE_DIR, help="Override base directory")
191
+ p.add_argument("--no-pretty", action="store_true", help="Output raw JSON lines")
192
+ p.add_argument("--types", help="Filter by record type (comma-separated, e.g. 'message,toolCall')")
193
+ p.add_argument("--summary", action="store_true",
194
+ help="Show record-count summary instead of full extraction")
195
+ args = p.parse_args()
196
+
197
+ files = find_session_files(args.session_id, args.base_dir, args.agent)
198
+ if not files:
199
+ 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 "")
202
+ + "\n"
203
+ )
204
+ return 1
205
+
206
+ if args.list:
207
+ list_files(files, sys.stdout)
208
+ return 0
209
+
210
+ selected = select_files(files, args.all, sys.stdout)
211
+ if not selected:
212
+ sys.stderr.write("No files selected.\n")
213
+ return 1
214
+
215
+ type_filter: Optional[set] = None
216
+ if args.types:
217
+ type_filter = {t.strip() for t in args.types.split(",") if t.strip()}
218
+
219
+ out_path = args.output
220
+ out_fp: TextIO
221
+ close_out = False
222
+ if out_path:
223
+ out_fp = open(out_path, "w", encoding="utf-8")
224
+ close_out = True
225
+ else:
226
+ out_fp = sys.stdout
227
+
228
+ try:
229
+ for path, state in selected:
230
+ if args.summary:
231
+ summarize_file(path, state, out_fp)
232
+ else:
233
+ extract_file(path, state, out_fp, pretty=not args.no_pretty,
234
+ type_filter=type_filter)
235
+ except BrokenPipeError:
236
+ try:
237
+ sys.stdout.flush()
238
+ except BrokenPipeError:
239
+ pass
240
+ devnull = os.open(os.devnull, os.O_WRONLY)
241
+ os.dup2(devnull, sys.stdout.fileno())
242
+ return 0
243
+ except KeyboardInterrupt:
244
+ sys.stderr.write("\nInterrupted.\n")
245
+ return 130
246
+ finally:
247
+ if close_out:
248
+ out_fp.close()
249
+
250
+ return 0
251
+
252
+
253
+ if __name__ == "__main__":
254
+ sys.exit(main())