synapse-cli-agent 0.1.13__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.
- synapse/__init__.py +13 -0
- synapse/__main__.py +6 -0
- synapse/app/__init__.py +1 -0
- synapse/app/agent.py +492 -0
- synapse/app/agent_md.py +107 -0
- synapse/cli.py +750 -0
- synapse/commands/__init__.py +1 -0
- synapse/commands/compression.py +573 -0
- synapse/commands/helpers.py +22 -0
- synapse/commands/mcp.py +406 -0
- synapse/commands/model.py +173 -0
- synapse/commands/result.py +34 -0
- synapse/commands/sessions.py +443 -0
- synapse/commands/slash_cmds.py +521 -0
- synapse/commands/slash_complete.py +816 -0
- synapse/commands/theme.py +99 -0
- synapse/config.py +27 -0
- synapse/content/__init__.py +1 -0
- synapse/content/input_history.py +122 -0
- synapse/content/multimodal.py +733 -0
- synapse/content/prompts.py +249 -0
- synapse/content/skills_catalog.py +128 -0
- synapse/integrations/__init__.py +1 -0
- synapse/integrations/checkpoint_seed.py +281 -0
- synapse/integrations/codex_history.py +375 -0
- synapse/integrations/codex_import.py +393 -0
- synapse/integrations/codex_sessions.py +629 -0
- synapse/integrations/describe_image.py +370 -0
- synapse/integrations/http_clients.py +199 -0
- synapse/integrations/llm_openai_compat.py +90 -0
- synapse/integrations/llm_openai_websocket.py +187 -0
- synapse/integrations/mcp_client.py +646 -0
- synapse/integrations/vision_middleware.py +62 -0
- synapse/models/__init__.py +5 -0
- synapse/models/config.py +240 -0
- synapse/models/helpers.py +206 -0
- synapse/models/profile.py +59 -0
- synapse/models/registry.py +722 -0
- synapse/models_registry.py +7 -0
- synapse/observability/__init__.py +1 -0
- synapse/observability/startup_trace.py +127 -0
- synapse/runtime/__init__.py +1 -0
- synapse/runtime/async_runtime.py +176 -0
- synapse/runtime/backends.py +458 -0
- synapse/runtime/context_compact.py +249 -0
- synapse/runtime/execute_capture.py +48 -0
- synapse/runtime/fs_permissions.py +79 -0
- synapse/runtime/harness.py +57 -0
- synapse/runtime/hitl.py +197 -0
- synapse/runtime/interaction_ledger.py +82 -0
- synapse/runtime/middleware.py +802 -0
- synapse/runtime/model_request_compression_middleware.py +745 -0
- synapse/runtime/pathing.py +146 -0
- synapse/runtime/safety.py +184 -0
- synapse/runtime/steer.py +240 -0
- synapse/runtime/subagents.py +207 -0
- synapse/runtime/tool_ignore.py +221 -0
- synapse/runtime/tool_output_eval.py +118 -0
- synapse/runtime/tool_output_middleware.py +585 -0
- synapse/runtime/tool_output_usage_middleware.py +60 -0
- synapse/sessions/__init__.py +31 -0
- synapse/sessions/cancel_repair.py +208 -0
- synapse/sessions/session_recap.py +174 -0
- synapse/sessions/store.py +695 -0
- synapse/sessions/transcript.py +754 -0
- synapse/settings/__init__.py +5 -0
- synapse/settings/config_paths.py +184 -0
- synapse/settings/schema.py +464 -0
- synapse/tool_output/__init__.py +59 -0
- synapse/tool_output/detection.py +170 -0
- synapse/tool_output/metrics.py +32 -0
- synapse/tool_output/models.py +173 -0
- synapse/tool_output/pipeline.py +330 -0
- synapse/tool_output/repository.py +721 -0
- synapse/tool_output/transformers.py +648 -0
- synapse/tools/__init__.py +5 -0
- synapse/tools/session_tools.py +204 -0
- synapse/ui/__init__.py +10 -0
- synapse/ui/bottombar/__init__.py +73 -0
- synapse/ui/bottombar/components/__init__.py +143 -0
- synapse/ui/bottombar/components/key_hints.py +30 -0
- synapse/ui/bottombar/components/mcp.py +64 -0
- synapse/ui/bottombar/components/mode.py +24 -0
- synapse/ui/bottombar/components/model.py +28 -0
- synapse/ui/bottombar/components/thread.py +29 -0
- synapse/ui/bottombar/context.py +36 -0
- synapse/ui/bottombar/core.py +74 -0
- synapse/ui/dialogs/__init__.py +25 -0
- synapse/ui/dialogs/base.py +362 -0
- synapse/ui/dialogs/codex_session_list.py +84 -0
- synapse/ui/dialogs/compression_diagnostics.py +210 -0
- synapse/ui/dialogs/git_explore.py +702 -0
- synapse/ui/dialogs/mcp_panel.py +407 -0
- synapse/ui/dialogs/model_picker.py +128 -0
- synapse/ui/dialogs/safety_panel.py +63 -0
- synapse/ui/dialogs/session_list.py +98 -0
- synapse/ui/dialogs/theme_designer.py +863 -0
- synapse/ui/dialogs/theme_picker.py +113 -0
- synapse/ui/git_explore/__init__.py +31 -0
- synapse/ui/git_explore/engine.py +82 -0
- synapse/ui/git_explore/provider.py +242 -0
- synapse/ui/git_explore/unified.py +85 -0
- synapse/ui/rendering.py +350 -0
- synapse/ui/sink.py +70 -0
- synapse/ui/steer_widget.py +367 -0
- synapse/ui/stream.py +1207 -0
- synapse/ui/stream_events.py +421 -0
- synapse/ui/stream_runtime.py +252 -0
- synapse/ui/theme.py +1154 -0
- synapse/ui/timeline.py +621 -0
- synapse/ui/topbar/__init__.py +97 -0
- synapse/ui/topbar/components/__init__.py +150 -0
- synapse/ui/topbar/components/branch.py +41 -0
- synapse/ui/topbar/components/title.py +24 -0
- synapse/ui/topbar/components/tool_output.py +24 -0
- synapse/ui/topbar/components/usage.py +24 -0
- synapse/ui/topbar/components/workspace.py +32 -0
- synapse/ui/topbar/context.py +32 -0
- synapse/ui/topbar/core.py +979 -0
- synapse/ui/topbar/git_changes_popover.py +178 -0
- synapse/ui/topbar/git_chrome.py +475 -0
- synapse/ui/topbar/tool_output_popover.py +84 -0
- synapse/ui/topbar/widget.py +474 -0
- synapse/ui/tui.py +5717 -0
- synapse/ui/turn_rail.py +71 -0
- synapse/ui/user_turn.py +83 -0
- synapse/ui/welcome.py +261 -0
- synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
- synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
- synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
- synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
"""Deterministic and optional native tool-output transformers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import importlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
from synapse.tool_output.detection import _ERROR_LINE, _LOG_SUMMARY, _SEARCH_LINE
|
|
10
|
+
from synapse.tool_output.models import ContentType, TransformContext, TransformResult
|
|
11
|
+
|
|
12
|
+
_WARNING_LINE = re.compile(r"\b(warn|warning|todo|fixme)\b", re.I)
|
|
13
|
+
_STACK_LINE = re.compile(r"(^\s+at\s+|^\s*File .+, line \d+|^\s*\d+\s*\||^\s*-->\s+)")
|
|
14
|
+
_NUMBER_OR_PATH = re.compile(r"\b\d+\b|(?:[A-Za-z]:)?[/\\][\w./\\-]+")
|
|
15
|
+
_TOKEN = re.compile(r"[\w.-]+", re.UNICODE)
|
|
16
|
+
|
|
17
|
+
class ToolOutputTransformer(Protocol):
|
|
18
|
+
name: str
|
|
19
|
+
|
|
20
|
+
def transform(self, content: str, context: TransformContext) -> TransformResult: ...
|
|
21
|
+
|
|
22
|
+
def _critical_lines(content: str, content_type: ContentType) -> list[str]:
|
|
23
|
+
lines = content.splitlines()
|
|
24
|
+
if content_type is ContentType.LOG:
|
|
25
|
+
return [
|
|
26
|
+
line
|
|
27
|
+
for line in lines
|
|
28
|
+
if _ERROR_LINE.search(line) or re.search(r"\b[A-Za-z_]+(?:Error|Exception):", line)
|
|
29
|
+
]
|
|
30
|
+
if content_type is ContentType.DIFF:
|
|
31
|
+
return [
|
|
32
|
+
line
|
|
33
|
+
for line in lines
|
|
34
|
+
if line.startswith(("+", "-")) and not line.startswith(("+++", "---"))
|
|
35
|
+
]
|
|
36
|
+
if content_type is ContentType.GIT_SUMMARY:
|
|
37
|
+
return [
|
|
38
|
+
line
|
|
39
|
+
for line in lines
|
|
40
|
+
if re.match(
|
|
41
|
+
r"^(?:Merge made by the .+ strategy\.|\s*\d+ files? changed(?:,|$)|"
|
|
42
|
+
r"\s*\d+ insertions?\(\+\)|\s*\d+ deletions?\(-\))",
|
|
43
|
+
line,
|
|
44
|
+
)
|
|
45
|
+
]
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _result(
|
|
50
|
+
content: str,
|
|
51
|
+
body: str,
|
|
52
|
+
*,
|
|
53
|
+
name: str,
|
|
54
|
+
content_type: ContentType,
|
|
55
|
+
metadata: dict[str, Any] | None = None,
|
|
56
|
+
) -> TransformResult:
|
|
57
|
+
critical = _critical_lines(content, content_type)
|
|
58
|
+
return TransformResult(
|
|
59
|
+
body,
|
|
60
|
+
name,
|
|
61
|
+
content_type,
|
|
62
|
+
len(critical),
|
|
63
|
+
sum(item in body for item in critical),
|
|
64
|
+
metadata or {},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SearchTransformer:
|
|
69
|
+
"""Headroom-inspired search parsing, scoring, and bounded selection."""
|
|
70
|
+
|
|
71
|
+
name = "search-v1"
|
|
72
|
+
content_types = frozenset({ContentType.SEARCH})
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self, *, max_files: int = 15, max_matches_per_file: int = 5, max_total_matches: int = 30
|
|
76
|
+
) -> None:
|
|
77
|
+
self.max_files = max_files
|
|
78
|
+
self.max_matches_per_file = max_matches_per_file
|
|
79
|
+
self.max_total_matches = max_total_matches
|
|
80
|
+
|
|
81
|
+
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
|
82
|
+
grouped: dict[str, list[tuple[int, str]]] = {}
|
|
83
|
+
for raw in content.splitlines():
|
|
84
|
+
match = _SEARCH_LINE.match(raw)
|
|
85
|
+
if match:
|
|
86
|
+
grouped.setdefault(match.group("path"), []).append((int(match.group("line")), raw))
|
|
87
|
+
if not grouped:
|
|
88
|
+
return TransformResult(content, self.name, ContentType.SEARCH, 0, 0)
|
|
89
|
+
terms = {term.casefold() for term in _TOKEN.findall(context.query) if len(term) > 2}
|
|
90
|
+
|
|
91
|
+
def score(raw: str) -> float:
|
|
92
|
+
value = sum(0.3 for term in terms if term in raw.casefold())
|
|
93
|
+
if _ERROR_LINE.search(raw):
|
|
94
|
+
value += 0.5
|
|
95
|
+
elif _WARNING_LINE.search(raw):
|
|
96
|
+
value += 0.3
|
|
97
|
+
return min(1.0, value)
|
|
98
|
+
|
|
99
|
+
files = sorted(
|
|
100
|
+
grouped.items(), key=lambda item: sum(score(raw) for _, raw in item[1]), reverse=True
|
|
101
|
+
)[: self.max_files]
|
|
102
|
+
selected: list[tuple[str, int, str]] = []
|
|
103
|
+
summaries: list[str] = []
|
|
104
|
+
for path, matches in files:
|
|
105
|
+
if len(selected) >= self.max_total_matches:
|
|
106
|
+
summaries.append(f"{path}: {len(matches)} matches omitted")
|
|
107
|
+
continue
|
|
108
|
+
ranked = sorted(matches, key=lambda item: score(item[1]), reverse=True)
|
|
109
|
+
keep = {matches[0], matches[-1], *ranked[: self.max_matches_per_file]}
|
|
110
|
+
shown = sorted(keep)[: self.max_total_matches - len(selected)]
|
|
111
|
+
selected.extend((path, line, raw) for line, raw in shown)
|
|
112
|
+
if len(shown) < len(matches):
|
|
113
|
+
summaries.append(f"{path}: {len(matches) - len(shown)} matches omitted")
|
|
114
|
+
selected.sort(key=lambda item: (item[0], item[1]))
|
|
115
|
+
omitted = sum(int(item.split(": ")[-1].split()[0]) for item in summaries)
|
|
116
|
+
body = "\n".join(
|
|
117
|
+
[
|
|
118
|
+
f"[search results compressed: {len(grouped)} files, {omitted} matches omitted]",
|
|
119
|
+
*(raw for _, _, raw in selected),
|
|
120
|
+
*summaries,
|
|
121
|
+
]
|
|
122
|
+
)
|
|
123
|
+
return _result(
|
|
124
|
+
content,
|
|
125
|
+
body,
|
|
126
|
+
name=self.name,
|
|
127
|
+
content_type=ContentType.SEARCH,
|
|
128
|
+
metadata={
|
|
129
|
+
"files": len(grouped),
|
|
130
|
+
"omitted_matches": omitted,
|
|
131
|
+
"file_summaries": summaries,
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class PathListTransformer:
|
|
137
|
+
"""Losslessly fold repeated parent directories in plain file-path listings.
|
|
138
|
+
|
|
139
|
+
This follows Headroom's reversible ``path_heading`` approach. It only accepts
|
|
140
|
+
every-line path listings and verifies that expansion exactly restores input.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
name = "path-list-v1"
|
|
144
|
+
content_types = frozenset({ContentType.PATHS})
|
|
145
|
+
_path_row = re.compile(
|
|
146
|
+
r"^(?P<directory>(?:(?:[A-Za-z]:)?[/\\]|\.{0,2}[/\\])?"
|
|
147
|
+
r"(?:[^/\\\s:]+[/\\])+)(?P<base>[^/\\\s:]+)$"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def _expand(cls, lines: list[str]) -> list[str] | None:
|
|
152
|
+
restored: list[str] = []
|
|
153
|
+
directory: str | None = None
|
|
154
|
+
for line in lines:
|
|
155
|
+
if line.endswith("/") or line.endswith("\\"):
|
|
156
|
+
directory = line
|
|
157
|
+
elif directory is not None and "/" not in line and "\\" not in line:
|
|
158
|
+
restored.append(directory + line)
|
|
159
|
+
else:
|
|
160
|
+
return None
|
|
161
|
+
return restored
|
|
162
|
+
|
|
163
|
+
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
|
164
|
+
lines = content.splitlines()
|
|
165
|
+
trailing_newline = (
|
|
166
|
+
"\r\n" if content.endswith("\r\n") else "\n" if content.endswith("\n") else ""
|
|
167
|
+
)
|
|
168
|
+
if len(lines) < 3:
|
|
169
|
+
return TransformResult(content, self.name, ContentType.PATHS, 0, 0)
|
|
170
|
+
grouped: list[str] = []
|
|
171
|
+
current_directory: str | None = None
|
|
172
|
+
for line in lines:
|
|
173
|
+
match = self._path_row.match(line)
|
|
174
|
+
if match is None:
|
|
175
|
+
return TransformResult(content, self.name, ContentType.PATHS, 0, 0)
|
|
176
|
+
directory = match.group("directory")
|
|
177
|
+
if directory != current_directory:
|
|
178
|
+
grouped.append(directory)
|
|
179
|
+
current_directory = directory
|
|
180
|
+
grouped.append(match.group("base"))
|
|
181
|
+
if self._expand(grouped) != lines:
|
|
182
|
+
return TransformResult(content, self.name, ContentType.PATHS, 0, 0)
|
|
183
|
+
directory_count = sum(line.endswith(("/", "\\")) for line in grouped)
|
|
184
|
+
line_ending = "\r\n" if "\r\n" in content else "\n"
|
|
185
|
+
body = line_ending.join(
|
|
186
|
+
[
|
|
187
|
+
f"[paths compressed: {len(lines)} paths / {directory_count} dirs]",
|
|
188
|
+
*grouped,
|
|
189
|
+
]
|
|
190
|
+
) + trailing_newline
|
|
191
|
+
return _result(
|
|
192
|
+
content,
|
|
193
|
+
body,
|
|
194
|
+
name=self.name,
|
|
195
|
+
content_type=ContentType.PATHS,
|
|
196
|
+
metadata={
|
|
197
|
+
"paths": len(lines),
|
|
198
|
+
"directories": sum(line.endswith(("/", "\\")) for line in grouped),
|
|
199
|
+
"reversible": True,
|
|
200
|
+
},
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class LogTransformer:
|
|
205
|
+
"""Headroom-inspired log parsing, warning dedupe, ranking, and context."""
|
|
206
|
+
|
|
207
|
+
name = "log-v1"
|
|
208
|
+
content_types = frozenset({ContentType.LOG})
|
|
209
|
+
|
|
210
|
+
def __init__(
|
|
211
|
+
self,
|
|
212
|
+
*,
|
|
213
|
+
context_lines: int = 3,
|
|
214
|
+
max_lines: int = 100,
|
|
215
|
+
min_lines_for_compression: int = 50,
|
|
216
|
+
max_warnings: int = 5,
|
|
217
|
+
) -> None:
|
|
218
|
+
self.context_lines = context_lines
|
|
219
|
+
self.max_lines = max_lines
|
|
220
|
+
self.min_lines_for_compression = min_lines_for_compression
|
|
221
|
+
self.max_warnings = max_warnings
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
def _normalise(line: str) -> str:
|
|
225
|
+
return _NUMBER_OR_PATH.sub("<value>", line.casefold())
|
|
226
|
+
|
|
227
|
+
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
|
228
|
+
lines = content.splitlines()
|
|
229
|
+
if len(lines) < self.min_lines_for_compression:
|
|
230
|
+
return TransformResult(content, self.name, ContentType.LOG, 0, 0)
|
|
231
|
+
errors = [
|
|
232
|
+
i
|
|
233
|
+
for i, line in enumerate(lines)
|
|
234
|
+
if _ERROR_LINE.search(line) or re.search(r"\b[A-Za-z_]+(?:Error|Exception):", line)
|
|
235
|
+
]
|
|
236
|
+
warnings = [
|
|
237
|
+
i for i, line in enumerate(lines) if _WARNING_LINE.search(line) and i not in errors
|
|
238
|
+
]
|
|
239
|
+
summaries = [i for i, line in enumerate(lines) if _LOG_SUMMARY.search(line)]
|
|
240
|
+
selected: set[int] = (
|
|
241
|
+
set(range(min(3, len(lines))))
|
|
242
|
+
| set(range(max(0, len(lines) - 3), len(lines)))
|
|
243
|
+
| set(summaries)
|
|
244
|
+
)
|
|
245
|
+
for index in errors:
|
|
246
|
+
selected.update(
|
|
247
|
+
range(
|
|
248
|
+
max(0, index - self.context_lines),
|
|
249
|
+
min(len(lines), index + self.context_lines + 1),
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
seen: set[str] = set()
|
|
253
|
+
warning_count = 0
|
|
254
|
+
for index in warnings:
|
|
255
|
+
key = self._normalise(lines[index])
|
|
256
|
+
if key in seen or warning_count >= self.max_warnings:
|
|
257
|
+
continue
|
|
258
|
+
seen.add(key)
|
|
259
|
+
warning_count += 1
|
|
260
|
+
selected.add(index)
|
|
261
|
+
for index, line in enumerate(lines):
|
|
262
|
+
if _STACK_LINE.search(line) and any(abs(index - error) <= 20 for error in errors):
|
|
263
|
+
selected.add(index)
|
|
264
|
+
ordered = sorted(selected)
|
|
265
|
+
retained_warning_keys: set[str] = set()
|
|
266
|
+
filtered: list[int] = []
|
|
267
|
+
for index in ordered:
|
|
268
|
+
line = lines[index]
|
|
269
|
+
if _WARNING_LINE.search(line) and index not in errors:
|
|
270
|
+
key = self._normalise(line)
|
|
271
|
+
if key in retained_warning_keys or len(retained_warning_keys) >= self.max_warnings:
|
|
272
|
+
continue
|
|
273
|
+
retained_warning_keys.add(key)
|
|
274
|
+
filtered.append(index)
|
|
275
|
+
ordered = filtered
|
|
276
|
+
if len(ordered) > self.max_lines:
|
|
277
|
+
priority = sorted(ordered, key=lambda i: (i not in errors, i not in summaries, i))
|
|
278
|
+
ordered = sorted(priority[: self.max_lines])
|
|
279
|
+
omitted = len(lines) - len(ordered)
|
|
280
|
+
stats = {
|
|
281
|
+
"errors": len(errors),
|
|
282
|
+
"warnings": len(warnings),
|
|
283
|
+
"selected": len(ordered),
|
|
284
|
+
"omitted_lines": max(0, omitted),
|
|
285
|
+
}
|
|
286
|
+
summary = (
|
|
287
|
+
f"[log compressed: {max(0, omitted)} lines omitted; "
|
|
288
|
+
f"ERROR={len(errors)} WARN={len(warnings)}]"
|
|
289
|
+
)
|
|
290
|
+
body = "\n".join([summary, *(lines[index] for index in ordered)])
|
|
291
|
+
return _result(content, body, name=self.name, content_type=ContentType.LOG, metadata=stats)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class DiffTransformer:
|
|
295
|
+
name = "diff-v1"
|
|
296
|
+
content_types = frozenset({ContentType.DIFF})
|
|
297
|
+
|
|
298
|
+
def __init__(self, *, context_lines: int = 2) -> None:
|
|
299
|
+
self.context_lines = context_lines
|
|
300
|
+
|
|
301
|
+
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
|
302
|
+
lines = content.splitlines()
|
|
303
|
+
keep: set[int] = set()
|
|
304
|
+
for index, line in enumerate(lines):
|
|
305
|
+
structural = line.startswith(("diff --git", "index ", "--- ", "+++ ", "@@"))
|
|
306
|
+
changed = line.startswith(("+", "-")) and not line.startswith(("+++", "---"))
|
|
307
|
+
if structural:
|
|
308
|
+
keep.add(index)
|
|
309
|
+
if changed:
|
|
310
|
+
keep.update(
|
|
311
|
+
range(
|
|
312
|
+
max(0, index - self.context_lines),
|
|
313
|
+
min(len(lines), index + self.context_lines + 1),
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
ordered = sorted(keep)
|
|
317
|
+
omitted = max(0, len(lines) - len(ordered))
|
|
318
|
+
body = "\n".join(
|
|
319
|
+
[f"[diff compressed: {omitted} context lines omitted]", *(lines[i] for i in ordered)]
|
|
320
|
+
)
|
|
321
|
+
return _result(
|
|
322
|
+
content,
|
|
323
|
+
body,
|
|
324
|
+
name=self.name,
|
|
325
|
+
content_type=ContentType.DIFF,
|
|
326
|
+
metadata={"omitted_lines": omitted},
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class GitSummaryTransformer:
|
|
331
|
+
"""Keep Git operation status and a bounded, representative file-stat view."""
|
|
332
|
+
|
|
333
|
+
name = "git-summary-v1"
|
|
334
|
+
content_types = frozenset({ContentType.GIT_SUMMARY})
|
|
335
|
+
|
|
336
|
+
def __init__(
|
|
337
|
+
self, *, head_lines: int = 8, tail_lines: int = 8, max_file_entries: int = 30
|
|
338
|
+
) -> None:
|
|
339
|
+
self.head_lines = head_lines
|
|
340
|
+
self.tail_lines = tail_lines
|
|
341
|
+
self.max_file_entries = max_file_entries
|
|
342
|
+
|
|
343
|
+
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
|
344
|
+
lines = content.splitlines()
|
|
345
|
+
file_entries = [
|
|
346
|
+
index
|
|
347
|
+
for index, line in enumerate(lines)
|
|
348
|
+
if re.match(
|
|
349
|
+
r"^(?:\s+.+\s+\|\s+\d+\s+[+\-]+$|(?:create|delete) mode \d+ |rename )", line
|
|
350
|
+
)
|
|
351
|
+
]
|
|
352
|
+
if len(file_entries) <= self.max_file_entries:
|
|
353
|
+
return TransformResult(content, self.name, ContentType.GIT_SUMMARY, 0, 0)
|
|
354
|
+
selected = set(range(min(self.head_lines, len(lines))))
|
|
355
|
+
selected.update(range(max(0, len(lines) - self.tail_lines), len(lines)))
|
|
356
|
+
selected.update(
|
|
357
|
+
index
|
|
358
|
+
for index, line in enumerate(lines)
|
|
359
|
+
if re.match(
|
|
360
|
+
r"^\s*\d+ files? changed|^\s*\d+ (?:insertions?\(\+\)|deletions?\(-\))",
|
|
361
|
+
line,
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
selected.update(file_entries[: self.max_file_entries // 2])
|
|
365
|
+
selected.update(file_entries[-(self.max_file_entries - self.max_file_entries // 2) :])
|
|
366
|
+
ordered = sorted(selected)
|
|
367
|
+
omitted_entries = len(file_entries) - len(set(file_entries) & selected)
|
|
368
|
+
omitted_lines = len(lines) - len(ordered)
|
|
369
|
+
body = "\n".join(
|
|
370
|
+
[
|
|
371
|
+
"[git summary compressed: "
|
|
372
|
+
f"{omitted_entries} file entries and {omitted_lines} lines omitted]",
|
|
373
|
+
*(lines[index] for index in ordered),
|
|
374
|
+
]
|
|
375
|
+
)
|
|
376
|
+
return _result(
|
|
377
|
+
content,
|
|
378
|
+
body,
|
|
379
|
+
name=self.name,
|
|
380
|
+
content_type=ContentType.GIT_SUMMARY,
|
|
381
|
+
metadata={"file_entries": len(file_entries), "omitted_file_entries": omitted_entries},
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class JsonTransformer:
|
|
386
|
+
name = "json-v1"
|
|
387
|
+
content_types = frozenset({ContentType.JSON})
|
|
388
|
+
|
|
389
|
+
def __init__(self, *, max_items: int = 20) -> None:
|
|
390
|
+
self.max_items = max_items
|
|
391
|
+
|
|
392
|
+
@staticmethod
|
|
393
|
+
def _score(value: Any, query_terms: set[str]) -> int:
|
|
394
|
+
text = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str).casefold()
|
|
395
|
+
score = sum(term in text for term in query_terms)
|
|
396
|
+
return (
|
|
397
|
+
score + 5
|
|
398
|
+
if any(word in text for word in ("error", "failed", "fatal", "exception"))
|
|
399
|
+
else score
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
|
403
|
+
try:
|
|
404
|
+
data = json.loads(content)
|
|
405
|
+
except (TypeError, ValueError):
|
|
406
|
+
return TransformResult(content, self.name, ContentType.JSON, 0, 0)
|
|
407
|
+
query_terms = {term.casefold() for term in _TOKEN.findall(context.query) if len(term) > 1}
|
|
408
|
+
if isinstance(data, list) and len(data) > self.max_items:
|
|
409
|
+
ranked = sorted(
|
|
410
|
+
enumerate(data),
|
|
411
|
+
key=lambda pair: (self._score(pair[1], query_terms), -pair[0]),
|
|
412
|
+
reverse=True,
|
|
413
|
+
)
|
|
414
|
+
indexes = sorted({0, len(data) - 1, *(index for index, _ in ranked[: self.max_items])})
|
|
415
|
+
selected = [data[index] for index in indexes]
|
|
416
|
+
body_data: Any = {
|
|
417
|
+
"_summary": {
|
|
418
|
+
"original_items": len(data),
|
|
419
|
+
"shown_items": len(selected),
|
|
420
|
+
"omitted_items": len(data) - len(selected),
|
|
421
|
+
},
|
|
422
|
+
"items": selected,
|
|
423
|
+
}
|
|
424
|
+
elif isinstance(data, dict) and len(data) > self.max_items:
|
|
425
|
+
ranked_keys = sorted(
|
|
426
|
+
data,
|
|
427
|
+
key=lambda key: (self._score({key: data[key]}, query_terms), key),
|
|
428
|
+
reverse=True,
|
|
429
|
+
)
|
|
430
|
+
selected_keys = ranked_keys[: self.max_items]
|
|
431
|
+
body_data = {
|
|
432
|
+
"_summary": {
|
|
433
|
+
"original_keys": len(data),
|
|
434
|
+
"shown_keys": len(selected_keys),
|
|
435
|
+
"omitted_keys": len(data) - len(selected_keys),
|
|
436
|
+
},
|
|
437
|
+
"values": {key: data[key] for key in selected_keys},
|
|
438
|
+
}
|
|
439
|
+
else:
|
|
440
|
+
return TransformResult(content, self.name, ContentType.JSON, 0, 0)
|
|
441
|
+
body = json.dumps(body_data, ensure_ascii=False, indent=2, sort_keys=True, default=str)
|
|
442
|
+
return _result(
|
|
443
|
+
content,
|
|
444
|
+
body,
|
|
445
|
+
name=self.name,
|
|
446
|
+
content_type=ContentType.JSON,
|
|
447
|
+
metadata={"structured": True},
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
class CodeTransformer:
|
|
452
|
+
name = "code-v1"
|
|
453
|
+
content_types = frozenset({ContentType.CODE})
|
|
454
|
+
_signature = re.compile(
|
|
455
|
+
r"^(?P<prefix>\s*\d+(?:\.\d+)?\t)?(?P<indent>\s*)"
|
|
456
|
+
r"(?:async\s+def|def|class|function|func|fn|export\s+(?:async\s+)?function)\s+[^:{(]+"
|
|
457
|
+
)
|
|
458
|
+
_import = re.compile(
|
|
459
|
+
r"^\s*(?:\d+(?:\.\d+)?\t)?\s*(?:import|from|use|package|#include)\b"
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
def __init__(self, *, body_lines: int = 3) -> None:
|
|
463
|
+
self.body_lines = body_lines
|
|
464
|
+
|
|
465
|
+
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
|
466
|
+
lines = content.splitlines()
|
|
467
|
+
signatures = [index for index, line in enumerate(lines) if self._signature.match(line)]
|
|
468
|
+
if len(signatures) < 3:
|
|
469
|
+
return TransformResult(content, self.name, ContentType.CODE, 0, 0)
|
|
470
|
+
keep: set[int] = set()
|
|
471
|
+
for index, line in enumerate(lines):
|
|
472
|
+
if self._import.match(line):
|
|
473
|
+
keep.add(index)
|
|
474
|
+
for position, start in enumerate(signatures):
|
|
475
|
+
end = signatures[position + 1] if position + 1 < len(signatures) else len(lines)
|
|
476
|
+
keep.update(range(start, min(end, start + self.body_lines + 1)))
|
|
477
|
+
ordered = sorted(keep)
|
|
478
|
+
omitted = len(lines) - len(ordered)
|
|
479
|
+
body = "\n".join(
|
|
480
|
+
[f"[code compressed: {omitted} body lines omitted]", *(lines[i] for i in ordered)]
|
|
481
|
+
)
|
|
482
|
+
return _result(
|
|
483
|
+
content,
|
|
484
|
+
body,
|
|
485
|
+
name=self.name,
|
|
486
|
+
content_type=ContentType.CODE,
|
|
487
|
+
metadata={"omitted_lines": omitted},
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
class GenericTransformer:
|
|
492
|
+
name = "generic-v1"
|
|
493
|
+
content_types = frozenset({ContentType.TEXT})
|
|
494
|
+
|
|
495
|
+
def __init__(
|
|
496
|
+
self, *, head_lines: int = 40, tail_lines: int = 16, max_anchor_lines: int = 12
|
|
497
|
+
) -> None:
|
|
498
|
+
self.head_lines = head_lines
|
|
499
|
+
self.tail_lines = tail_lines
|
|
500
|
+
self.max_anchor_lines = max_anchor_lines
|
|
501
|
+
|
|
502
|
+
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
|
503
|
+
lines = content.splitlines()
|
|
504
|
+
if len(lines) <= self.head_lines + self.tail_lines:
|
|
505
|
+
return TransformResult(content, self.name, ContentType.TEXT, 0, 0)
|
|
506
|
+
anchors = [
|
|
507
|
+
line for line in lines[self.head_lines : -self.tail_lines] if _ERROR_LINE.search(line)
|
|
508
|
+
][: self.max_anchor_lines]
|
|
509
|
+
omitted = len(lines) - self.head_lines - self.tail_lines - len(anchors)
|
|
510
|
+
body = "\n".join(
|
|
511
|
+
[
|
|
512
|
+
*lines[: self.head_lines],
|
|
513
|
+
f"...[{max(0, omitted)} lines omitted]...",
|
|
514
|
+
*anchors,
|
|
515
|
+
*lines[-self.tail_lines :],
|
|
516
|
+
]
|
|
517
|
+
)
|
|
518
|
+
return _result(
|
|
519
|
+
content,
|
|
520
|
+
body,
|
|
521
|
+
name=self.name,
|
|
522
|
+
content_type=ContentType.TEXT,
|
|
523
|
+
metadata={"omitted_lines": max(0, omitted)},
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def load_transformer_plugins(specs: list[str] | tuple[str, ...]) -> list[ToolOutputTransformer]:
|
|
528
|
+
"""Load optional local transformers from ``module:attribute`` specifications."""
|
|
529
|
+
plugins: list[ToolOutputTransformer] = []
|
|
530
|
+
for spec in specs:
|
|
531
|
+
module_name, separator, attribute = str(spec).partition(":")
|
|
532
|
+
if not separator or not module_name or not attribute:
|
|
533
|
+
raise ValueError(f"invalid tool-output transformer plugin: {spec!r}")
|
|
534
|
+
factory = getattr(importlib.import_module(module_name), attribute)
|
|
535
|
+
plugin = factory() if isinstance(factory, type) else factory
|
|
536
|
+
if not callable(getattr(plugin, "transform", None)) or not getattr(plugin, "name", ""):
|
|
537
|
+
raise TypeError(f"invalid tool-output transformer plugin: {spec!r}")
|
|
538
|
+
plugins.append(plugin)
|
|
539
|
+
return plugins
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
class NativeTransformer:
|
|
543
|
+
"""Adapter for the optional prebuilt Apache-2.0 native compression wheel.
|
|
544
|
+
|
|
545
|
+
Native code receives only text and returns a compact view. Synapse retains
|
|
546
|
+
responsibility for critical-fact validation, fallback, metrics, and SQLite
|
|
547
|
+
reversible storage in ``ToolOutputTransformPipeline``.
|
|
548
|
+
"""
|
|
549
|
+
|
|
550
|
+
def __init__(
|
|
551
|
+
self,
|
|
552
|
+
*,
|
|
553
|
+
name: str,
|
|
554
|
+
content_type: ContentType,
|
|
555
|
+
native_transform: Any,
|
|
556
|
+
) -> None:
|
|
557
|
+
self.name = name
|
|
558
|
+
self.content_types = frozenset({content_type})
|
|
559
|
+
self._content_type = content_type
|
|
560
|
+
self._native_transform = native_transform
|
|
561
|
+
|
|
562
|
+
def transform(self, content: str, context: TransformContext) -> TransformResult:
|
|
563
|
+
try:
|
|
564
|
+
payload = self._native_transform(content, context)
|
|
565
|
+
transformed = payload.get("content") if isinstance(payload, dict) else None
|
|
566
|
+
if not isinstance(transformed, str):
|
|
567
|
+
raise TypeError("native compressor returned no text content")
|
|
568
|
+
metadata = {
|
|
569
|
+
"native": True,
|
|
570
|
+
**{key: value for key, value in payload.items() if key != "content"},
|
|
571
|
+
}
|
|
572
|
+
return _result(
|
|
573
|
+
content,
|
|
574
|
+
transformed,
|
|
575
|
+
name=str(payload.get("transformer") or self.name),
|
|
576
|
+
content_type=self._content_type,
|
|
577
|
+
metadata=metadata,
|
|
578
|
+
)
|
|
579
|
+
except Exception as exc: # noqa: BLE001
|
|
580
|
+
return TransformResult(
|
|
581
|
+
content,
|
|
582
|
+
self.name,
|
|
583
|
+
self._content_type,
|
|
584
|
+
0,
|
|
585
|
+
0,
|
|
586
|
+
{"native": True, "fallback": "native_error", "error": type(exc).__name__},
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def load_native_transformers(*, enabled: bool = True) -> list[ToolOutputTransformer]:
|
|
591
|
+
"""Load native transformers when the optional wheel is installed.
|
|
592
|
+
|
|
593
|
+
Import failure is expected on unsupported platforms and leaves the Python
|
|
594
|
+
deterministic transformers active. The package is never built at install
|
|
595
|
+
time by Synapse itself.
|
|
596
|
+
"""
|
|
597
|
+
if not enabled:
|
|
598
|
+
return []
|
|
599
|
+
try:
|
|
600
|
+
import synapse_tool_compress_core as native
|
|
601
|
+
except (ImportError, OSError):
|
|
602
|
+
return []
|
|
603
|
+
|
|
604
|
+
def compress_native_diff(content: str, context: TransformContext) -> dict[str, Any]:
|
|
605
|
+
try:
|
|
606
|
+
result = native.compress_diff(content, context=context.query)
|
|
607
|
+
except TypeError as exc:
|
|
608
|
+
if "unexpected keyword argument 'context'" not in str(exc):
|
|
609
|
+
raise
|
|
610
|
+
result = native.compress_diff(content)
|
|
611
|
+
result["context_supported"] = False
|
|
612
|
+
else:
|
|
613
|
+
result["context_supported"] = True
|
|
614
|
+
return result
|
|
615
|
+
|
|
616
|
+
return [
|
|
617
|
+
NativeTransformer(
|
|
618
|
+
name="headroom-search-v1",
|
|
619
|
+
content_type=ContentType.SEARCH,
|
|
620
|
+
native_transform=lambda content, context: native.compress_search(
|
|
621
|
+
content, query=context.query
|
|
622
|
+
),
|
|
623
|
+
),
|
|
624
|
+
NativeTransformer(
|
|
625
|
+
name="headroom-log-v1",
|
|
626
|
+
content_type=ContentType.LOG,
|
|
627
|
+
native_transform=lambda content, _context: native.compress_log(content),
|
|
628
|
+
),
|
|
629
|
+
NativeTransformer(
|
|
630
|
+
name="headroom-diff-v1",
|
|
631
|
+
content_type=ContentType.DIFF,
|
|
632
|
+
native_transform=compress_native_diff,
|
|
633
|
+
),
|
|
634
|
+
NativeTransformer(
|
|
635
|
+
name="headroom-smart-crusher-v1",
|
|
636
|
+
content_type=ContentType.JSON,
|
|
637
|
+
native_transform=lambda content, context: native.crush_json(
|
|
638
|
+
content, query=context.query
|
|
639
|
+
),
|
|
640
|
+
),
|
|
641
|
+
NativeTransformer(
|
|
642
|
+
name="headroom-code-v1",
|
|
643
|
+
content_type=ContentType.CODE,
|
|
644
|
+
native_transform=lambda content, context: native.compress_code(
|
|
645
|
+
content, context=context.query
|
|
646
|
+
),
|
|
647
|
+
),
|
|
648
|
+
]
|