chad-code 0.1.0__py3-none-any.whl

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.
chad/diag.py ADDED
@@ -0,0 +1,100 @@
1
+ """Diagnostic session log for chad (extracted from agent.py).
2
+
3
+ Throughput / prefill numbers and a readable trace — the user query, tool-call args
4
+ (bash commands, write/edit content), and result previews — are diagnostics, not UX, so
5
+ they go to ~/.chad/session.log (tail it live) instead of cluttering the transcript.
6
+ Dogfooding showed sessions can "do nothing then stop" with no way to see why: which
7
+ tool args were used, whether a search returned anything, whether the model tried to
8
+ declare done, which cache served the turn. This module is that trace.
9
+
10
+ The log is bounded (5 MB x3 rotation) and previews pass through a best-effort secret
11
+ redactor, but it still records command/file previews in plaintext outside the repo, so
12
+ treat it as sensitive. None of this touches the model-facing transcript or the tool
13
+ results the model sees — it is the diagnostic trace only.
14
+ """
15
+ import json
16
+ import logging
17
+ import os
18
+ import re
19
+ from logging.handlers import RotatingFileHandler
20
+
21
+ from . import config
22
+
23
+ _LOG_DIR = os.path.expanduser("~/.chad")
24
+ log = logging.getLogger("chad")
25
+ # Local privacy opt-out: set CHAD_NO_SESSION_LOG (any truthy value) to disable the
26
+ # diagnostic session log entirely, matching the CHAD_NO_VALIDATE convention. When opted
27
+ # out we install a NullHandler (so the many log.info calls stay cheap no-ops and Python
28
+ # never warns about missing handlers) and never create ~/.chad for the log's sake.
29
+ _DISABLED = config.flag("CHAD_NO_SESSION_LOG")
30
+ if _DISABLED:
31
+ log.addHandler(logging.NullHandler())
32
+ log.propagate = False
33
+ else:
34
+ try:
35
+ os.makedirs(_LOG_DIR, exist_ok=True)
36
+ # Bounded rotation (5MB x3) on the NAMED logger, not root, so other libraries'
37
+ # logging isn't redirected into our session file. propagate=False keeps these
38
+ # lines off the root handlers. Nothing else relies on basicConfig/root here.
39
+ _handler = RotatingFileHandler(
40
+ os.path.join(_LOG_DIR, "session.log"), maxBytes=5_000_000, backupCount=3)
41
+ _handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
42
+ log.setLevel(logging.INFO)
43
+ log.addHandler(_handler)
44
+ log.propagate = False
45
+ except OSError:
46
+ pass
47
+
48
+
49
+ # Coarse, conservative secret masking for what gets WRITTEN TO THE LOG only.
50
+ # Two branches:
51
+ # 1. A known key prefix (Bearer/sk-/ghp_/xox/aws_secret/api_key) followed by a
52
+ # value in a WIDE class that includes base64 punctuation (+ / =). The old
53
+ # single-class regex stopped at the first `/`, so an AWS secret like
54
+ # `wJalrXUtnFEMI/K7MDENG/bPx…` was only half-masked — the wide class fixes that.
55
+ # 2. A bare 32+ high-entropy blob in a NARROW class (no `/` or `+`) so ordinary
56
+ # file paths and URLs are left untouched — over-redaction silently guts the
57
+ # log's diagnostic value, which is worse than the status quo.
58
+ _SECRET_RE = re.compile(
59
+ r"(?i)"
60
+ r"(bearer\s+|sk-|ghp_|xox[baprs]-|aws_secret_access_key\s*=\s*|api[_-]?key\s*[=:]\s*)"
61
+ r"([A-Za-z0-9_\-+/]{16,}={0,2})"
62
+ r"|([A-Za-z0-9_\-]{32,})")
63
+
64
+
65
+ def redact(s: str) -> str:
66
+ """Mask secrets in a log preview. Known-prefix secrets keep their prefix and mask
67
+ the (wide) value; bare high-entropy blobs are masked whole. Length-preserving so
68
+ `<redacted:NN>` still carries a rough signal of what was there."""
69
+ def _mask(m):
70
+ if m.group(1) is not None: # known-prefix secret (wide value class)
71
+ return m.group(1) + "<redacted:" + str(len(m.group(2))) + ">"
72
+ return "<redacted:" + str(len(m.group(3))) + ">" # bare high-entropy blob
73
+ return _SECRET_RE.sub(_mask, s)
74
+
75
+
76
+ def warn_footer(warnings) -> list:
77
+ """One-line footer summarizing config/discovery warnings for the `/mcp` and
78
+ `/skills` summaries: `(N warning(s): a; b; c …)`, showing the first three. Returns
79
+ [] when there are none. Shared so the format lives in exactly one place."""
80
+ if not warnings:
81
+ return []
82
+ return [f"({len(warnings)} warning(s): " + "; ".join(warnings[:3])
83
+ + (" …" if len(warnings) > 3 else "") + ")"]
84
+
85
+
86
+ def args_preview(args, n=160):
87
+ """One-line, redacted, length-capped preview of tool-call args for the log."""
88
+ try:
89
+ s = json.dumps(args, ensure_ascii=False, sort_keys=True)
90
+ except Exception: # noqa: BLE001
91
+ s = str(args)
92
+ s = s if len(s) <= n else s[:n] + f"…(+{len(s) - n})"
93
+ return redact(s)
94
+
95
+
96
+ def result_preview(result, n=120):
97
+ """One-line, redacted preview of a tool result, with its full length annotated."""
98
+ s = str(result).replace("\n", "⏎")
99
+ extra = f" [{len(str(result))} chars]"
100
+ return redact((s if len(s) <= n else s[:n] + "…")) + extra