traceforge-toolkit 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.
Files changed (205) hide show
  1. traceforge/__init__.py +72 -0
  2. traceforge/__main__.py +5 -0
  3. traceforge/_generated.py +254 -0
  4. traceforge/adapters/__init__.py +12 -0
  5. traceforge/adapters/base.py +82 -0
  6. traceforge/adapters/genai_otel.py +164 -0
  7. traceforge/adapters/mapped_json.py +297 -0
  8. traceforge/adapters/otel.py +220 -0
  9. traceforge/boundary/__init__.py +46 -0
  10. traceforge/boundary/data/boundary-model.joblib +0 -0
  11. traceforge/boundary/decode.py +87 -0
  12. traceforge/boundary/features.py +137 -0
  13. traceforge/boundary/inference.py +267 -0
  14. traceforge/boundary/inferencer.py +146 -0
  15. traceforge/classify/__init__.py +95 -0
  16. traceforge/classify/cmd.py +109 -0
  17. traceforge/classify/coding.py +213 -0
  18. traceforge/classify/config.py +554 -0
  19. traceforge/classify/core.py +266 -0
  20. traceforge/classify/data/binary_info.yaml +358 -0
  21. traceforge/classify/data/canonical_tools.yaml +251 -0
  22. traceforge/classify/data/effect_overrides.yaml +480 -0
  23. traceforge/classify/data/mcp_profiles.yaml +711 -0
  24. traceforge/classify/data/recommendation_rules.yaml +111 -0
  25. traceforge/classify/data/risk.yaml +534 -0
  26. traceforge/classify/data/shell_defaults.yaml +95 -0
  27. traceforge/classify/data/shell_rules.yaml +1192 -0
  28. traceforge/classify/data/tool_classifications.yaml +215 -0
  29. traceforge/classify/data/verb_inference.yaml +218 -0
  30. traceforge/classify/mcp.py +181 -0
  31. traceforge/classify/phases.py +75 -0
  32. traceforge/classify/powershell.py +93 -0
  33. traceforge/classify/registry.py +138 -0
  34. traceforge/classify/risk.py +553 -0
  35. traceforge/classify/rules.py +215 -0
  36. traceforge/classify/schema.yaml +282 -0
  37. traceforge/classify/shell.py +503 -0
  38. traceforge/classify/tools.py +91 -0
  39. traceforge/classify/workflow.py +32 -0
  40. traceforge/cli/__init__.py +42 -0
  41. traceforge/cli/config_cmd.py +130 -0
  42. traceforge/cli/detect.py +46 -0
  43. traceforge/cli/download_cmd.py +74 -0
  44. traceforge/cli/factory.py +60 -0
  45. traceforge/cli/gate_cmd.py +30 -0
  46. traceforge/cli/init_cmd.py +86 -0
  47. traceforge/cli/replay.py +130 -0
  48. traceforge/cli/runner.py +181 -0
  49. traceforge/cli/score.py +217 -0
  50. traceforge/cli/status.py +65 -0
  51. traceforge/cli/watch.py +297 -0
  52. traceforge/config/__init__.py +80 -0
  53. traceforge/config/defaults.py +149 -0
  54. traceforge/config/loader.py +239 -0
  55. traceforge/config/mappings.py +104 -0
  56. traceforge/config/models.py +579 -0
  57. traceforge/enricher.py +576 -0
  58. traceforge/formatting/__init__.py +12 -0
  59. traceforge/formatting/budget.py +92 -0
  60. traceforge/formatting/density.py +154 -0
  61. traceforge/gate/__init__.py +17 -0
  62. traceforge/gate/client.py +145 -0
  63. traceforge/gate/external.py +305 -0
  64. traceforge/gate/registry.py +108 -0
  65. traceforge/gate/server.py +174 -0
  66. traceforge/gates/__init__.py +8 -0
  67. traceforge/gates/pii.py +352 -0
  68. traceforge/gates/pii_patterns.yaml +228 -0
  69. traceforge/governance/__init__.py +121 -0
  70. traceforge/governance/assessor.py +441 -0
  71. traceforge/governance/budget.py +59 -0
  72. traceforge/governance/canonical.py +56 -0
  73. traceforge/governance/codec.py +414 -0
  74. traceforge/governance/context.py +249 -0
  75. traceforge/governance/drift.py +196 -0
  76. traceforge/governance/emitter.py +234 -0
  77. traceforge/governance/envelope.py +138 -0
  78. traceforge/governance/ifc.py +364 -0
  79. traceforge/governance/integrity.py +162 -0
  80. traceforge/governance/labeler.py +250 -0
  81. traceforge/governance/mcp_drift.py +328 -0
  82. traceforge/governance/monitor.py +539 -0
  83. traceforge/governance/observer.py +276 -0
  84. traceforge/governance/persistence.py +401 -0
  85. traceforge/governance/phase1.py +77 -0
  86. traceforge/governance/pii.py +276 -0
  87. traceforge/governance/pipeline.py +840 -0
  88. traceforge/governance/registry.py +110 -0
  89. traceforge/governance/results.py +183 -0
  90. traceforge/governance/risk_wrapper.py +113 -0
  91. traceforge/governance/rules.py +368 -0
  92. traceforge/governance/scorer.py +262 -0
  93. traceforge/governance/shield.py +318 -0
  94. traceforge/governance/state.py +466 -0
  95. traceforge/governance/types.py +176 -0
  96. traceforge/mappings/__init__.py +1 -0
  97. traceforge/mappings/aider.yaml +216 -0
  98. traceforge/mappings/aider_markdown.yaml +113 -0
  99. traceforge/mappings/amazonq.yaml +56 -0
  100. traceforge/mappings/antigravity.yaml +88 -0
  101. traceforge/mappings/claude.yaml +93 -0
  102. traceforge/mappings/cline.yaml +286 -0
  103. traceforge/mappings/codex.yaml +158 -0
  104. traceforge/mappings/continue_dev.yaml +57 -0
  105. traceforge/mappings/copilot.yaml +266 -0
  106. traceforge/mappings/copilot_markdown.yaml +72 -0
  107. traceforge/mappings/copilot_vscode.yaml +181 -0
  108. traceforge/mappings/crewai.yaml +592 -0
  109. traceforge/mappings/goose.yaml +128 -0
  110. traceforge/mappings/langgraph.yaml +165 -0
  111. traceforge/mappings/maf.yaml +90 -0
  112. traceforge/mappings/maf_transcript.yaml +99 -0
  113. traceforge/mappings/openai_agents.yaml +144 -0
  114. traceforge/mappings/opencode.yaml +473 -0
  115. traceforge/mappings/openhands.yaml +320 -0
  116. traceforge/mappings/pydantic_ai.yaml +183 -0
  117. traceforge/mappings/smolagents.yaml +89 -0
  118. traceforge/mappings/sweagent.yaml +82 -0
  119. traceforge/migrations/__init__.py +1 -0
  120. traceforge/migrations/env.py +60 -0
  121. traceforge/migrations/models.py +145 -0
  122. traceforge/migrations/runner.py +44 -0
  123. traceforge/migrations/script.py.mako +25 -0
  124. traceforge/migrations/versions/0001_initial.py +176 -0
  125. traceforge/migrations/versions/__init__.py +4 -0
  126. traceforge/models.py +29 -0
  127. traceforge/parsers/__init__.py +21 -0
  128. traceforge/parsers/aider.py +416 -0
  129. traceforge/parsers/base.py +226 -0
  130. traceforge/parsers/copilot.py +314 -0
  131. traceforge/phase/__init__.py +50 -0
  132. traceforge/phase/data/phase-model.joblib +0 -0
  133. traceforge/phase/data/potion-base-8M/README.md +99 -0
  134. traceforge/phase/data/potion-base-8M/config.json +13 -0
  135. traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
  136. traceforge/phase/data/potion-base-8M/modules.json +14 -0
  137. traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
  138. traceforge/phase/event_rows.py +107 -0
  139. traceforge/phase/features.py +468 -0
  140. traceforge/phase/inference.py +279 -0
  141. traceforge/phase/inferencer.py +171 -0
  142. traceforge/phase/segmentation.py +258 -0
  143. traceforge/pipeline.py +891 -0
  144. traceforge/preprocessors/__init__.py +34 -0
  145. traceforge/preprocessors/amazonq.py +224 -0
  146. traceforge/preprocessors/antigravity.py +116 -0
  147. traceforge/preprocessors/claude.py +95 -0
  148. traceforge/preprocessors/cline.py +36 -0
  149. traceforge/preprocessors/codex.py +311 -0
  150. traceforge/preprocessors/continue_dev.py +119 -0
  151. traceforge/preprocessors/copilot_vscode.py +171 -0
  152. traceforge/preprocessors/goose.py +156 -0
  153. traceforge/preprocessors/maf_transcript.py +84 -0
  154. traceforge/preprocessors/openai_agents.py +86 -0
  155. traceforge/preprocessors/opencode.py +85 -0
  156. traceforge/preprocessors/openhands.py +36 -0
  157. traceforge/preprocessors/pydantic_ai.py +62 -0
  158. traceforge/preprocessors/registry.py +24 -0
  159. traceforge/preprocessors/smolagents.py +90 -0
  160. traceforge/py.typed +0 -0
  161. traceforge/sdk/__init__.py +59 -0
  162. traceforge/sdk/gate_policy.py +63 -0
  163. traceforge/sdk/gate_types.py +140 -0
  164. traceforge/sdk/pipeline.py +265 -0
  165. traceforge/sdk/verdict.py +81 -0
  166. traceforge/sinks/__init__.py +23 -0
  167. traceforge/sinks/base.py +95 -0
  168. traceforge/sinks/callback.py +132 -0
  169. traceforge/sinks/console.py +112 -0
  170. traceforge/sinks/factory.py +94 -0
  171. traceforge/sinks/jsonl.py +125 -0
  172. traceforge/sinks/otel_exporter.py +212 -0
  173. traceforge/sinks/parquet.py +260 -0
  174. traceforge/sinks/s3.py +206 -0
  175. traceforge/sinks/sqlite_output.py +234 -0
  176. traceforge/sinks/webhook.py +136 -0
  177. traceforge/sources/__init__.py +18 -0
  178. traceforge/sources/auto_detect.py +173 -0
  179. traceforge/sources/base.py +45 -0
  180. traceforge/sources/file_poll.py +136 -0
  181. traceforge/sources/file_watch.py +221 -0
  182. traceforge/sources/http_poll.py +141 -0
  183. traceforge/sources/replay.py +63 -0
  184. traceforge/sources/sqlite.py +187 -0
  185. traceforge/sources/sse.py +198 -0
  186. traceforge/telemetry/__init__.py +192 -0
  187. traceforge/title/__init__.py +23 -0
  188. traceforge/title/_resolve.py +79 -0
  189. traceforge/title/context.py +295 -0
  190. traceforge/title/data/boilerplate_files.json +14 -0
  191. traceforge/title/heuristics.py +429 -0
  192. traceforge/title/hygiene.py +90 -0
  193. traceforge/title/inference.py +314 -0
  194. traceforge/title/inferencer.py +477 -0
  195. traceforge/title/naming.py +398 -0
  196. traceforge/trace.py +291 -0
  197. traceforge/tracking/__init__.py +30 -0
  198. traceforge/tracking/models.py +115 -0
  199. traceforge/tracking/phase_tracker.py +288 -0
  200. traceforge/types.py +315 -0
  201. traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
  202. traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
  203. traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
  204. traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  205. traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,34 @@
