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.
- traceforge/__init__.py +72 -0
- traceforge/__main__.py +5 -0
- traceforge/_generated.py +254 -0
- traceforge/adapters/__init__.py +12 -0
- traceforge/adapters/base.py +82 -0
- traceforge/adapters/genai_otel.py +164 -0
- traceforge/adapters/mapped_json.py +297 -0
- traceforge/adapters/otel.py +220 -0
- traceforge/boundary/__init__.py +46 -0
- traceforge/boundary/data/boundary-model.joblib +0 -0
- traceforge/boundary/decode.py +87 -0
- traceforge/boundary/features.py +137 -0
- traceforge/boundary/inference.py +267 -0
- traceforge/boundary/inferencer.py +146 -0
- traceforge/classify/__init__.py +95 -0
- traceforge/classify/cmd.py +109 -0
- traceforge/classify/coding.py +213 -0
- traceforge/classify/config.py +554 -0
- traceforge/classify/core.py +266 -0
- traceforge/classify/data/binary_info.yaml +358 -0
- traceforge/classify/data/canonical_tools.yaml +251 -0
- traceforge/classify/data/effect_overrides.yaml +480 -0
- traceforge/classify/data/mcp_profiles.yaml +711 -0
- traceforge/classify/data/recommendation_rules.yaml +111 -0
- traceforge/classify/data/risk.yaml +534 -0
- traceforge/classify/data/shell_defaults.yaml +95 -0
- traceforge/classify/data/shell_rules.yaml +1192 -0
- traceforge/classify/data/tool_classifications.yaml +215 -0
- traceforge/classify/data/verb_inference.yaml +218 -0
- traceforge/classify/mcp.py +181 -0
- traceforge/classify/phases.py +75 -0
- traceforge/classify/powershell.py +93 -0
- traceforge/classify/registry.py +138 -0
- traceforge/classify/risk.py +553 -0
- traceforge/classify/rules.py +215 -0
- traceforge/classify/schema.yaml +282 -0
- traceforge/classify/shell.py +503 -0
- traceforge/classify/tools.py +91 -0
- traceforge/classify/workflow.py +32 -0
- traceforge/cli/__init__.py +42 -0
- traceforge/cli/config_cmd.py +130 -0
- traceforge/cli/detect.py +46 -0
- traceforge/cli/download_cmd.py +74 -0
- traceforge/cli/factory.py +60 -0
- traceforge/cli/gate_cmd.py +30 -0
- traceforge/cli/init_cmd.py +86 -0
- traceforge/cli/replay.py +130 -0
- traceforge/cli/runner.py +181 -0
- traceforge/cli/score.py +217 -0
- traceforge/cli/status.py +65 -0
- traceforge/cli/watch.py +297 -0
- traceforge/config/__init__.py +80 -0
- traceforge/config/defaults.py +149 -0
- traceforge/config/loader.py +239 -0
- traceforge/config/mappings.py +104 -0
- traceforge/config/models.py +579 -0
- traceforge/enricher.py +576 -0
- traceforge/formatting/__init__.py +12 -0
- traceforge/formatting/budget.py +92 -0
- traceforge/formatting/density.py +154 -0
- traceforge/gate/__init__.py +17 -0
- traceforge/gate/client.py +145 -0
- traceforge/gate/external.py +305 -0
- traceforge/gate/registry.py +108 -0
- traceforge/gate/server.py +174 -0
- traceforge/gates/__init__.py +8 -0
- traceforge/gates/pii.py +352 -0
- traceforge/gates/pii_patterns.yaml +228 -0
- traceforge/governance/__init__.py +121 -0
- traceforge/governance/assessor.py +441 -0
- traceforge/governance/budget.py +59 -0
- traceforge/governance/canonical.py +56 -0
- traceforge/governance/codec.py +414 -0
- traceforge/governance/context.py +249 -0
- traceforge/governance/drift.py +196 -0
- traceforge/governance/emitter.py +234 -0
- traceforge/governance/envelope.py +138 -0
- traceforge/governance/ifc.py +364 -0
- traceforge/governance/integrity.py +162 -0
- traceforge/governance/labeler.py +250 -0
- traceforge/governance/mcp_drift.py +328 -0
- traceforge/governance/monitor.py +539 -0
- traceforge/governance/observer.py +276 -0
- traceforge/governance/persistence.py +401 -0
- traceforge/governance/phase1.py +77 -0
- traceforge/governance/pii.py +276 -0
- traceforge/governance/pipeline.py +840 -0
- traceforge/governance/registry.py +110 -0
- traceforge/governance/results.py +183 -0
- traceforge/governance/risk_wrapper.py +113 -0
- traceforge/governance/rules.py +368 -0
- traceforge/governance/scorer.py +262 -0
- traceforge/governance/shield.py +318 -0
- traceforge/governance/state.py +466 -0
- traceforge/governance/types.py +176 -0
- traceforge/mappings/__init__.py +1 -0
- traceforge/mappings/aider.yaml +216 -0
- traceforge/mappings/aider_markdown.yaml +113 -0
- traceforge/mappings/amazonq.yaml +56 -0
- traceforge/mappings/antigravity.yaml +88 -0
- traceforge/mappings/claude.yaml +93 -0
- traceforge/mappings/cline.yaml +286 -0
- traceforge/mappings/codex.yaml +158 -0
- traceforge/mappings/continue_dev.yaml +57 -0
- traceforge/mappings/copilot.yaml +266 -0
- traceforge/mappings/copilot_markdown.yaml +72 -0
- traceforge/mappings/copilot_vscode.yaml +181 -0
- traceforge/mappings/crewai.yaml +592 -0
- traceforge/mappings/goose.yaml +128 -0
- traceforge/mappings/langgraph.yaml +165 -0
- traceforge/mappings/maf.yaml +90 -0
- traceforge/mappings/maf_transcript.yaml +99 -0
- traceforge/mappings/openai_agents.yaml +144 -0
- traceforge/mappings/opencode.yaml +473 -0
- traceforge/mappings/openhands.yaml +320 -0
- traceforge/mappings/pydantic_ai.yaml +183 -0
- traceforge/mappings/smolagents.yaml +89 -0
- traceforge/mappings/sweagent.yaml +82 -0
- traceforge/migrations/__init__.py +1 -0
- traceforge/migrations/env.py +60 -0
- traceforge/migrations/models.py +145 -0
- traceforge/migrations/runner.py +44 -0
- traceforge/migrations/script.py.mako +25 -0
- traceforge/migrations/versions/0001_initial.py +176 -0
- traceforge/migrations/versions/__init__.py +4 -0
- traceforge/models.py +29 -0
- traceforge/parsers/__init__.py +21 -0
- traceforge/parsers/aider.py +416 -0
- traceforge/parsers/base.py +226 -0
- traceforge/parsers/copilot.py +314 -0
- traceforge/phase/__init__.py +50 -0
- traceforge/phase/data/phase-model.joblib +0 -0
- traceforge/phase/data/potion-base-8M/README.md +99 -0
- traceforge/phase/data/potion-base-8M/config.json +13 -0
- traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
- traceforge/phase/data/potion-base-8M/modules.json +14 -0
- traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
- traceforge/phase/event_rows.py +107 -0
- traceforge/phase/features.py +468 -0
- traceforge/phase/inference.py +279 -0
- traceforge/phase/inferencer.py +171 -0
- traceforge/phase/segmentation.py +258 -0
- traceforge/pipeline.py +891 -0
- traceforge/preprocessors/__init__.py +34 -0
- traceforge/preprocessors/amazonq.py +224 -0
- traceforge/preprocessors/antigravity.py +116 -0
- traceforge/preprocessors/claude.py +95 -0
- traceforge/preprocessors/cline.py +36 -0
- traceforge/preprocessors/codex.py +311 -0
- traceforge/preprocessors/continue_dev.py +119 -0
- traceforge/preprocessors/copilot_vscode.py +171 -0
- traceforge/preprocessors/goose.py +156 -0
- traceforge/preprocessors/maf_transcript.py +84 -0
- traceforge/preprocessors/openai_agents.py +86 -0
- traceforge/preprocessors/opencode.py +85 -0
- traceforge/preprocessors/openhands.py +36 -0
- traceforge/preprocessors/pydantic_ai.py +62 -0
- traceforge/preprocessors/registry.py +24 -0
- traceforge/preprocessors/smolagents.py +90 -0
- traceforge/py.typed +0 -0
- traceforge/sdk/__init__.py +59 -0
- traceforge/sdk/gate_policy.py +63 -0
- traceforge/sdk/gate_types.py +140 -0
- traceforge/sdk/pipeline.py +265 -0
- traceforge/sdk/verdict.py +81 -0
- traceforge/sinks/__init__.py +23 -0
- traceforge/sinks/base.py +95 -0
- traceforge/sinks/callback.py +132 -0
- traceforge/sinks/console.py +112 -0
- traceforge/sinks/factory.py +94 -0
- traceforge/sinks/jsonl.py +125 -0
- traceforge/sinks/otel_exporter.py +212 -0
- traceforge/sinks/parquet.py +260 -0
- traceforge/sinks/s3.py +206 -0
- traceforge/sinks/sqlite_output.py +234 -0
- traceforge/sinks/webhook.py +136 -0
- traceforge/sources/__init__.py +18 -0
- traceforge/sources/auto_detect.py +173 -0
- traceforge/sources/base.py +45 -0
- traceforge/sources/file_poll.py +136 -0
- traceforge/sources/file_watch.py +221 -0
- traceforge/sources/http_poll.py +141 -0
- traceforge/sources/replay.py +63 -0
- traceforge/sources/sqlite.py +187 -0
- traceforge/sources/sse.py +198 -0
- traceforge/telemetry/__init__.py +192 -0
- traceforge/title/__init__.py +23 -0
- traceforge/title/_resolve.py +79 -0
- traceforge/title/context.py +295 -0
- traceforge/title/data/boilerplate_files.json +14 -0
- traceforge/title/heuristics.py +429 -0
- traceforge/title/hygiene.py +90 -0
- traceforge/title/inference.py +314 -0
- traceforge/title/inferencer.py +477 -0
- traceforge/title/naming.py +398 -0
- traceforge/trace.py +291 -0
- traceforge/tracking/__init__.py +30 -0
- traceforge/tracking/models.py +115 -0
- traceforge/tracking/phase_tracker.py +288 -0
- traceforge/types.py +315 -0
- traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
- traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
- traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
- traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|