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,416 @@
1
+ """AiderPreParser — converts Aider .aider.chat.history.md into event dicts.
2
+
3
+ Aider writes session logs as append-only markdown. This parser uses
4
+ tree-sitter-markdown to build an AST and tree-sitter queries to extract
5
+ structural blocks, then emits structured dicts suitable for feeding into
6
+ MappedJsonAdapter with the aider_markdown.yaml mapping.
7
+
8
+ Format authority: Aider-AI/aider:aider/io.py
9
+
10
+ Query-based AST classification:
11
+ atx_heading (h1) "aider chat started at …" → session.started
12
+ atx_heading (h4) → user message / slash command
13
+ block_quote (single >) → tool/system output (sub-classified)
14
+ paragraph / setext_heading / other → AI response content
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from collections.abc import Iterator
21
+ from dataclasses import dataclass, field
22
+ from datetime import datetime, timedelta, timezone
23
+ from enum import Enum
24
+ from typing import Any
25
+
26
+ import tree_sitter as ts
27
+
28
+ from traceforge.parsers.base import (
29
+ MD_LANGUAGE,
30
+ Block,
31
+ MarkdownPreParser,
32
+ node_text,
33
+ strip_blockquote_markers,
34
+ )
35
+
36
+ # ─── Tree-sitter queries ─────────────────────────────────────────────────────
37
+
38
+ _BLOCK_QUERY = ts.Query(
39
+ MD_LANGUAGE,
40
+ """
41
+ (atx_heading (atx_h1_marker) (inline) @h1_text) @h1
42
+ (atx_heading (atx_h4_marker) (inline) @h4_text) @h4
43
+ (block_quote) @bq
44
+ (paragraph) @para
45
+ (setext_heading) @setext
46
+ (fenced_code_block) @fenced
47
+ (list) @list_block
48
+ """,
49
+ )
50
+
51
+ _ROLE_SESSION = 0
52
+ _ROLE_USER = 1
53
+ _ROLE_BQ = 2
54
+ _ROLE_PARA = 3
55
+ _ROLE_SETEXT = 4
56
+ _ROLE_FENCED = 5
57
+ _ROLE_LIST = 6
58
+
59
+ _STRUCTURAL_PARENTS = frozenset({"section", "document"})
60
+ _REPLACE_FOOTER_RE = re.compile(r"^>{7}\s*REPLACE\s*$")
61
+
62
+ # ─── Block roles ─────────────────────────────────────────────────────────────
63
+
64
+ _BLOCK_SESSION = "session"
65
+ _BLOCK_USER = "user"
66
+ _BLOCK_TOOL = "tool"
67
+ _BLOCK_AI = "ai"
68
+
69
+
70
+ # ─── Tool output sub-classification ──────────────────────────────────────────
71
+
72
+
73
+ class ToolOutputKind(Enum):
74
+ VERSION = "version"
75
+ MODEL = "model"
76
+ REPO_INFO = "repo_info"
77
+ USAGE = "usage"
78
+ FILE_EDIT_APPLIED = "file_edit_applied"
79
+ GIT_COMMIT = "git_commit"
80
+ FILE_ADD_PROMPT = "file_add_prompt"
81
+ ERROR = "error"
82
+ REPO_MAP = "repo_map"
83
+ GENERIC = "generic"
84
+
85
+
86
+ @dataclass
87
+ class ToolOutputResult:
88
+ kind: ToolOutputKind
89
+ fields: dict[str, Any] = field(default_factory=dict)
90
+
91
+
92
+ _TOOL_PATTERNS: list[tuple[re.Pattern[str], ToolOutputKind, list[str]]] = [
93
+ (re.compile(r"^Aider v([\d.]+)"), ToolOutputKind.VERSION, ["version"]),
94
+ (
95
+ re.compile(r"^Model:\s+(.+?)(?:\s+with\s+(.+)\s+edit format)?$"),
96
+ ToolOutputKind.MODEL,
97
+ ["model", "edit_format"],
98
+ ),
99
+ (
100
+ re.compile(r"^Git repo:\s+(.+?)\s+with\s+(\d+)\s+files"),
101
+ ToolOutputKind.REPO_INFO,
102
+ ["repo_path", "file_count"],
103
+ ),
104
+ (
105
+ re.compile(r"^Tokens:\s+(.+?)\s+sent,\s+(.+?)\s+received"),
106
+ ToolOutputKind.USAGE,
107
+ ["tokens_sent", "tokens_received"],
108
+ ),
109
+ (re.compile(r"^Applied edit to\s+(.+)$"), ToolOutputKind.FILE_EDIT_APPLIED, ["file_path"]),
110
+ (
111
+ re.compile(r"^Commit\s+([a-f0-9]+)\s+(.+)$"),
112
+ ToolOutputKind.GIT_COMMIT,
113
+ ["commit_sha", "commit_message"],
114
+ ),
115
+ (
116
+ re.compile(r"^(.+)\s+Add (?:file|these files) to the chat\?"),
117
+ ToolOutputKind.FILE_ADD_PROMPT,
118
+ ["file_path"],
119
+ ),
120
+ (re.compile(r"^Add\s+(.+?)\s+to the chat\?"), ToolOutputKind.FILE_ADD_PROMPT, ["file_path"]),
121
+ (re.compile(r"(?:Error|Exception|Traceback|litellm\.)"), ToolOutputKind.ERROR, []),
122
+ (re.compile(r"^Repo-map:\s+(.+)$"), ToolOutputKind.REPO_MAP, ["repo_map_info"]),
123
+ ]
124
+
125
+
126
+ def classify_tool_output(text: str) -> ToolOutputResult:
127
+ """Sub-classify a tool output line into a specific kind with extracted fields."""
128
+ for pattern, kind, field_names in _TOOL_PATTERNS:
129
+ m = pattern.search(text)
130
+ if m:
131
+ fields = {}
132
+ for i, name in enumerate(field_names):
133
+ val = m.group(i + 1) if i + 1 <= len(m.groups()) else None
134
+ if val is not None:
135
+ fields[name] = val
136
+ return ToolOutputResult(kind=kind, fields=fields)
137
+ return ToolOutputResult(kind=ToolOutputKind.GENERIC, fields={"text": text})
138
+
139
+
140
+ # ─── SEARCH/REPLACE block extraction ────────────────────────────────────────
141
+
142
+ _SEARCH_REPLACE_RE = re.compile(
143
+ r"^([^\n]+)\n"
144
+ r"<<<<<<< SEARCH\n"
145
+ r"(.*?)\n"
146
+ r"=======\n"
147
+ r"(.*?)\n"
148
+ r">>>>>>> REPLACE",
149
+ re.MULTILINE | re.DOTALL,
150
+ )
151
+
152
+
153
+ @dataclass
154
+ class FileEdit:
155
+ file_path: str
156
+ search: str
157
+ replace: str
158
+
159
+
160
+ def extract_edits(ai_text: str) -> list[FileEdit]:
161
+ """Extract SEARCH/REPLACE file edit blocks from AI response text."""
162
+ edits: list[FileEdit] = []
163
+ for m in _SEARCH_REPLACE_RE.finditer(ai_text):
164
+ file_path = m.group(1).strip()
165
+ if file_path.startswith("```"):
166
+ continue
167
+ edits.append(
168
+ FileEdit(
169
+ file_path=file_path,
170
+ search=m.group(2),
171
+ replace=m.group(3),
172
+ )
173
+ )
174
+ return edits
175
+
176
+
177
+ # ─── Helpers ─────────────────────────────────────────────────────────────────
178
+
179
+
180
+ def _is_replace_footer(node: ts.Node, source: bytes) -> bool:
181
+ """True if this block_quote is a >>>>>>> REPLACE footer."""
182
+ first_line = node_text(node, source).split("\n", 1)[0]
183
+ return bool(_REPLACE_FOOTER_RE.match(first_line))
184
+
185
+
186
+ # ─── Session state ───────────────────────────────────────────────────────────
187
+
188
+
189
+ @dataclass
190
+ class _SessionState:
191
+ """Tracks state within a single aider session."""
192
+
193
+ session_id: str
194
+ start_time: datetime
195
+ sequence: int = 0
196
+ model: str | None = None
197
+ edit_format: str | None = None
198
+
199
+ def next_timestamp(self) -> datetime:
200
+ self.sequence += 1
201
+ return self.start_time + timedelta(seconds=self.sequence)
202
+
203
+
204
+ # ─── Main parser ─────────────────────────────────────────────────────────────
205
+
206
+
207
+ class AiderPreParser(MarkdownPreParser):
208
+ """Converts Aider .aider.chat.history.md into structured event dicts.
209
+
210
+ Uses a tree-sitter query to capture all structural blocks in one pass,
211
+ then sorts them by byte position and emits events in document order.
212
+
213
+ Supports:
214
+ - Full file parsing (``parse_file`` / ``parse_text``)
215
+ - Incremental chunk parsing (``parse_chunk``) for live file-watching
216
+ """
217
+
218
+ def __init__(self, offset: int = 0) -> None:
219
+ super().__init__()
220
+ self._offset = offset
221
+ self._state: _SessionState | None = None
222
+
223
+ # ─── Base class hooks ────────────────────────────────────────────────
224
+
225
+ def _get_query(self) -> ts.Query:
226
+ return _BLOCK_QUERY
227
+
228
+ def _reset_state(self) -> None:
229
+ self._state = None
230
+
231
+ def _classify_match(
232
+ self, pattern_index: int, captures: dict[str, list[ts.Node]], source: bytes
233
+ ) -> list[Block]:
234
+ blocks: list[Block] = []
235
+
236
+ if pattern_index == _ROLE_SESSION:
237
+ heading_nodes = captures.get("h1", [])
238
+ text_nodes = captures.get("h1_text", [])
239
+ if heading_nodes and text_nodes:
240
+ blocks.append(
241
+ Block(
242
+ role=_BLOCK_SESSION,
243
+ byte_pos=heading_nodes[0].start_byte,
244
+ text=node_text(text_nodes[0], source).strip(),
245
+ )
246
+ )
247
+
248
+ elif pattern_index == _ROLE_USER:
249
+ heading_nodes = captures.get("h4", [])
250
+ text_nodes = captures.get("h4_text", [])
251
+ if heading_nodes and text_nodes:
252
+ blocks.append(
253
+ Block(
254
+ role=_BLOCK_USER,
255
+ byte_pos=heading_nodes[0].start_byte,
256
+ text=node_text(text_nodes[0], source).strip(),
257
+ )
258
+ )
259
+
260
+ elif pattern_index == _ROLE_BQ:
261
+ bq_nodes = captures.get("bq", [])
262
+ for node in bq_nodes:
263
+ if node.parent and node.parent.type not in _STRUCTURAL_PARENTS:
264
+ continue
265
+ if _is_replace_footer(node, source):
266
+ blocks.append(Block(role=_BLOCK_AI, byte_pos=node.start_byte, node=node))
267
+ else:
268
+ blocks.append(Block(role=_BLOCK_TOOL, byte_pos=node.start_byte, node=node))
269
+
270
+ elif pattern_index in (_ROLE_PARA, _ROLE_SETEXT, _ROLE_FENCED, _ROLE_LIST):
271
+ cap_name = {
272
+ _ROLE_PARA: "para",
273
+ _ROLE_SETEXT: "setext",
274
+ _ROLE_FENCED: "fenced",
275
+ _ROLE_LIST: "list_block",
276
+ }[pattern_index]
277
+ content_nodes = captures.get(cap_name, [])
278
+ for node in content_nodes:
279
+ if node.parent and node.parent.type not in _STRUCTURAL_PARENTS:
280
+ continue
281
+ blocks.append(Block(role=_BLOCK_AI, byte_pos=node.start_byte, node=node))
282
+
283
+ return blocks
284
+
285
+ def _process_blocks(self, blocks: list[Block], source: bytes) -> Iterator[dict[str, Any]]:
286
+ """Group contiguous AI nodes, emit events in document order."""
287
+ ai_nodes: list[ts.Node] = []
288
+
289
+ for block in blocks:
290
+ if block.role == _BLOCK_AI:
291
+ if block.node:
292
+ ai_nodes.append(block.node)
293
+ continue
294
+
295
+ if ai_nodes:
296
+ yield from self._emit_ai_from_nodes(ai_nodes, source)
297
+ ai_nodes = []
298
+
299
+ if block.role == _BLOCK_SESSION:
300
+ yield from self._start_session(block.text)
301
+ elif block.role == _BLOCK_USER:
302
+ yield from self._emit_user_input(block.text)
303
+ elif block.role == _BLOCK_TOOL:
304
+ if block.node:
305
+ yield from self._handle_block_quote(block.node, source)
306
+
307
+ if ai_nodes:
308
+ yield from self._emit_ai_from_nodes(ai_nodes, source)
309
+
310
+ # ─── Event emitters ──────────────────────────────────────────────────
311
+
312
+ def _start_session(self, content: str) -> Iterator[dict[str, Any]]:
313
+ prefix = "aider chat started at"
314
+ if content.startswith(prefix):
315
+ datetime_str = content[len(prefix) :].strip()
316
+ else:
317
+ datetime_str = content
318
+
319
+ try:
320
+ dt = datetime.strptime(datetime_str, "%Y-%m-%d %H:%M:%S")
321
+ dt = dt.replace(tzinfo=timezone.utc)
322
+ except ValueError:
323
+ dt = datetime.now(timezone.utc)
324
+
325
+ session_id = f"aider-{dt.strftime('%Y%m%dT%H%M%S')}"
326
+ self._state = _SessionState(session_id=session_id, start_time=dt)
327
+
328
+ yield self._make_event(
329
+ "session_start",
330
+ {"session_id": session_id, "started_at": dt.isoformat()},
331
+ )
332
+
333
+ def _emit_user_input(self, content: str) -> Iterator[dict[str, Any]]:
334
+ if content.startswith("/"):
335
+ parts = content.split(None, 1)
336
+ yield self._make_event(
337
+ "slash_command",
338
+ {"command": parts[0], "args": parts[1] if len(parts) > 1 else ""},
339
+ )
340
+ else:
341
+ yield self._make_event("user_message", {"content": content})
342
+
343
+ def _handle_block_quote(self, node: ts.Node, source: bytes) -> Iterator[dict[str, Any]]:
344
+ raw = node_text(node, source)
345
+ lines = strip_blockquote_markers(raw)
346
+ for line_text in lines:
347
+ if not line_text.strip():
348
+ continue
349
+ result = classify_tool_output(line_text)
350
+ yield from self._tool_result_to_event(result, line_text)
351
+
352
+ def _emit_ai_from_nodes(self, nodes: list[ts.Node], source: bytes) -> Iterator[dict[str, Any]]:
353
+ """Reconstruct AI response text from contiguous AST nodes."""
354
+ start = nodes[0].start_byte
355
+ end = nodes[-1].end_byte
356
+ content = source[start:end].decode("utf-8", errors="replace").strip()
357
+ if not content:
358
+ return
359
+
360
+ yield self._make_event("assistant_message", {"content": content})
361
+
362
+ edits = extract_edits(content)
363
+ for edit in edits:
364
+ yield self._make_event(
365
+ "file_edit",
366
+ {
367
+ "file_path": edit.file_path,
368
+ "search": edit.search,
369
+ "replace": edit.replace,
370
+ },
371
+ )
372
+
373
+ def _tool_result_to_event(
374
+ self, result: ToolOutputResult, raw_text: str
375
+ ) -> Iterator[dict[str, Any]]:
376
+ match result.kind:
377
+ case ToolOutputKind.VERSION:
378
+ yield self._make_event("version_info", result.fields)
379
+ case ToolOutputKind.MODEL:
380
+ if self._state and result.fields.get("model"):
381
+ self._state.model = result.fields["model"]
382
+ self._state.edit_format = result.fields.get("edit_format")
383
+ yield self._make_event("model_info", result.fields)
384
+ case ToolOutputKind.REPO_INFO:
385
+ yield self._make_event("repo_info", result.fields)
386
+ case ToolOutputKind.USAGE:
387
+ yield self._make_event("token_usage", result.fields)
388
+ case ToolOutputKind.FILE_EDIT_APPLIED:
389
+ yield self._make_event("file_edit_applied", result.fields)
390
+ case ToolOutputKind.GIT_COMMIT:
391
+ yield self._make_event("git_commit", result.fields)
392
+ case ToolOutputKind.FILE_ADD_PROMPT:
393
+ yield self._make_event("file_add", result.fields)
394
+ case ToolOutputKind.ERROR:
395
+ yield self._make_event("error", {"message": raw_text})
396
+ case ToolOutputKind.REPO_MAP:
397
+ yield self._make_event("repo_map", result.fields)
398
+ case ToolOutputKind.GENERIC:
399
+ yield self._make_event("tool_output", {"text": raw_text})
400
+
401
+ def _make_event(self, event_type: str, payload: dict[str, Any]) -> dict[str, Any]:
402
+ state = self._state
403
+ timestamp = state.next_timestamp() if state else datetime.now(timezone.utc)
404
+
405
+ event: dict[str, Any] = {
406
+ "type": event_type,
407
+ "timestamp": timestamp.isoformat(),
408
+ }
409
+
410
+ if state:
411
+ event["session_id"] = state.session_id
412
+ if state.model:
413
+ event["model"] = state.model
414
+
415
+ event.update(payload)
416
+ return event
@@ -0,0 +1,226 @@
1
+ """Base class and shared utilities for tree-sitter-based pre-parsers.
2
+
3
+ All pre-parsers that convert non-structured agent logs into event dicts
4
+ should inherit from MarkdownPreParser and use the shared utilities here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from abc import ABC, abstractmethod
11
+ from collections.abc import Iterator
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import tree_sitter as ts
18
+ import tree_sitter_markdown as tsmd
19
+
20
+ # ─── Shared tree-sitter setup ────────────────────────────────────────────────
21
+
22
+ MD_LANGUAGE = ts.Language(tsmd.language())
23
+
24
+
25
+ # ─── Shared block dataclass ──────────────────────────────────────────────────
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class Block:
30
+ """A classified structural block extracted from tree-sitter AST.
31
+
32
+ Concrete parsers populate this with their own role semantics.
33
+ """
34
+
35
+ role: str
36
+ byte_pos: int
37
+ text: str = ""
38
+ lang: str = ""
39
+ level: int = 0
40
+ node: ts.Node | None = None
41
+
42
+
43
+ # ─── Shared helpers ──────────────────────────────────────────────────────────
44
+
45
+
46
+ def node_text(node: ts.Node, source: bytes) -> str:
47
+ """Extract UTF-8 text from a tree-sitter node."""
48
+ return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
49
+
50
+
51
+ def try_parse_json(text: str) -> dict[str, Any] | list[Any] | None:
52
+ """Attempt to parse JSON from text, return None on failure."""
53
+ text = text.strip()
54
+ if not text or text[0] not in ("{", "["):
55
+ return None
56
+ try:
57
+ return json.loads(text)
58
+ except (json.JSONDecodeError, ValueError):
59
+ return None
60
+
61
+
62
+ def strip_blockquote_markers(raw: str) -> list[str]:
63
+ """Strip leading '> ' or '>' from each line of a block quote."""
64
+ lines: list[str] = []
65
+ for line in raw.splitlines():
66
+ if line.startswith("> "):
67
+ lines.append(line[2:])
68
+ elif line.startswith(">"):
69
+ lines.append(line[1:])
70
+ else:
71
+ lines.append(line)
72
+ return lines
73
+
74
+
75
+ # ─── Abstract base class ─────────────────────────────────────────────────────
76
+
77
+
78
+ class MarkdownPreParser(ABC):
79
+ """Base class for tree-sitter markdown pre-parsers.
80
+
81
+ Provides:
82
+ - Shared tree-sitter parser initialization
83
+ - parse_file() / parse_text() / parse_chunk() orchestration
84
+ - Block extraction and sorting boilerplate
85
+ - Event construction helpers
86
+
87
+ Subclasses implement:
88
+ - _build_query(): return the tree-sitter Query for this parser
89
+ - _classify_match(): convert a query match into Block(s)
90
+ - _process_blocks(): convert sorted blocks into event dicts
91
+ """
92
+
93
+ def __init__(self) -> None:
94
+ self._ts_parser = ts.Parser(MD_LANGUAGE)
95
+ self._offset: int = 0
96
+ self._accumulated: str = ""
97
+ self._emitted_count: int = 0
98
+
99
+ @property
100
+ def current_offset(self) -> int:
101
+ """Current byte offset of accumulated content (informational)."""
102
+ return self._offset
103
+
104
+ # ─── Public API ──────────────────────────────────────────────────────
105
+
106
+ def parse_file(self, path: str | Path) -> Iterator[dict[str, Any]]:
107
+ """Parse an entire file and yield event dicts."""
108
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
109
+ yield from self.parse_text(text)
110
+
111
+ def parse_text(self, text: str) -> Iterator[dict[str, Any]]:
112
+ """Parse full text and yield event dicts. Resets internal state."""
113
+ self._offset = 0
114
+ self._accumulated = ""
115
+ self._emitted_count = 0
116
+ self._reset_state()
117
+
118
+ source = text.encode("utf-8")
119
+ tree = self._ts_parser.parse(source)
120
+ yield from self._process_tree(tree, source)
121
+ self._offset = len(source)
122
+
123
+ def parse_chunk(self, chunk: str) -> Iterator[dict[str, Any]]:
124
+ """Parse an incremental chunk (for live file-watching).
125
+
126
+ Accumulates text, re-parses the full tree with tree-sitter,
127
+ and emits only events beyond what was previously emitted.
128
+ The last event is held back until the next chunk confirms
129
+ it is structurally closed. Call ``flush()`` when the stream
130
+ ends to emit any held-back final event.
131
+ """
132
+ self._accumulated += chunk
133
+ source = self._accumulated.encode("utf-8")
134
+ tree = self._ts_parser.parse(source)
135
+
136
+ self._reset_state()
137
+ all_events = list(self._process_tree(tree, source))
138
+
139
+ safe_end = max(len(all_events) - 1, 0)
140
+ new_events = all_events[self._emitted_count : safe_end]
141
+ self._emitted_count = safe_end
142
+ self._offset = len(source)
143
+
144
+ yield from new_events
145
+
146
+ def flush(self) -> Iterator[dict[str, Any]]:
147
+ """Emit any held-back final event from incremental parsing.
148
+
149
+ Call this when the stream/file is complete to ensure no events
150
+ are lost. Safe to call multiple times (idempotent after first).
151
+ """
152
+ if not self._accumulated:
153
+ return
154
+
155
+ source = self._accumulated.encode("utf-8")
156
+ tree = self._ts_parser.parse(source)
157
+
158
+ self._reset_state()
159
+ all_events = list(self._process_tree(tree, source))
160
+
161
+ remaining = all_events[self._emitted_count :]
162
+ self._emitted_count = len(all_events)
163
+
164
+ yield from remaining
165
+
166
+ # ─── Template methods for subclasses ─────────────────────────────────
167
+
168
+ @abstractmethod
169
+ def _get_query(self) -> ts.Query:
170
+ """Return the tree-sitter Query used by this parser."""
171
+
172
+ @abstractmethod
173
+ def _classify_match(
174
+ self, pattern_index: int, captures: dict[str, list[ts.Node]], source: bytes
175
+ ) -> list[Block]:
176
+ """Convert a single query match into zero or more Blocks."""
177
+
178
+ @abstractmethod
179
+ def _process_blocks(self, blocks: list[Block], source: bytes) -> Iterator[dict[str, Any]]:
180
+ """Convert sorted blocks into event dicts."""
181
+
182
+ def _reset_state(self) -> None:
183
+ """Reset any parser-specific state. Override if needed."""
184
+
185
+ # ─── Shared infrastructure ───────────────────────────────────────────
186
+
187
+ def _process_tree(self, tree: ts.Tree, source: bytes) -> Iterator[dict[str, Any]]:
188
+ """Extract blocks via query, sort by position, then process."""
189
+ blocks = self._extract_blocks(tree, source)
190
+ yield from self._process_blocks(blocks, source)
191
+
192
+ def _extract_blocks(self, tree: ts.Tree, source: bytes) -> list[Block]:
193
+ """Run the query and classify all matches into sorted blocks."""
194
+ query = self._get_query()
195
+ cursor = ts.QueryCursor(query)
196
+ blocks: list[Block] = []
197
+
198
+ for pat_idx, captures in cursor.matches(tree.root_node):
199
+ blocks.extend(self._classify_match(pat_idx, captures, source))
200
+
201
+ blocks.sort(key=lambda b: b.byte_pos)
202
+ return blocks
203
+
204
+ # ─── Event construction helper ───────────────────────────────────────
205
+
206
+ @staticmethod
207
+ def make_event(
208
+ event_type: str,
209
+ payload: dict[str, Any],
210
+ *,
211
+ timestamp: datetime | str | None = None,
212
+ session_id: str | None = None,
213
+ ) -> dict[str, Any]:
214
+ """Construct a standard event dict."""
215
+ if timestamp is None:
216
+ ts_str = datetime.now(timezone.utc).isoformat()
217
+ elif isinstance(timestamp, datetime):
218
+ ts_str = timestamp.isoformat()
219
+ else:
220
+ ts_str = timestamp
221
+
222
+ event: dict[str, Any] = {"type": event_type, "timestamp": ts_str}
223
+ if session_id:
224
+ event["session_id"] = session_id
225
+ event.update(payload)
226
+ return event