redundo 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.
redundo/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """redundo: adapt agent traces to a common schema, then analyze them.
2
+
3
+ redundo adapt --source openinference traces/ | redundo analyze > report.html
4
+
5
+ `redundo.adapter` and `redundo.analyzer` are independent packages
6
+ joined by one contract, `redundo.analyzer.schema.Event` -- `analyze`
7
+ doesn't know or care whether its input came from `adapt` or was hand-built
8
+ NDJSON from a source this project doesn't support yet. See each
9
+ subpackage's own docstring for its half of the pipeline.
10
+ """
@@ -0,0 +1,13 @@
1
+ from .detect import Detection, DetectionError, Source, detect_source
2
+ from .sources import convert_claude_code, convert_cowork, convert_openclaw, convert_openinference
3
+
4
+ __all__ = [
5
+ "Source",
6
+ "Detection",
7
+ "DetectionError",
8
+ "detect_source",
9
+ "convert_openinference",
10
+ "convert_claude_code",
11
+ "convert_cowork",
12
+ "convert_openclaw",
13
+ ]
redundo/adapter/cli.py ADDED
@@ -0,0 +1,135 @@
1
+ """CLI entry point: a directory of captured OTLP JSON batches -> JSONL
2
+ matching the redundo.analyzer schema contract.
3
+
4
+ redundo adapt ./otlp_traces -o trace.jsonl
5
+
6
+ The source (OpenInference/Hermes, Claude Code, Cowork, or OpenClaw) is auto-detected
7
+ from the captured data itself -- see detect.py for exactly how, and
8
+ `--source` to skip detection and force one explicitly. A directory is
9
+ accepted rather than a single file because every source's own exporter
10
+ flushes on an interval, producing many small batch files per session
11
+ rather than one large export.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from .detect import DetectionError, Source, detect_source
22
+ from .otlp import OtlpParseError, is_log_document, is_trace_document
23
+ from .sources import convert_claude_code, convert_cowork, convert_openclaw, convert_openinference
24
+ from .writer import write_jsonl
25
+
26
+
27
+ def build_parser() -> argparse.ArgumentParser:
28
+ parser = argparse.ArgumentParser(
29
+ prog="redundo adapt",
30
+ description="Convert a directory of captured OTLP JSON batches into JSONL "
31
+ "matching the redundo analyze schema contract. The source is "
32
+ "auto-detected from the data; pass --source to override.",
33
+ )
34
+ parser.add_argument(
35
+ "otlp_dir", type=Path,
36
+ help="Directory of *.json OTLP export batch files (traces and/or logs, any mix)",
37
+ )
38
+ parser.add_argument(
39
+ "-o", "--output", type=Path, default=None, metavar="PATH",
40
+ help="Write JSONL here instead of stdout",
41
+ )
42
+ parser.add_argument(
43
+ "--source", choices=[s.value for s in Source], default=None,
44
+ help="Skip auto-detection and force this source",
45
+ )
46
+ parser.add_argument(
47
+ "--summary",
48
+ action="store_true",
49
+ help="Print the conversion summary (record counts, content-provenance "
50
+ "stats, per-source notes) to stderr",
51
+ )
52
+ return parser
53
+
54
+
55
+ def main(argv: list[str] | None = None) -> int:
56
+ parser = build_parser()
57
+ args = parser.parse_args(argv)
58
+
59
+ if not args.otlp_dir.is_dir():
60
+ print(f"redundo adapt: not a directory: {args.otlp_dir}", file=sys.stderr)
61
+ return 1
62
+
63
+ documents = []
64
+ skipped_unrecognized = 0
65
+ for path in sorted(args.otlp_dir.glob("*.json")):
66
+ try:
67
+ doc = json.loads(path.read_text(encoding="utf-8"))
68
+ except (OSError, json.JSONDecodeError) as exc:
69
+ print(f"redundo adapt: {path}: {exc}", file=sys.stderr)
70
+ return 1
71
+ if is_trace_document(doc) or is_log_document(doc):
72
+ documents.append(doc)
73
+ else:
74
+ skipped_unrecognized += 1
75
+
76
+ if not documents:
77
+ print(
78
+ f"redundo adapt: no OTLP trace or log documents found in {args.otlp_dir}",
79
+ file=sys.stderr,
80
+ )
81
+ return 1
82
+
83
+ if args.source is not None:
84
+ source = Source(args.source)
85
+ reason = "--source flag"
86
+ else:
87
+ try:
88
+ detection = detect_source(documents)
89
+ except DetectionError as exc:
90
+ print(f"redundo adapt: {exc}", file=sys.stderr)
91
+ return 1
92
+ source, reason = detection.source, detection.reason
93
+
94
+ trace_docs = [d for d in documents if is_trace_document(d)]
95
+ log_docs = [d for d in documents if is_log_document(d)]
96
+
97
+ try:
98
+ if source is Source.OPENINFERENCE:
99
+ records, summary = convert_openinference(trace_docs)
100
+ elif source is Source.CLAUDE_CODE:
101
+ records, summary = convert_claude_code(documents)
102
+ elif source is Source.OPENCLAW:
103
+ records, summary = convert_openclaw(trace_docs)
104
+ else:
105
+ records, summary = convert_cowork(log_docs)
106
+ except OtlpParseError as exc:
107
+ print(f"redundo adapt: {exc}", file=sys.stderr)
108
+ return 1
109
+
110
+ if args.output:
111
+ with args.output.open("w", encoding="utf-8") as handle:
112
+ write_jsonl(records, handle)
113
+ else:
114
+ write_jsonl(records, sys.stdout)
115
+
116
+ if args.summary or not records:
117
+ print(f"\nsource: {source.value} (detected via {reason})", file=sys.stderr)
118
+ if skipped_unrecognized:
119
+ print(
120
+ f" ({skipped_unrecognized} file(s) in {args.otlp_dir} were not "
121
+ "recognized OTLP trace/log exports and were ignored)",
122
+ file=sys.stderr,
123
+ )
124
+ skipped_by_kind = getattr(summary, "skipped_by_kind", None)
125
+ if skipped_by_kind:
126
+ for kind, count in sorted(skipped_by_kind.items()):
127
+ print(f" - {count} span(s)/event(s) of kind {kind!r} skipped", file=sys.stderr)
128
+ for note in summary.notes():
129
+ print(f" - {note}", file=sys.stderr)
130
+
131
+ return 0
132
+
133
+
134
+ if __name__ == "__main__":
135
+ raise SystemExit(main())
@@ -0,0 +1,171 @@
1
+ """Minimal local OTLP/HTTP receiver -- point any source's OTLP exporter at
2
+ it and it writes each POST out as OTLP JSON, one file per batch, into one
3
+ output directory that `redundo adapt` reads directly.
4
+
5
+ This is a convenience for local development and one-off analysis, not a
6
+ production observability pipeline -- if you already run a real OTel
7
+ Collector (or any backend with a file/JSON export path), point your
8
+ source at that instead and hand its output directory to `redundo adapt`
9
+ the same way. Every source this project supports needs both `/v1/traces`
10
+ and `/v1/logs` served from the *same* endpoint (some sources use only one,
11
+ some use both, and get one endpoint config to remember either way), so
12
+ this collector always serves both.
13
+
14
+ Requires the `collector` extra: `pip install redundo[collector]`
15
+
16
+ Usage:
17
+ redundo collect --port 4318 --out-dir ./otlp_traces
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import sys
25
+ import time
26
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
27
+ from pathlib import Path
28
+
29
+ try:
30
+ from google.protobuf.json_format import MessageToDict
31
+ from opentelemetry.proto.collector.logs.v1.logs_service_pb2 import (
32
+ ExportLogsServiceRequest,
33
+ ExportLogsServiceResponse,
34
+ )
35
+ from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
36
+ ExportTraceServiceRequest,
37
+ ExportTraceServiceResponse,
38
+ )
39
+ except ImportError:
40
+ print(
41
+ "redundo collect requires the 'collector' extra: "
42
+ "pip install redundo[collector]",
43
+ file=sys.stderr,
44
+ )
45
+ raise SystemExit(1)
46
+
47
+ OUT_DIR = Path("./otlp_traces")
48
+
49
+
50
+ def _fix_span_ids_to_hex(request: ExportTraceServiceRequest, document: dict) -> None:
51
+ """MessageToDict base64-encodes `bytes` fields by default. The OTLP
52
+ spec carves out trace_id/span_id/parent_span_id as a documented
53
+ exception -- those are hex in real OTLP JSON, not base64. Overwrite
54
+ them from the original protobuf objects, which have the real bytes.
55
+ """
56
+ for rs_proto, rs_doc in zip(request.resource_spans, document.get("resourceSpans", [])):
57
+ for ss_proto, ss_doc in zip(rs_proto.scope_spans, rs_doc.get("scopeSpans", [])):
58
+ for span_proto, span_doc in zip(ss_proto.spans, ss_doc.get("spans", [])):
59
+ span_doc["traceId"] = span_proto.trace_id.hex()
60
+ span_doc["spanId"] = span_proto.span_id.hex()
61
+ if span_proto.parent_span_id:
62
+ span_doc["parentSpanId"] = span_proto.parent_span_id.hex()
63
+ elif "parentSpanId" in span_doc:
64
+ del span_doc["parentSpanId"]
65
+
66
+
67
+ def _fix_log_ids_to_hex(request: ExportLogsServiceRequest, document: dict) -> None:
68
+ for rl_proto, rl_doc in zip(request.resource_logs, document.get("resourceLogs", [])):
69
+ for sl_proto, sl_doc in zip(rl_proto.scope_logs, rl_doc.get("scopeLogs", [])):
70
+ for rec_proto, rec_doc in zip(sl_proto.log_records, sl_doc.get("logRecords", [])):
71
+ if rec_proto.trace_id:
72
+ rec_doc["traceId"] = rec_proto.trace_id.hex()
73
+ elif "traceId" in rec_doc:
74
+ del rec_doc["traceId"]
75
+ if rec_proto.span_id:
76
+ rec_doc["spanId"] = rec_proto.span_id.hex()
77
+ elif "spanId" in rec_doc:
78
+ del rec_doc["spanId"]
79
+
80
+
81
+ class Handler(BaseHTTPRequestHandler):
82
+ def log_message(self, fmt, *args):
83
+ print(f"[collector] {self.address_string()} - {fmt % args}")
84
+
85
+ def do_POST(self):
86
+ if self.path in ("/v1/traces", "/v1/traces/"):
87
+ self._handle_traces()
88
+ elif self.path in ("/v1/logs", "/v1/logs/"):
89
+ self._handle_logs()
90
+ else:
91
+ self.send_response(404)
92
+ self.end_headers()
93
+
94
+ def _handle_traces(self):
95
+ length = int(self.headers.get("Content-Length", 0))
96
+ body = self.rfile.read(length)
97
+ request = ExportTraceServiceRequest()
98
+ request.ParseFromString(body)
99
+ document = MessageToDict(
100
+ request, preserving_proto_field_name=False, use_integers_for_enums=True
101
+ )
102
+ _fix_span_ids_to_hex(request, document)
103
+
104
+ span_count = sum(
105
+ len(scope_span.get("spans", []))
106
+ for rs in document.get("resourceSpans", [])
107
+ for scope_span in rs.get("scopeSpans", [])
108
+ )
109
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
110
+ out_path = OUT_DIR / f"traces-{time.time_ns()}.otlp.json"
111
+ out_path.write_text(json.dumps(document), encoding="utf-8")
112
+ print(f"[collector] wrote {span_count} span(s) -> {out_path}")
113
+
114
+ payload = ExportTraceServiceResponse().SerializeToString()
115
+ self.send_response(200)
116
+ self.send_header("Content-Type", "application/x-protobuf")
117
+ self.send_header("Content-Length", str(len(payload)))
118
+ self.end_headers()
119
+ self.wfile.write(payload)
120
+
121
+ def _handle_logs(self):
122
+ length = int(self.headers.get("Content-Length", 0))
123
+ body = self.rfile.read(length)
124
+ request = ExportLogsServiceRequest()
125
+ request.ParseFromString(body)
126
+ document = MessageToDict(
127
+ request, preserving_proto_field_name=False, use_integers_for_enums=True
128
+ )
129
+ _fix_log_ids_to_hex(request, document)
130
+
131
+ record_count = sum(
132
+ len(scope_log.get("logRecords", []))
133
+ for rl in document.get("resourceLogs", [])
134
+ for scope_log in rl.get("scopeLogs", [])
135
+ )
136
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
137
+ out_path = OUT_DIR / f"logs-{time.time_ns()}.otlp.json"
138
+ out_path.write_text(json.dumps(document), encoding="utf-8")
139
+ print(f"[collector] wrote {record_count} log record(s) -> {out_path}")
140
+
141
+ payload = ExportLogsServiceResponse().SerializeToString()
142
+ self.send_response(200)
143
+ self.send_header("Content-Type", "application/x-protobuf")
144
+ self.send_header("Content-Length", str(len(payload)))
145
+ self.end_headers()
146
+ self.wfile.write(payload)
147
+
148
+
149
+ def main(argv: list[str] | None = None) -> int:
150
+ parser = argparse.ArgumentParser(prog="redundo collect")
151
+ parser.add_argument("--port", type=int, default=4318)
152
+ parser.add_argument("--out-dir", type=Path, default=Path("./otlp_traces"))
153
+ args = parser.parse_args(argv)
154
+
155
+ global OUT_DIR
156
+ OUT_DIR = args.out_dir
157
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
158
+
159
+ server = ThreadingHTTPServer(("localhost", args.port), Handler)
160
+ print(f"[collector] listening on http://localhost:{args.port}/v1/traces and /v1/logs")
161
+ print(f"[collector] writing OTLP JSON batches to {OUT_DIR.resolve()}")
162
+ print("[collector] Ctrl+C to stop, then: redundo adapt <out-dir> -o trace.jsonl")
163
+ try:
164
+ server.serve_forever()
165
+ except KeyboardInterrupt:
166
+ pass
167
+ return 0
168
+
169
+
170
+ if __name__ == "__main__":
171
+ raise SystemExit(main())
@@ -0,0 +1,136 @@
1
+ """Figure out which source produced a captured OTLP corpus, so the CLI can
2
+ "just point it at your traces directory" without the user having to name
3
+ the source themselves.
4
+
5
+ Detection order, each check grounded in something actually verified
6
+ against real captured data or real source code (see each source's
7
+ docs/*.md), not assumed:
8
+
9
+ 1. Any span named `claude_code.*` -> Claude Code. Cowork never produces
10
+ trace spans in its documented configuration, and OpenInference spans
11
+ are never named this way, so this is unambiguous by itself.
12
+ 2. Any span named `openclaw.*` -> OpenClaw. Same reasoning: no other
13
+ supported source uses this prefix. This only catches OpenClaw's default
14
+ span naming -- an operator who has opted into
15
+ `OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental` gets spans
16
+ named `"<gen_ai.operation.name> <model>"` instead, which is genuinely
17
+ ambiguous with other gen_ai-semconv-instrumented sources and isn't
18
+ guessed at here -- see step 3's fallback and docs/openclaw.md.
19
+ 3. Any span carrying `openinference.span.kind` -> OpenInference (Hermes,
20
+ or anything else instrumented with an OpenInference-compatible
21
+ library -- see docs/openinference.md for what that covers and doesn't).
22
+ 4. Any span carrying `openclaw.model_call.observation_unit` -> OpenClaw.
23
+ Catches the gen_ai_latest_experimental-named case from step 2: this
24
+ attribute is present on every OpenClaw model-call span regardless of
25
+ naming mode (confirmed against the exporter's own source), and no other
26
+ supported source sets it.
27
+ 5. Otherwise (a logs-only corpus, no trace spans at all): Claude Code and
28
+ Cowork both emit an overlapping set of logs-signal event names
29
+ (`user_prompt`, `tool_result`, `api_request`, ...), so the event shape
30
+ alone is genuinely ambiguous -- confirmed empirically, not assumed
31
+ (see docs/claude-code.md and docs/cowork.md). Disambiguated by the
32
+ OTLP resource-level `service.name` attribute instead
33
+ (`resource.attributes`, attached once per resourceLogs entry,
34
+ describing the emitting process -- "claude-code" or "cowork",
35
+ confirmed against real captures / the Cowork monitoring reference).
36
+ Falls back to Claude-Code-only event names (`mcp_server_connection`,
37
+ `permission_mode_changed`, `auth` -- not in Cowork's documented event
38
+ list) if `service.name` is ever missing.
39
+
40
+ Detection failure is reported, never guessed past -- see DetectionError.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ from dataclasses import dataclass
46
+ from enum import Enum
47
+ from typing import Any
48
+
49
+ from .otlp import document_resource_attributes, is_log_document, is_trace_document, parse_log_records, parse_spans
50
+
51
+
52
+ class Source(str, Enum):
53
+ OPENINFERENCE = "openinference"
54
+ CLAUDE_CODE = "claude-code"
55
+ COWORK = "cowork"
56
+ OPENCLAW = "openclaw"
57
+
58
+
59
+ class DetectionError(ValueError):
60
+ pass
61
+
62
+
63
+ # Event names Cowork's monitoring reference documents in full -- an event
64
+ # outside this set appearing in a logs-only corpus means it isn't Cowork.
65
+ _COWORK_EVENT_NAMES = frozenset(
66
+ {"user_prompt", "assistant_response", "tool_result", "api_request", "api_error", "tool_decision"}
67
+ )
68
+ # Observed on real Claude Code captures, never documented for Cowork.
69
+ _CLAUDE_CODE_ONLY_EVENT_NAMES = frozenset(
70
+ {"mcp_server_connection", "permission_mode_changed", "auth"}
71
+ )
72
+
73
+ _OPENINFERENCE_KIND_ATTR = "openinference.span.kind"
74
+ _OPENCLAW_OBSERVATION_UNIT_ATTR = "openclaw.model_call.observation_unit"
75
+
76
+
77
+ @dataclass
78
+ class Detection:
79
+ source: Source
80
+ reason: str
81
+
82
+
83
+ def detect_source(documents: list[dict[str, Any]]) -> Detection:
84
+ trace_docs = [d for d in documents if is_trace_document(d)]
85
+ log_docs = [d for d in documents if is_log_document(d)]
86
+
87
+ for doc in trace_docs:
88
+ for span in parse_spans(doc):
89
+ if span.name.startswith("claude_code."):
90
+ return Detection(Source.CLAUDE_CODE, f"span name {span.name!r}")
91
+ if span.name.startswith("openclaw."):
92
+ return Detection(Source.OPENCLAW, f"span name {span.name!r}")
93
+ if _OPENINFERENCE_KIND_ATTR in span.attributes:
94
+ return Detection(
95
+ Source.OPENINFERENCE, f"span attribute {_OPENINFERENCE_KIND_ATTR!r}"
96
+ )
97
+ if _OPENCLAW_OBSERVATION_UNIT_ATTR in span.attributes:
98
+ return Detection(
99
+ Source.OPENCLAW, f"span attribute {_OPENCLAW_OBSERVATION_UNIT_ATTR!r}"
100
+ )
101
+
102
+ for doc in trace_docs + log_docs:
103
+ for resource_attrs in document_resource_attributes(doc):
104
+ service_name = resource_attrs.get("service.name")
105
+ if service_name == "claude-code":
106
+ return Detection(Source.CLAUDE_CODE, "resource service.name='claude-code'")
107
+ if service_name == "cowork":
108
+ return Detection(Source.COWORK, "resource service.name='cowork'")
109
+
110
+ event_names: set[str] = set()
111
+ for doc in log_docs:
112
+ for rec in parse_log_records(doc):
113
+ name = rec.attributes.get("event.name")
114
+ if isinstance(name, str):
115
+ event_names.add(name)
116
+ if event_names:
117
+ if event_names & _CLAUDE_CODE_ONLY_EVENT_NAMES:
118
+ return Detection(
119
+ Source.CLAUDE_CODE,
120
+ f"event name(s) {sorted(event_names & _CLAUDE_CODE_ONLY_EVENT_NAMES)} "
121
+ "not in Cowork's documented event set",
122
+ )
123
+ if event_names <= _COWORK_EVENT_NAMES:
124
+ return Detection(
125
+ Source.COWORK,
126
+ "logs-only corpus with only Cowork-documented event names, no "
127
+ "service.name and no Claude-Code-only event present",
128
+ )
129
+
130
+ raise DetectionError(
131
+ "could not determine which source produced this corpus -- found "
132
+ f"{len(trace_docs)} trace document(s) and {len(log_docs)} log document(s), "
133
+ "but none carried a recognizable span name, openinference.span.kind "
134
+ "attribute, resource service.name, or logs-signal event.name. Pass "
135
+ "--source explicitly (openinference, claude-code, cowork, or openclaw)."
136
+ )
@@ -0,0 +1,145 @@
1
+ """Content hashing: the precise procedure, implemented once so it can be
2
+ copied correctly. See docs/hashing.md for the written contract this implements and
3
+ the reasoning behind each choice. Do not change this file's behavior
4
+ without bumping HASH_SPEC -- comparisons across corpora depend on both
5
+ sides having run identical code.
6
+
7
+ The one invariant that matters most: nothing downstream of this module
8
+ ever sees raw content, only hashes. That's what makes it safe to run this
9
+ adapter against production traces without a security review of the
10
+ analyzer. Don't let raw prompt/argument text leak into logs, exceptions,
11
+ or the `name`/`workflow` fields this module doesn't touch.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import re
19
+ import unicodedata
20
+ from typing import Any
21
+
22
+ HASH_SPEC = "v1"
23
+ HASH_LENGTH = 16 # hex chars = 64 bits. See docs/hashing.md for the birthday-bound math.
24
+
25
+ # Order matters: broader/more specific patterns first so a later pattern
26
+ # can't partially match inside a token an earlier pass already replaced.
27
+ _ISO_DATETIME_RE = re.compile(
28
+ r"\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\b"
29
+ )
30
+ _ISO_DATE_ONLY_RE = re.compile(r"\b\d{4}-\d{2}-\d{2}\b")
31
+ _RFC_DATE_RE = re.compile(
32
+ r"\b(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s+\d{1,2}\s+"
33
+ r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*\s+\d{4}\s+"
34
+ r"\d{2}:\d{2}:\d{2}\s+(?:GMT|UTC|[+-]\d{4})\b"
35
+ )
36
+ _UUID_RE = re.compile(
37
+ r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"
38
+ )
39
+ _DURATION_RE = re.compile(r"\b\d+(?:\.\d+)?(?:ms|us|µs|ns|s|m|h)\b")
40
+ _HEX_ADDR_RE = re.compile(r"\b0x[0-9a-fA-F]{4,}\b")
41
+ _TMP_PATH_RE = re.compile(r"(?:/tmp/|/var/folders/)\S*")
42
+ _BARE_INTEGER_RE = re.compile(r"\b\d{5,}\b") # opt-in only; see mask_volatile
43
+
44
+ # (replacement token, pattern) in application order.
45
+ _MASKS: list[tuple[str, re.Pattern[str]]] = [
46
+ ("<DATE>", _ISO_DATETIME_RE),
47
+ ("<DATE>", _ISO_DATE_ONLY_RE),
48
+ ("<DATE>", _RFC_DATE_RE),
49
+ ("<UUID>", _UUID_RE),
50
+ ("<DUR>", _DURATION_RE),
51
+ ("<ADDR>", _HEX_ADDR_RE),
52
+ ("<TMP>", _TMP_PATH_RE),
53
+ ]
54
+
55
+
56
+ def normalize_text(text: str) -> str:
57
+ """Unicode NFC, collapse whitespace runs, strip ends. For plain-text
58
+ content (prompts, messages) -- not for structured content, which goes
59
+ through canonicalize_json instead.
60
+ """
61
+ text = unicodedata.normalize("NFC", text)
62
+ text = re.sub(r"\s+", " ", text)
63
+ return text.strip()
64
+
65
+
66
+ def _normalize_numbers(value: Any) -> Any:
67
+ """Floats with no fractional part collapse to int, so 1 and 1.0
68
+ canonicalize identically. This is the one deliberate departure from
69
+ "serialize exactly what json.loads gave you" -- documented in docs/hashing.md.
70
+ """
71
+ if isinstance(value, float) and value.is_integer():
72
+ return int(value)
73
+ if isinstance(value, dict):
74
+ return {k: _normalize_numbers(v) for k, v in value.items()}
75
+ if isinstance(value, list):
76
+ return [_normalize_numbers(v) for v in value]
77
+ return value
78
+
79
+
80
+ def canonicalize_json(value: Any) -> str:
81
+ """Sorted keys, no insignificant whitespace, normalized numbers.
82
+ `value` may already be a parsed object, or a JSON string -- both are
83
+ accepted so callers don't need to know which they have.
84
+ """
85
+ if isinstance(value, str):
86
+ value = json.loads(value)
87
+ normalized = _normalize_numbers(value)
88
+ return json.dumps(normalized, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
89
+
90
+
91
+ def mask_volatile(text: str, *, mask_integers: bool = False) -> tuple[str, int]:
92
+ """Replace volatile spans with stable placeholder tokens. Returns
93
+ (masked_text, spans_masked) -- the count is the diagnostic signal:
94
+ surface it in metadata so "zero repeats" and "zero repeats because
95
+ something volatile leaked through" don't look identical.
96
+
97
+ mask_integers is opt-in and off by default: a bare integer could be an
98
+ epoch timestamp or an order ID, and masking order IDs would collapse
99
+ genuinely different calls into one hash -- a false positive in the
100
+ headline metric, which is worse than a false negative here.
101
+ """
102
+ total = 0
103
+ for token, pattern in _MASKS:
104
+ text, n = pattern.subn(token, text)
105
+ total += n
106
+ if mask_integers:
107
+ text, n = _BARE_INTEGER_RE.subn("<NUM>", text)
108
+ total += n
109
+ return text, total
110
+
111
+
112
+ def content_hash(
113
+ raw: Any,
114
+ *,
115
+ structured: bool = False,
116
+ mask_integers: bool = False,
117
+ ) -> tuple[str, int]:
118
+ """The full procedure: normalize (text) or canonicalize (structured),
119
+ mask volatile spans, hash. Returns (hash_hex, masked_span_count).
120
+
121
+ `structured=True` for tool call arguments / JSON payloads.
122
+ `structured=False` (default) for free-text prompts and messages.
123
+
124
+ A `structured=True` value that isn't actually valid JSON (a mime_type
125
+ attribute claiming application/json on malformed content, for
126
+ instance) falls back to plain-text normalization instead of raising.
127
+ This adapter runs inside the boundary that's supposed to keep raw
128
+ content contained; letting one malformed record's parse error
129
+ propagate as an uncaught exception is the wrong failure mode here --
130
+ it aborts the whole conversion, and an exception object risks
131
+ carrying a fragment of that content into a log or traceback somewhere
132
+ outside this function's control. Degrading to text-mode hashing
133
+ keeps every record processed and keeps content from ever needing to
134
+ leave this function in the first place.
135
+ """
136
+ if structured:
137
+ try:
138
+ text = canonicalize_json(raw)
139
+ except (TypeError, ValueError):
140
+ text = normalize_text(str(raw))
141
+ else:
142
+ text = normalize_text(str(raw))
143
+ masked_text, span_count = mask_volatile(text, mask_integers=mask_integers)
144
+ digest = hashlib.sha256(masked_text.encode("utf-8")).hexdigest()[:HASH_LENGTH]
145
+ return digest, span_count