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.
- package/README.md +84 -71
- package/bin/openclaw-diag.js +67 -178
- package/diag/01_sys_health.py +0 -2
- package/diag/02_environment.py +34 -8
- package/diag/03_configuration.py +4 -1
- package/diag/04_gateway.py +30 -8
- package/diag/05_recent_errors.py +24 -14
- package/diag/06_cron_jobs.py +4 -41
- package/diag/07_performance.py +114 -42
- package/diag/08_sessions.py +2 -54
- package/diag/09_plugin_diag.py +52 -25
- package/diag/10_shell_history.py +30 -12
- package/lib/bundle.py +6 -13
- package/ocdiag/__init__.py +1 -1
- package/ocdiag/cli.py +16 -1
- package/ocdiag/dispatcher.py +140 -53
- package/ocdiag/doctor.py +162 -0
- package/ocdiag/jsonlog.py +0 -5
- package/ocdiag/paths.py +0 -1
- package/ocdiag/recent_logs.py +0 -3
- package/ocdiag/sensitive.py +95 -1
- package/ocdiag/timeutil.py +0 -11
- package/ocdiag/tokens.py +0 -4
- package/package.json +2 -3
- package/tools/oc_session_extract.py +75 -7
- package/tools/oc_session_trace.py +31 -9
package/ocdiag/timeutil.py
CHANGED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-diag-cli",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "OpenClaw
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
201
|
-
+ (f"
|
|
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:
|
|
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)
|