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,311 @@
1
+ """Codex CLI preprocessor — normalize JSONL rollout lines into tool call events."""
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("codex")
12
+ def preprocess_codex(obj: dict[str, Any]) -> list[dict[str, Any]]:
13
+ """Normalize a Codex CLI rollout line into traceforge-friendly dicts.
14
+
15
+ Codex CLI JSONL format (~/.codex/sessions/*.jsonl):
16
+ Each line: {"timestamp": "...", "type": "...", "payload": {...}}
17
+
18
+ Type discriminator values:
19
+ - "session_meta" — session metadata (first line)
20
+ - "response_item" — model outputs (function_call, function_call_output, message)
21
+ - "event_msg" — lifecycle events (exec_command_begin/end, mcp_tool_call_begin/end)
22
+
23
+ We flatten the double-type nesting into a single `block_type` field:
24
+ - response_item.function_call → "tool.shell_call"
25
+ - response_item.function_call_output → "tool.shell_result"
26
+ - event_msg.exec_command_begin → "tool.exec_begin"
27
+ - event_msg.exec_command_end → "tool.exec_end"
28
+ - event_msg.mcp_tool_call_begin → "tool.mcp_call"
29
+ - event_msg.mcp_tool_call_end → "tool.mcp_result"
30
+ - session_meta → "session.meta"
31
+ - response_item.message (role=assistant) → "message.assistant"
32
+ - event_msg.user_message → "message.user"
33
+ """
34
+ top_type = obj.get("type", "")
35
+ payload = obj.get("payload") or {}
36
+ timestamp = obj.get("timestamp", "")
37
+
38
+ # If already preprocessed (has block_type but no rollout structure), pass through
39
+ if "block_type" in obj and not top_type:
40
+ return [obj]
41
+
42
+ if not isinstance(payload, dict):
43
+ return [obj]
44
+
45
+ base = {"_timestamp": timestamp}
46
+
47
+ if top_type == "session_meta":
48
+ return [
49
+ {
50
+ **base,
51
+ "block_type": "session.meta",
52
+ "session_id": payload.get("id", ""),
53
+ "model_provider": payload.get("model_provider", ""),
54
+ "cwd": payload.get("cwd", ""),
55
+ "cli_version": payload.get("cli_version", ""),
56
+ }
57
+ ]
58
+
59
+ if top_type == "response_item":
60
+ return _handle_response_item(payload, base)
61
+
62
+ if top_type == "event_msg":
63
+ return _handle_event_msg(payload, base)
64
+
65
+ if top_type == "turn_context":
66
+ return []
67
+
68
+ # Pass through unknown types
69
+ return [{**base, "block_type": f"raw.{top_type}", **payload}]
70
+
71
+
72
+ def _handle_response_item(payload: dict[str, Any], base: dict[str, Any]) -> list[dict[str, Any]]:
73
+ """Handle response_item lines (model output)."""
74
+ item_type = payload.get("type", "")
75
+
76
+ if item_type == "function_call":
77
+ # Shell/built-in tool call from model
78
+ arguments_raw = payload.get("arguments", "{}")
79
+ # arguments is a JSON-encoded string for function_call
80
+ try:
81
+ arguments = (
82
+ json.loads(arguments_raw) if isinstance(arguments_raw, str) else arguments_raw
83
+ )
84
+ except (json.JSONDecodeError, TypeError):
85
+ arguments = {"_raw": arguments_raw}
86
+
87
+ return [
88
+ {
89
+ **base,
90
+ "block_type": "tool.shell_call",
91
+ "call_id": payload.get("call_id", ""),
92
+ "tool_name": payload.get("name", "shell"),
93
+ "arguments": arguments,
94
+ "namespace": payload.get("namespace"),
95
+ }
96
+ ]
97
+
98
+ if item_type == "function_call_output":
99
+ output = payload.get("output", "")
100
+ return [
101
+ {
102
+ **base,
103
+ "block_type": "tool.shell_result",
104
+ "call_id": payload.get("call_id", ""),
105
+ "output": output,
106
+ }
107
+ ]
108
+
109
+ if item_type == "message":
110
+ role = payload.get("role", "")
111
+ content_blocks = payload.get("content", [])
112
+ if role == "developer":
113
+ return []
114
+
115
+ text = ""
116
+ if isinstance(content_blocks, list):
117
+ text = " ".join(
118
+ b.get("text", "")
119
+ for b in content_blocks
120
+ if isinstance(b, dict) and b.get("type") in {"output_text", "input_text"}
121
+ )
122
+ elif isinstance(content_blocks, str):
123
+ text = content_blocks
124
+ return [
125
+ {
126
+ **base,
127
+ "block_type": f"message.{role}" if role else "message.unknown",
128
+ "content": text,
129
+ }
130
+ ]
131
+
132
+ if item_type == "reasoning":
133
+ return []
134
+
135
+ # Other response items (e.g. local_shell_call, custom_tool_call)
136
+ if item_type == "local_shell_call":
137
+ action = payload.get("action", {})
138
+ return [
139
+ {
140
+ **base,
141
+ "block_type": "tool.shell_call",
142
+ "call_id": payload.get("call_id", ""),
143
+ "tool_name": "shell",
144
+ "arguments": action if isinstance(action, dict) else {"_raw": action},
145
+ }
146
+ ]
147
+
148
+ if item_type == "custom_tool_call":
149
+ input_raw = payload.get("input", "{}")
150
+ try:
151
+ arguments = json.loads(input_raw) if isinstance(input_raw, str) else input_raw
152
+ except (json.JSONDecodeError, TypeError):
153
+ arguments = {"_raw": input_raw}
154
+ return [
155
+ {
156
+ **base,
157
+ "block_type": "tool.custom_call",
158
+ "call_id": payload.get("call_id", ""),
159
+ "tool_name": payload.get("name", ""),
160
+ "arguments": arguments,
161
+ }
162
+ ]
163
+
164
+ if item_type == "custom_tool_call_output":
165
+ return [
166
+ {
167
+ **base,
168
+ "block_type": "tool.custom_result",
169
+ "call_id": payload.get("call_id", ""),
170
+ "tool_name": payload.get("name", ""),
171
+ "output": payload.get("output", ""),
172
+ }
173
+ ]
174
+
175
+ return [
176
+ {
177
+ **base,
178
+ "block_type": f"response.{item_type}",
179
+ **{k: v for k, v in payload.items() if k != "type"},
180
+ }
181
+ ]
182
+
183
+
184
+ def _handle_event_msg(payload: dict[str, Any], base: dict[str, Any]) -> list[dict[str, Any]]:
185
+ """Handle event_msg lines (lifecycle events)."""
186
+ event_type = payload.get("type", "")
187
+
188
+ if event_type == "exec_command_begin":
189
+ return [
190
+ {
191
+ **base,
192
+ "block_type": "tool.exec_begin",
193
+ "call_id": payload.get("call_id", ""),
194
+ "command": payload.get("command", []),
195
+ "cwd": payload.get("cwd", ""),
196
+ }
197
+ ]
198
+
199
+ if event_type == "exec_command_end":
200
+ return [
201
+ {
202
+ **base,
203
+ "block_type": "tool.exec_end",
204
+ "call_id": payload.get("call_id", ""),
205
+ "command": payload.get("command", []),
206
+ "cwd": payload.get("cwd", ""),
207
+ "exit_code": payload.get("exit_code"),
208
+ "stdout": payload.get("stdout", ""),
209
+ "stderr": payload.get("stderr", ""),
210
+ "status": payload.get("status", ""),
211
+ }
212
+ ]
213
+
214
+ if event_type == "patch_apply_begin":
215
+ return [
216
+ {
217
+ **base,
218
+ "block_type": "tool.exec_begin",
219
+ "call_id": payload.get("call_id", ""),
220
+ "command": "apply_patch",
221
+ "cwd": payload.get("cwd", ""),
222
+ }
223
+ ]
224
+
225
+ if event_type == "patch_apply_end":
226
+ return [
227
+ {
228
+ **base,
229
+ "block_type": "tool.exec_end",
230
+ "call_id": payload.get("call_id", ""),
231
+ "command": "apply_patch",
232
+ "cwd": payload.get("cwd", ""),
233
+ "exit_code": 0 if payload.get("success") else 1,
234
+ "stdout": payload.get("stdout", ""),
235
+ "stderr": payload.get("stderr", ""),
236
+ "status": payload.get("status", ""),
237
+ "changes": payload.get("changes", {}),
238
+ }
239
+ ]
240
+
241
+ if event_type == "mcp_tool_call_begin":
242
+ invocation = payload.get("invocation") or {}
243
+ return [
244
+ {
245
+ **base,
246
+ "block_type": "tool.mcp_call",
247
+ "call_id": payload.get("call_id", ""),
248
+ "tool_name": invocation.get("tool", ""),
249
+ "server": invocation.get("server", ""),
250
+ "arguments": invocation.get("arguments"),
251
+ }
252
+ ]
253
+
254
+ if event_type == "mcp_tool_call_end":
255
+ invocation = payload.get("invocation") or {}
256
+ result = payload.get("result", {})
257
+ # Result is {"Ok": {...}} or {"Err": "..."}
258
+ ok_val = result.get("Ok", {}) if isinstance(result, dict) else {}
259
+ err_val = result.get("Err") if isinstance(result, dict) else None
260
+ return [
261
+ {
262
+ **base,
263
+ "block_type": "tool.mcp_result",
264
+ "call_id": payload.get("call_id", ""),
265
+ "tool_name": invocation.get("tool", ""),
266
+ "server": invocation.get("server", ""),
267
+ "is_error": err_val is not None
268
+ or (isinstance(ok_val, dict) and ok_val.get("is_error", False)),
269
+ "output": err_val if err_val else ok_val,
270
+ }
271
+ ]
272
+
273
+ if event_type == "exec_approval_request":
274
+ return [
275
+ {
276
+ **base,
277
+ "block_type": "tool.approval_request",
278
+ "call_id": payload.get("call_id", ""),
279
+ "command": payload.get("command", ""),
280
+ }
281
+ ]
282
+
283
+ if event_type == "user_message":
284
+ return [
285
+ {
286
+ **base,
287
+ "block_type": "message.user",
288
+ "content": payload.get("message", ""),
289
+ }
290
+ ]
291
+
292
+ if event_type == "agent_message":
293
+ return [
294
+ {
295
+ **base,
296
+ "block_type": "message.assistant",
297
+ "content": payload.get("message", ""),
298
+ }
299
+ ]
300
+
301
+ if event_type in {"task_started", "task_complete", "token_count"}:
302
+ return []
303
+
304
+ # Pass through other event types
305
+ return [
306
+ {
307
+ **base,
308
+ "block_type": f"event.{event_type}",
309
+ **{k: v for k, v in payload.items() if k != "type"},
310
+ }
311
+ ]
@@ -0,0 +1,119 @@
1
+ """Continue.dev preprocessor — flatten session JSON history into per-message events."""
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("continue")
12
+ def preprocess_continue(obj: dict[str, Any]) -> list[dict[str, Any]]:
13
+ """Flatten Continue.dev session JSON into per-message dicts.
14
+
15
+ Continue.dev session format (~/.continue/sessions/{id}.json):
16
+ {
17
+ "sessionId": "...",
18
+ "title": "...",
19
+ "history": [
20
+ {"message": {"role": "user", "content": "..."}},
21
+ {"message": {"role": "assistant", "content": "...", "toolCalls": [...]}},
22
+ {"message": {"role": "tool", "toolCallId": "...", "content": "..."}}
23
+ ]
24
+ }
25
+
26
+ Note: Continue.dev persists camelCase keys (toolCalls, toolCallId) — its
27
+ internal TypeScript ChatMessage types are written to disk via JSON.stringify
28
+ with no rename. snake_case (tool_calls/tool_call_id) only appears on the
29
+ OpenAI provider wire format, not in the session file on disk.
30
+
31
+ Each history entry becomes a normalized dict with:
32
+ - block_type: "user.message", "assistant.message", "assistant.tool_use", "tool.result"
33
+ - Flattened message fields
34
+ """
35
+ history = obj.get("history")
36
+ if not isinstance(history, list):
37
+ return [obj]
38
+
39
+ session_id = obj.get("sessionId", "")
40
+ results: list[dict[str, Any]] = []
41
+
42
+ for entry in history:
43
+ message = entry.get("message") if isinstance(entry, dict) else None
44
+ if not isinstance(message, dict):
45
+ continue
46
+
47
+ role = message.get("role", "")
48
+ content = message.get("content")
49
+ # Continue.dev persists session JSON with camelCase keys (TypeScript
50
+ # JSON.stringify of its internal ChatMessage types). snake_case keys
51
+ # only appear on the OpenAI provider wire format, never on disk.
52
+ tool_calls = message.get("toolCalls")
53
+
54
+ if role == "user":
55
+ results.append(
56
+ {
57
+ "block_type": "user.message",
58
+ "session_id": session_id,
59
+ "content": content,
60
+ }
61
+ )
62
+
63
+ elif role == "assistant":
64
+ # Assistant messages may contain both text and tool_calls
65
+ if content:
66
+ results.append(
67
+ {
68
+ "block_type": "assistant.message",
69
+ "session_id": session_id,
70
+ "content": content,
71
+ }
72
+ )
73
+
74
+ if isinstance(tool_calls, list):
75
+ for tc in tool_calls:
76
+ if not isinstance(tc, dict):
77
+ continue
78
+ func = tc.get("function", {})
79
+ args_raw = func.get("arguments", "{}") if isinstance(func, dict) else "{}"
80
+ # Parse arguments string to dict for consistency with other preprocessors
81
+ if isinstance(args_raw, str):
82
+ try:
83
+ arguments = json.loads(args_raw)
84
+ except (json.JSONDecodeError, ValueError):
85
+ arguments = {"_raw": args_raw}
86
+ else:
87
+ arguments = args_raw if isinstance(args_raw, dict) else {}
88
+ results.append(
89
+ {
90
+ "block_type": "assistant.tool_use",
91
+ "session_id": session_id,
92
+ "tool_call_id": tc.get("id", ""),
93
+ "tool_name": func.get("name", "") if isinstance(func, dict) else "",
94
+ "arguments": arguments,
95
+ }
96
+ )
97
+
98
+ elif role == "thinking":
99
+ # Claude extended thinking / OpenAI reasoning tokens persisted on disk
100
+ # as role "thinking" (ThinkingChatMessage). Without this they are dropped.
101
+ results.append(
102
+ {
103
+ "block_type": "assistant.thinking",
104
+ "session_id": session_id,
105
+ "content": content,
106
+ }
107
+ )
108
+
109
+ elif role == "tool":
110
+ results.append(
111
+ {
112
+ "block_type": "tool.result",
113
+ "session_id": session_id,
114
+ "tool_call_id": message.get("toolCallId", ""),
115
+ "content": content,
116
+ }
117
+ )
118
+
119
+ return results if results else [obj]
@@ -0,0 +1,171 @@
1
+ """VS Code Copilot Chat preprocessor — replay the ChatModel journal.
2
+
3
+ VS Code persists each Copilot Chat session as a line-delimited journal at
4
+ ``workspaceStorage/<hash>/chatSessions/<sessionId>.jsonl`` (ChatModel version 3).
5
+ Each physical line is one journal record:
6
+
7
+ * ``{"kind": 0, "v": {...}}`` — full snapshot (session start)
8
+ * ``{"kind": 1, "k": [path...], "v": x}`` — *set* value ``x`` at ``path``
9
+ * ``{"kind": 2, "k": [path...], "v": [...]}`` — *append* (list-extend) at ``path``
10
+
11
+ Paths mix dict keys and integer array indices, e.g.
12
+ ``["requests", 2, "response"]``. The interesting state lives under ``requests[]``:
13
+ each request carries the user ``message``, a streamed ``response`` part list
14
+ (``thinking`` / ``toolInvocationSerialized`` / markdown text / file refs), and a
15
+ terminal ``result`` with timings.
16
+
17
+ Because the adapter feeds records one physical line at a time, this preprocessor
18
+ keeps a small amount of module-level state (a mirror of the ``requests`` index ->
19
+ metadata map) so streamed response parts can be attributed to their originating
20
+ request. State is reset whenever a snapshot (``kind == 0``) is seen, which is the
21
+ first record of every session file, so state never bleeds across sessions.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Any
27
+
28
+ from traceforge.preprocessors.registry import register_preprocessor
29
+
30
+ # Per-session reconstruction state (reset on every snapshot record).
31
+ _REQ_IDS: dict[int, str | None] = {}
32
+ _REQ_MODELS: dict[int, Any] = {}
33
+ _REQ_TS: dict[int, Any] = {}
34
+ _REQ_COUNT = [0] # boxed int so helpers can mutate it
35
+
36
+
37
+ def _reset() -> None:
38
+ _REQ_IDS.clear()
39
+ _REQ_MODELS.clear()
40
+ _REQ_TS.clear()
41
+ _REQ_COUNT[0] = 0
42
+
43
+
44
+ def _agent_id(req: dict[str, Any]) -> Any:
45
+ agent = req.get("agent")
46
+ if isinstance(agent, dict):
47
+ return agent.get("id") or agent.get("name")
48
+ return agent
49
+
50
+
51
+ def _emit_part(part: Any, idx: int) -> dict[str, Any] | None:
52
+ """Turn one streamed ``response`` part into a flat, typed dict."""
53
+ if not isinstance(part, dict):
54
+ return None
55
+ flat = dict(part)
56
+ # Markdown content parts have no ``kind`` discriminator; everything else
57
+ # (thinking / toolInvocationSerialized / inlineReference / ...) does.
58
+ flat["event_type"] = part.get("kind") or "assistant_text"
59
+ flat["request_id"] = _REQ_IDS.get(idx)
60
+ flat["model"] = _REQ_MODELS.get(idx)
61
+ flat["timestamp"] = _REQ_TS.get(idx)
62
+ return flat
63
+
64
+
65
+ def _emit_request(req: dict[str, Any], idx: int) -> list[dict[str, Any]]:
66
+ """Register a request at ``idx`` and emit its user message + any inline parts."""
67
+ _REQ_IDS[idx] = req.get("requestId")
68
+ _REQ_MODELS[idx] = req.get("modelId")
69
+ _REQ_TS[idx] = req.get("timestamp")
70
+
71
+ message = req.get("message")
72
+ text = message.get("text") if isinstance(message, dict) else message
73
+
74
+ out: list[dict[str, Any]] = [
75
+ {
76
+ "event_type": "user_message",
77
+ "request_id": req.get("requestId"),
78
+ "model": req.get("modelId"),
79
+ "agent": _agent_id(req),
80
+ "text": text,
81
+ "timestamp": req.get("timestamp"),
82
+ }
83
+ ]
84
+ # A compacted journal may carry the full response/result inline on the
85
+ # request itself; a streamed one appends them later. Emit whatever is here.
86
+ for part in req.get("response") or []:
87
+ emitted = _emit_part(part, idx)
88
+ if emitted is not None:
89
+ out.append(emitted)
90
+ result = req.get("result")
91
+ if isinstance(result, dict):
92
+ out.append(_emit_result(result, idx))
93
+ return out
94
+
95
+
96
+ def _emit_result(result: dict[str, Any], idx: int) -> dict[str, Any]:
97
+ timings = result.get("timings") if isinstance(result.get("timings"), dict) else {}
98
+ return {
99
+ "event_type": "request_result",
100
+ "request_id": _REQ_IDS.get(idx),
101
+ "model": _REQ_MODELS.get(idx),
102
+ "first_progress_ms": timings.get("firstProgress"),
103
+ "total_elapsed_ms": timings.get("totalElapsed"),
104
+ "timestamp": _REQ_TS.get(idx),
105
+ }
106
+
107
+
108
+ @register_preprocessor("copilot_vscode")
109
+ def preprocess_copilot_vscode(obj: dict[str, Any]) -> list[dict[str, Any]]:
110
+ """Expand one VS Code ChatModel journal record into typed flat events."""
111
+ # Passthrough for already-typed rows (generic conformance probes feed a line
112
+ # keyed by the post-preprocessor type_field, with no journal ``kind``).
113
+ if "kind" not in obj and obj.get("event_type"):
114
+ return [obj]
115
+
116
+ kind = obj.get("kind")
117
+
118
+ # ── Snapshot: session start (+ any requests already present) ──────────────
119
+ if kind == 0:
120
+ _reset()
121
+ snap = obj.get("v") if isinstance(obj.get("v"), dict) else {}
122
+ out: list[dict[str, Any]] = [
123
+ {
124
+ "event_type": "session_started",
125
+ "session_id": snap.get("sessionId"),
126
+ "version": snap.get("version"),
127
+ "responder": snap.get("responderUsername"),
128
+ "initial_location": snap.get("initialLocation"),
129
+ "timestamp": snap.get("creationDate"),
130
+ }
131
+ ]
132
+ for req in snap.get("requests") or []:
133
+ if isinstance(req, dict):
134
+ out.extend(_emit_request(req, _REQ_COUNT[0]))
135
+ _REQ_COUNT[0] += 1
136
+ return out
137
+
138
+ path = obj.get("k")
139
+ value = obj.get("v")
140
+ if not isinstance(path, list) or not path:
141
+ return []
142
+
143
+ # ── Append (list-extend) records ──────────────────────────────────────────
144
+ if kind == 2:
145
+ # New requests appended to the top-level requests array.
146
+ if path == ["requests"]:
147
+ out = []
148
+ for req in value or []:
149
+ if isinstance(req, dict):
150
+ out.extend(_emit_request(req, _REQ_COUNT[0]))
151
+ _REQ_COUNT[0] += 1
152
+ return out
153
+ # Streamed response parts appended to requests[i].response.
154
+ if len(path) == 3 and path[0] == "requests" and path[2] == "response":
155
+ idx = path[1]
156
+ out = []
157
+ for part in value or []:
158
+ emitted = _emit_part(part, idx)
159
+ if emitted is not None:
160
+ out.append(emitted)
161
+ return out
162
+ return []
163
+
164
+ # ── Set records: only the terminal per-request result is interesting ──────
165
+ if kind == 1:
166
+ if len(path) == 3 and path[0] == "requests" and path[2] == "result":
167
+ if isinstance(value, dict):
168
+ return [_emit_result(value, path[1])]
169
+ return []
170
+
171
+ return []