1
+ """Preprocessors for normalizing framework-specific event structures.
2
+
3
+ Each preprocessor transforms raw dicts into flat dicts suitable for
4
+ type_field-based YAML mapping. They handle compound discriminators,
5
+ nested structures, and field-presence-based typing.
6
+ """
7
+
8
+ from traceforge.preprocessors.registry import (
9
+ PreprocessorFn,
10
+ get_preprocessor,
11
+ register_preprocessor,
12
+ )
13
+
14
+ # Import all preprocessor modules to trigger registration
15
+ import traceforge.preprocessors.cline # noqa: F401
16
+ import traceforge.preprocessors.continue_dev # noqa: F401
17
+ import traceforge.preprocessors.goose # noqa: F401
18
+ import traceforge.preprocessors.openhands # noqa: F401
19
+ import traceforge.preprocessors.pydantic_ai # noqa: F401
20
+ import traceforge.preprocessors.openai_agents # noqa: F401
21
+ import traceforge.preprocessors.claude # noqa: F401
22
+ import traceforge.preprocessors.smolagents # noqa: F401
23
+ import traceforge.preprocessors.codex # noqa: F401
24
+ import traceforge.preprocessors.amazonq # noqa: F401
25
+ import traceforge.preprocessors.maf_transcript # noqa: F401
26
+ import traceforge.preprocessors.opencode # noqa: F401
27
+ import traceforge.preprocessors.copilot_vscode # noqa: F401
28
+ import traceforge.preprocessors.antigravity # noqa: F401
29
+
30
+ __all__ = [
31
+ "PreprocessorFn",
32
+ "get_preprocessor",
33
+ "register_preprocessor",
34
+ ]
@@ -0,0 +1,224 @@
1
+ """Amazon Q Developer preprocessor — extract tool calls from SQLite conversations table."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from traceforge.preprocessors.registry import register_preprocessor
9
+
10
+
11
+ def _amazonq_text(content: Any) -> str:
12
+ """Flatten an Amazon Q ToolUseResultBlock list into text."""
13
+ if isinstance(content, str):
14
+ return content
15
+ if isinstance(content, list):
16
+ parts = []
17
+ for b in content:
18
+ if isinstance(b, dict):
19
+ # externally-tagged: {"Text": "..."} | {"Json": {...}}
20
+ if "Text" in b:
21
+ parts.append(str(b["Text"]))
22
+ elif "Json" in b:
23
+ parts.append(json.dumps(b["Json"]))
24
+ elif b.get("type") == "text":
25
+ parts.append(b.get("text", ""))
26
+ return " ".join(p for p in parts if p)
27
+ return ""
28
+
29
+
30
+ def _expand_amazonq_pair(entry: dict[str, Any], cid: str) -> list[dict[str, Any]]:
31
+ """Expand one HistoryEntry {user, assistant} pair into block events.
32
+
33
+ Current Amazon Q schema uses externally-tagged Rust enums:
34
+ user.content = {Prompt:{prompt}} | {ToolUseResults:{tool_use_results:[...]}}
35
+ | {CancelledToolUses:{prompt, tool_use_results}}
36
+ assistant = {Response:{content}} | {ToolUse:{content, tool_uses:[...]}}
37
+ tool_use = {id, name, args, ...}; result = {tool_use_id, content, status}
38
+ """
39
+ out: list[dict[str, Any]] = []
40
+ user = entry.get("user")
41
+ if isinstance(user, dict):
42
+ content = user.get("content", {})
43
+ prompt = None
44
+ results = None
45
+ if isinstance(content, dict):
46
+ if "Prompt" in content:
47
+ prompt = (content["Prompt"] or {}).get("prompt")
48
+ elif "CancelledToolUses" in content:
49
+ cancelled = content["CancelledToolUses"] or {}
50
+ prompt = cancelled.get("prompt")
51
+ results = cancelled.get("tool_use_results")
52
+ elif "ToolUseResults" in content:
53
+ results = (content["ToolUseResults"] or {}).get("tool_use_results")
54
+ if prompt:
55
+ out.append({"block_type": "message.user", "conversation_id": cid, "content": prompt})
56
+ for r in results or []:
57
+ if not isinstance(r, dict):
58
+ continue
59
+ out.append(
60
+ {
61
+ "block_type": "tool.result",
62
+ "conversation_id": cid,
63
+ "tool_call_id": r.get("tool_use_id", ""),
64
+ "is_error": r.get("status") == "Error",
65
+ "output": _amazonq_text(r.get("content", "")),
66
+ }
67
+ )
68
+
69
+ assistant = entry.get("assistant")
70
+ if isinstance(assistant, dict):
71
+ body = assistant.get("Response") or assistant.get("ToolUse") or {}
72
+ if isinstance(body, dict):
73
+ if body.get("content"):
74
+ out.append(
75
+ {
76
+ "block_type": "message.assistant",
77
+ "conversation_id": cid,
78
+ "content": body["content"],
79
+ }
80
+ )
81
+ for tu in body.get("tool_uses", []) or []:
82
+ if not isinstance(tu, dict):
83
+ continue
84
+ out.append(
85
+ {
86
+ "block_type": "tool.call",
87
+ "conversation_id": cid,
88
+ "tool_call_id": tu.get("id", ""),
89
+ "tool_name": tu.get("name", ""),
90
+ "arguments": tu.get("args", {}),
91
+ }
92
+ )
93
+ return out
94
+
95
+
96
+ @register_preprocessor("amazonq")
97
+ def preprocess_amazonq(obj: dict[str, Any]) -> list[dict[str, Any]]:
98
+ """Normalize an Amazon Q conversation row into tool call events.
99
+
100
+ Amazon Q Developer stores conversations in:
101
+ ~/.local/share/amazon-q/data.sqlite3 (Linux)
102
+ ~/Library/Application Support/amazon-q/data.sqlite3 (macOS)
103
+
104
+ The conversations table has a JSON `value` column containing the full
105
+ conversation structure. This preprocessor is invoked per-row after the
106
+ source reads rows from SQLite.
107
+
108
+ Expected input shape (one row from conversations table):
109
+ {
110
+ "conversation_id": "...",
111
+ "value": "{...}", # JSON string with messages array
112
+ "created_at": "...",
113
+ "updated_at": "..."
114
+ }
115
+
116
+ The JSON value structure contains messages with role/content pairs.
117
+ Tool calls are embedded in assistant messages as tool_use content blocks.
118
+ """
119
+ # If already preprocessed (has block_type but no value/data), pass through
120
+ if "block_type" in obj and "value" not in obj and "data" not in obj:
121
+ return [obj]
122
+
123
+ conversation_id = obj.get("conversation_id", obj.get("id", ""))
124
+ value_raw = obj.get("value", obj.get("data", ""))
125
+
126
+ # Parse the JSON value column
127
+ if isinstance(value_raw, str):
128
+ try:
129
+ value = json.loads(value_raw)
130
+ except (json.JSONDecodeError, TypeError):
131
+ return [
132
+ {
133
+ "block_type": "raw.parse_error",
134
+ "conversation_id": conversation_id,
135
+ "_raw": value_raw,
136
+ }
137
+ ]
138
+ elif isinstance(value_raw, dict):
139
+ value = value_raw
140
+ else:
141
+ return [obj]
142
+
143
+ messages = value.get("messages", value.get("history", []))
144
+ if not isinstance(messages, list):
145
+ return [{"block_type": "raw.no_messages", "conversation_id": conversation_id, **value}]
146
+
147
+ # Amazon Q persists conversation_id INSIDE the value blob; the SQLite `key`
148
+ # column is the working directory, not the id.
149
+ conversation_id = value.get("conversation_id", conversation_id)
150
+ results: list[dict[str, Any]] = []
151
+
152
+ # Current Amazon Q format: history is a list of {user, assistant, request_metadata}
153
+ # pairs, with externally-tagged Rust enums for content. Detect and expand.
154
+ if value.get("history") and any(
155
+ isinstance(e, dict) and ("user" in e or "assistant" in e) for e in messages
156
+ ):
157
+ for entry in messages:
158
+ if not isinstance(entry, dict):
159
+ continue
160
+ results.extend(_expand_amazonq_pair(entry, conversation_id))
161
+ return (
162
+ results
163
+ if results
164
+ else [{"block_type": "raw.empty", "conversation_id": conversation_id}]
165
+ )
166
+
167
+ for msg in messages:
168
+ if not isinstance(msg, dict):
169
+ continue
170
+ role = msg.get("role", "")
171
+ content = msg.get("content", "")
172
+
173
+ # Content can be a string or array of content blocks
174
+ if isinstance(content, str):
175
+ results.append(
176
+ {
177
+ "block_type": f"message.{role}" if role else "message.unknown",
178
+ "conversation_id": conversation_id,
179
+ "content": content,
180
+ }
181
+ )
182
+ elif isinstance(content, list):
183
+ for block in content:
184
+ if not isinstance(block, dict):
185
+ continue
186
+ block_type = block.get("type", "")
187
+
188
+ if block_type == "text":
189
+ results.append(
190
+ {
191
+ "block_type": f"message.{role}" if role else "message.unknown",
192
+ "conversation_id": conversation_id,
193
+ "content": block.get("text", ""),
194
+ }
195
+ )
196
+
197
+ elif block_type == "tool_use":
198
+ results.append(
199
+ {
200
+ "block_type": "tool.call",
201
+ "conversation_id": conversation_id,
202
+ "tool_call_id": block.get("id", ""),
203
+ "tool_name": block.get("name", ""),
204
+ "arguments": block.get("input", {}),
205
+ }
206
+ )
207
+
208
+ elif block_type == "tool_result":
209
+ result_content = block.get("content", "")
210
+ if isinstance(result_content, list):
211
+ result_content = " ".join(
212
+ b.get("text", "") for b in result_content if isinstance(b, dict)
213
+ )
214
+ results.append(
215
+ {
216
+ "block_type": "tool.result",
217
+ "conversation_id": conversation_id,
218
+ "tool_call_id": block.get("tool_use_id", ""),
219
+ "is_error": block.get("is_error", False),
220
+ "output": result_content,
221
+ }
222
+ )
223
+
224
+ return results if results else [{"block_type": "raw.empty", "conversation_id": conversation_id}]
@@ -0,0 +1,116 @@
1
+ """Google Antigravity preprocessor — replay the SDK ``conversation.history``.
2
+
3
+ The Antigravity Python SDK exposes a finished agent run as
4
+ ``agent.conversation.history``: a ``list[google.antigravity.types.Step]`` that the
5
+ capture scripts dump verbatim with ``model_dump(mode="json")``. Each JSONL line is
6
+ therefore one serialized ``Step`` with these fields (see the SDK's
7
+ ``local_connection.LocalConnectionStep.from_dict`` which constructs them):
8
+
9
+ id, step_index, type, source, target, status, content, content_delta,
10
+ thinking, thinking_delta, tool_calls[], error, is_complete_response,
11
+ structured_output, usage_metadata
12
+
13
+ There is no single native ``type_field`` because the event identity is a compound
14
+ of ``type`` (StepType) and ``source`` (StepSource) — e.g. a ``TEXT_RESPONSE`` is a
15
+ user message or an assistant message depending on its source. This preprocessor
16
+ synthesizes a flat ``event_type`` discriminator from that pair and fans a
17
+ ``TOOL_CALL`` step out into one event per entry in ``tool_calls`` (an
18
+ Antigravity step may batch several calls).
19
+
20
+ Authoritative shape note: builtin tool *output* is consumed inside the Go
21
+ localharness and fed back to the model — it is never surfaced as a result field on
22
+ a history Step. So history carries tool CALLS (with args) but not tool RESULTS;
23
+ this preprocessor intentionally emits no tool-result event.
24
+
25
+ Stateless: each Step is self-describing, so no cross-line reconstruction is needed
26
+ (unlike the copilot_vscode journal). Enum fields serialize to their string values
27
+ (``"TEXT_RESPONSE"``, ``"MODEL"``, ``"TARGET_ENVIRONMENT"``, ``"DONE"`` ...).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from typing import Any
33
+
34
+ from traceforge.preprocessors.registry import register_preprocessor
35
+
36
+ # StepType (string) -> handler. TEXT_RESPONSE is resolved by source; TOOL_CALL
37
+ # fans out; the rest are 1:1.
38
+ _TEXT = "TEXT_RESPONSE"
39
+ _THINKING = "THINKING"
40
+ _TOOL_CALL = "TOOL_CALL"
41
+ _SYSTEM = "SYSTEM_MESSAGE"
42
+ _FINISH = "FINISH"
43
+ _COMPACTION = "COMPACTION"
44
+
45
+ # StepSource (string) for a TEXT_RESPONSE -> event_type.
46
+ _TEXT_BY_SOURCE = {
47
+ "USER": "user_message",
48
+ "MODEL": "assistant_message",
49
+ "SYSTEM": "system_message",
50
+ }
51
+
52
+
53
+ def _common(obj: dict[str, Any]) -> dict[str, Any]:
54
+ return {
55
+ "step_id": obj.get("id"),
56
+ "step_index": obj.get("step_index"),
57
+ "source": obj.get("source"),
58
+ "target": obj.get("target"),
59
+ "status": obj.get("status"),
60
+ }
61
+
62
+
63
+ @register_preprocessor("antigravity")
64
+ def preprocess_antigravity(obj: dict[str, Any]) -> list[dict[str, Any]]:
65
+ """Expand one serialized Antigravity ``types.Step`` into typed flat events."""
66
+ # Passthrough for already-typed rows (generic conformance probes feed a line
67
+ # keyed by the post-preprocessor type_field, with no native Step ``type``).
68
+ if "type" not in obj and obj.get("event_type"):
69
+ return [obj]
70
+
71
+ stype = obj.get("type")
72
+ base = _common(obj)
73
+
74
+ if stype == _TEXT:
75
+ event_type = _TEXT_BY_SOURCE.get(obj.get("source"), "assistant_message")
76
+ return [{**base, "event_type": event_type, "content": obj.get("content")}]
77
+
78
+ if stype == _THINKING:
79
+ return [{**base, "event_type": "thinking", "content": obj.get("thinking")}]
80
+
81
+ if stype == _SYSTEM:
82
+ return [{**base, "event_type": "system_message", "content": obj.get("content")}]
83
+
84
+ if stype == _TOOL_CALL:
85
+ out: list[dict[str, Any]] = []
86
+ for call in obj.get("tool_calls") or []:
87
+ if not isinstance(call, dict):
88
+ continue
89
+ out.append(
90
+ {
91
+ **base,
92
+ "event_type": "tool_call",
93
+ "tool_name": call.get("name"),
94
+ "args": call.get("args"),
95
+ "tool_call_id": call.get("id"),
96
+ "path": call.get("canonical_path"),
97
+ }
98
+ )
99
+ return out
100
+
101
+ if stype == _FINISH:
102
+ return [
103
+ {
104
+ **base,
105
+ "event_type": "finish",
106
+ "content": obj.get("content"),
107
+ "structured_output": obj.get("structured_output"),
108
+ }
109
+ ]
110
+
111
+ if stype == _COMPACTION:
112
+ return [{**base, "event_type": "compaction"}]
113
+
114
+ # Unknown / UNKNOWN step types deliberately fall through (no event_type) so
115
+ # the golden 0-raw test surfaces genuine upstream drift rather than masking it.
116
+ return []
@@ -0,0 +1,95 @@
1
+ """Claude preprocessor — flatten content blocks into individual events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from traceforge.preprocessors.registry import register_preprocessor
8
+
9
+
10
+ @register_preprocessor("claude")
11
+ def preprocess_claude(obj: dict[str, Any]) -> list[dict[str, Any]]:
12
+ """Flatten Claude wire-format messages into per-block dicts.
13
+
14
+ Claude wire format:
15
+ - user: {type: "user", message: {content: "..."}}
16
+ - assistant: {type: "assistant", message: {content: [{type: "text", ...}, ...]}}
17
+ - result: {type: "result", subtype: ..., usage: {...}, ...}
18
+ - system: {type: "system", ...}
19
+
20
+ Assistant messages contain a list of content blocks, each of which
21
+ becomes a separate normalized dict with a synthesized ``block_type``
22
+ discriminator (e.g. "assistant.text", "assistant.tool_use").
23
+
24
+ User messages with string content get block_type "user.text".
25
+ Result messages pass through with block_type "result".
26
+ """
27
+ msg_type = obj.get("type")
28
+ if not msg_type:
29
+ return [obj]
30
+
31
+ if msg_type == "result":
32
+ normalized = dict(obj)
33
+ normalized["block_type"] = "result"
34
+ # Flatten usage dict to top level for dot-path access
35
+ usage = normalized.get("usage")
36
+ if isinstance(usage, dict):
37
+ for k, v in usage.items():
38
+ normalized[f"usage_{k}"] = v
39
+ return [normalized]
40
+
41
+ if msg_type == "system":
42
+ normalized = dict(obj)
43
+ normalized["block_type"] = "system"
44
+ return [normalized]
45
+
46
+ message = obj.get("message")
47
+ if not isinstance(message, dict):
48
+ return [obj]
49
+
50
+ content = message.get("content")
51
+
52
+ if msg_type == "user":
53
+ if isinstance(content, str):
54
+ return [{"block_type": "user.text", "content": content}]
55
+ if isinstance(content, list):
56
+ return _flatten_blocks(content, "user")
57
+ return [{"block_type": "user.text", "content": str(content) if content else ""}]
58
+
59
+ if msg_type == "assistant":
60
+ if isinstance(content, list):
61
+ return _flatten_blocks(content, "assistant")
62
+ return [obj]
63
+
64
+ return [obj]
65
+
66
+
67
+ def _flatten_blocks(blocks: list[Any], context: str) -> list[dict[str, Any]]:
68
+ """Flatten a list of content blocks into normalized dicts."""
69
+ results: list[dict[str, Any]] = []
70
+ for block in blocks:
71
+ if not isinstance(block, dict):
72
+ continue
73
+ block_kind = block.get("type", "unknown")
74
+ normalized = dict(block)
75
+ normalized["block_type"] = f"{context}.{block_kind}"
76
+
77
+ # For tool_result blocks, handle list-of-dicts content → joined text
78
+ if block_kind == "tool_result":
79
+ normalized["success"] = not block.get("is_error", False)
80
+ raw_content = block.get("content")
81
+ if isinstance(raw_content, list):
82
+ parts = []
83
+ for item in raw_content:
84
+ if isinstance(item, dict) and item.get("type") == "text":
85
+ parts.append(item.get("text", ""))
86
+ elif isinstance(item, str):
87
+ parts.append(item)
88
+ normalized["content_text"] = "\n".join(parts) if parts else None
89
+ elif isinstance(raw_content, str):
90
+ normalized["content_text"] = raw_content
91
+ else:
92
+ normalized["content_text"] = None
93
+
94
+ results.append(normalized)
95
+ return results
@@ -0,0 +1,36 @@
1
+ """Cline preprocessor — compound type + say/ask subtype discriminator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from traceforge.preprocessors.registry import register_preprocessor
9
+
10
+
11
+ @register_preprocessor("cline")
12
+ def preprocess_cline(obj: dict[str, Any]) -> list[dict[str, Any]]:
13
+ """Synthesize compound type from Cline's type + say/ask subtype.
14
+
15
+ Cline events have type="ask"|"say" with the subtype in the
16
+ corresponding field. Synthesizes "say.api_req_started" etc.
17
+ Parses JSON text field for structured subtypes into top-level fields.
18
+ """
19
+ msg_type = obj.get("type") # "ask" or "say"
20
+ subtype = obj.get(msg_type) if msg_type in ("ask", "say") else None
21
+
22
+ if subtype:
23
+ normalized = dict(obj)
24
+ normalized["type"] = f"{msg_type}.{subtype}"
25
+
26
+ # Parse JSON text field for known subtypes that embed structured data
27
+ text = normalized.get("text")
28
+ if text and subtype in ("api_req_started", "api_req_finished", "tool"):
29
+ try:
30
+ parsed = json.loads(text)
31
+ if isinstance(parsed, dict):
32
+ normalized["parsed"] = parsed
33
+ except (json.JSONDecodeError, ValueError):
34
+ pass
35
+ return [normalized]
36
+ return [obj]