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.
Files changed (131) hide show
  1. synapse/__init__.py +13 -0
  2. synapse/__main__.py +6 -0
  3. synapse/app/__init__.py +1 -0
  4. synapse/app/agent.py +492 -0
  5. synapse/app/agent_md.py +107 -0
  6. synapse/cli.py +750 -0
  7. synapse/commands/__init__.py +1 -0
  8. synapse/commands/compression.py +573 -0
  9. synapse/commands/helpers.py +22 -0
  10. synapse/commands/mcp.py +406 -0
  11. synapse/commands/model.py +173 -0
  12. synapse/commands/result.py +34 -0
  13. synapse/commands/sessions.py +443 -0
  14. synapse/commands/slash_cmds.py +521 -0
  15. synapse/commands/slash_complete.py +816 -0
  16. synapse/commands/theme.py +99 -0
  17. synapse/config.py +27 -0
  18. synapse/content/__init__.py +1 -0
  19. synapse/content/input_history.py +122 -0
  20. synapse/content/multimodal.py +733 -0
  21. synapse/content/prompts.py +249 -0
  22. synapse/content/skills_catalog.py +128 -0
  23. synapse/integrations/__init__.py +1 -0
  24. synapse/integrations/checkpoint_seed.py +281 -0
  25. synapse/integrations/codex_history.py +375 -0
  26. synapse/integrations/codex_import.py +393 -0
  27. synapse/integrations/codex_sessions.py +629 -0
  28. synapse/integrations/describe_image.py +370 -0
  29. synapse/integrations/http_clients.py +199 -0
  30. synapse/integrations/llm_openai_compat.py +90 -0
  31. synapse/integrations/llm_openai_websocket.py +187 -0
  32. synapse/integrations/mcp_client.py +646 -0
  33. synapse/integrations/vision_middleware.py +62 -0
  34. synapse/models/__init__.py +5 -0
  35. synapse/models/config.py +240 -0
  36. synapse/models/helpers.py +206 -0
  37. synapse/models/profile.py +59 -0
  38. synapse/models/registry.py +722 -0
  39. synapse/models_registry.py +7 -0
  40. synapse/observability/__init__.py +1 -0
  41. synapse/observability/startup_trace.py +127 -0
  42. synapse/runtime/__init__.py +1 -0
  43. synapse/runtime/async_runtime.py +176 -0
  44. synapse/runtime/backends.py +458 -0
  45. synapse/runtime/context_compact.py +249 -0
  46. synapse/runtime/execute_capture.py +48 -0
  47. synapse/runtime/fs_permissions.py +79 -0
  48. synapse/runtime/harness.py +57 -0
  49. synapse/runtime/hitl.py +197 -0
  50. synapse/runtime/interaction_ledger.py +82 -0
  51. synapse/runtime/middleware.py +802 -0
  52. synapse/runtime/model_request_compression_middleware.py +745 -0
  53. synapse/runtime/pathing.py +146 -0
  54. synapse/runtime/safety.py +184 -0
  55. synapse/runtime/steer.py +240 -0
  56. synapse/runtime/subagents.py +207 -0
  57. synapse/runtime/tool_ignore.py +221 -0
  58. synapse/runtime/tool_output_eval.py +118 -0
  59. synapse/runtime/tool_output_middleware.py +585 -0
  60. synapse/runtime/tool_output_usage_middleware.py +60 -0
  61. synapse/sessions/__init__.py +31 -0
  62. synapse/sessions/cancel_repair.py +208 -0
  63. synapse/sessions/session_recap.py +174 -0
  64. synapse/sessions/store.py +695 -0
  65. synapse/sessions/transcript.py +754 -0
  66. synapse/settings/__init__.py +5 -0
  67. synapse/settings/config_paths.py +184 -0
  68. synapse/settings/schema.py +464 -0
  69. synapse/tool_output/__init__.py +59 -0
  70. synapse/tool_output/detection.py +170 -0
  71. synapse/tool_output/metrics.py +32 -0
  72. synapse/tool_output/models.py +173 -0
  73. synapse/tool_output/pipeline.py +330 -0
  74. synapse/tool_output/repository.py +721 -0
  75. synapse/tool_output/transformers.py +648 -0
  76. synapse/tools/__init__.py +5 -0
  77. synapse/tools/session_tools.py +204 -0
  78. synapse/ui/__init__.py +10 -0
  79. synapse/ui/bottombar/__init__.py +73 -0
  80. synapse/ui/bottombar/components/__init__.py +143 -0
  81. synapse/ui/bottombar/components/key_hints.py +30 -0
  82. synapse/ui/bottombar/components/mcp.py +64 -0
  83. synapse/ui/bottombar/components/mode.py +24 -0
  84. synapse/ui/bottombar/components/model.py +28 -0
  85. synapse/ui/bottombar/components/thread.py +29 -0
  86. synapse/ui/bottombar/context.py +36 -0
  87. synapse/ui/bottombar/core.py +74 -0
  88. synapse/ui/dialogs/__init__.py +25 -0
  89. synapse/ui/dialogs/base.py +362 -0
  90. synapse/ui/dialogs/codex_session_list.py +84 -0
  91. synapse/ui/dialogs/compression_diagnostics.py +210 -0
  92. synapse/ui/dialogs/git_explore.py +702 -0
  93. synapse/ui/dialogs/mcp_panel.py +407 -0
  94. synapse/ui/dialogs/model_picker.py +128 -0
  95. synapse/ui/dialogs/safety_panel.py +63 -0
  96. synapse/ui/dialogs/session_list.py +98 -0
  97. synapse/ui/dialogs/theme_designer.py +863 -0
  98. synapse/ui/dialogs/theme_picker.py +113 -0
  99. synapse/ui/git_explore/__init__.py +31 -0
  100. synapse/ui/git_explore/engine.py +82 -0
  101. synapse/ui/git_explore/provider.py +242 -0
  102. synapse/ui/git_explore/unified.py +85 -0
  103. synapse/ui/rendering.py +350 -0
  104. synapse/ui/sink.py +70 -0
  105. synapse/ui/steer_widget.py +367 -0
  106. synapse/ui/stream.py +1207 -0
  107. synapse/ui/stream_events.py +421 -0
  108. synapse/ui/stream_runtime.py +252 -0
  109. synapse/ui/theme.py +1154 -0
  110. synapse/ui/timeline.py +621 -0
  111. synapse/ui/topbar/__init__.py +97 -0
  112. synapse/ui/topbar/components/__init__.py +150 -0
  113. synapse/ui/topbar/components/branch.py +41 -0
  114. synapse/ui/topbar/components/title.py +24 -0
  115. synapse/ui/topbar/components/tool_output.py +24 -0
  116. synapse/ui/topbar/components/usage.py +24 -0
  117. synapse/ui/topbar/components/workspace.py +32 -0
  118. synapse/ui/topbar/context.py +32 -0
  119. synapse/ui/topbar/core.py +979 -0
  120. synapse/ui/topbar/git_changes_popover.py +178 -0
  121. synapse/ui/topbar/git_chrome.py +475 -0
  122. synapse/ui/topbar/tool_output_popover.py +84 -0
  123. synapse/ui/topbar/widget.py +474 -0
  124. synapse/ui/tui.py +5717 -0
  125. synapse/ui/turn_rail.py +71 -0
  126. synapse/ui/user_turn.py +83 -0
  127. synapse/ui/welcome.py +261 -0
  128. synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
  129. synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
  130. synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
  131. 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
+ ]
@@ -0,0 +1,5 @@
1
+ """Custom tools for the coding agent."""
2
+
3
+ from synapse.tools.session_tools import build_session_tools, build_tool_result_reader_tool
4
+
5
+ __all__ = ["build_session_tools", "build_tool_result_reader_tool"]