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
synapse/cli.py ADDED
@@ -0,0 +1,750 @@
1
+ """Typer CLI for the local coding agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from synapse.sessions.store import SessionStore, format_session_table
11
+ from synapse.settings import bootstrap_project_env, load_settings
12
+ from synapse.ui.stream import (
13
+ console,
14
+ print_error,
15
+ print_info,
16
+ )
17
+
18
+ app = typer.Typer(
19
+ name="synapse",
20
+ help="Local coding agent built on LangChain Deep Agents (LocalShell, no sandbox).",
21
+ add_completion=False,
22
+ no_args_is_help=False,
23
+ )
24
+
25
+ sessions_app = typer.Typer(help="Manage chat session metadata.")
26
+ models_app = typer.Typer(help="List/select configured model profiles.")
27
+ mcp_app = typer.Typer(help="Inspect MCP server configuration and tools.")
28
+ tool_output_app = typer.Typer(help="Inspect reversible tool-output transformation metrics.")
29
+ app.add_typer(sessions_app, name="sessions")
30
+ app.add_typer(models_app, name="models")
31
+ app.add_typer(mcp_app, name="mcp")
32
+ app.add_typer(tool_output_app, name="tool-output")
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Common helpers
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ def _bootstrap_env() -> Path | None:
41
+ """Load project `.env` with override=True so it beats stale system keys."""
42
+ try:
43
+ from synapse.content.prompts import ensure_user_system_prompt
44
+
45
+ ensure_user_system_prompt()
46
+ except Exception: # noqa: BLE001
47
+ pass
48
+ return bootstrap_project_env(Path.cwd())
49
+
50
+
51
+ def _resolve_settings(
52
+ *,
53
+ workspace: Path | None,
54
+ model: str | None,
55
+ require_approval: bool | None,
56
+ debug: bool,
57
+ readonly: bool | None = None,
58
+ ):
59
+ overrides: dict = {"debug": debug}
60
+ if workspace is not None:
61
+ overrides["workspace"] = workspace
62
+ if model is not None:
63
+ overrides["model"] = model
64
+ overrides["active_model"] = model
65
+ if require_approval is not None:
66
+ overrides["require_approval"] = require_approval
67
+ if readonly is not None:
68
+ overrides["readonly"] = readonly
69
+ return load_settings(**overrides)
70
+
71
+
72
+ def _session_store(settings) -> SessionStore:
73
+ return SessionStore(settings.resolved_sessions_path())
74
+
75
+
76
+ def _print_auth_error(settings, exc: Exception) -> None:
77
+ msg = str(exc)
78
+ print_error(msg)
79
+ if "401" in msg or "Invalid token" in msg or "Unauthorized" in msg:
80
+ print_info(
81
+ "Auth failed. Check project .env OPENAI_API_KEY / OPENAI_BASE_URL. "
82
+ "Project .env now overrides system env; re-check key validity on gateway."
83
+ )
84
+ print_info(
85
+ f"Using key {settings.mask_openai_key()} "
86
+ f"base_url={settings.openai_base_url!r} model={settings.model!r}"
87
+ )
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Default callback: launch TUI when no subcommand is given
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ def _launch_tui(
96
+ *,
97
+ workspace: Path | None,
98
+ model: str | None,
99
+ require_approval: bool,
100
+ readonly: bool,
101
+ thread_id: str | None,
102
+ debug: bool,
103
+ ) -> None:
104
+ env_path = _bootstrap_env()
105
+ settings = _resolve_settings(
106
+ workspace=workspace,
107
+ model=model,
108
+ require_approval=require_approval,
109
+ debug=debug,
110
+ readonly=readonly,
111
+ )
112
+ try:
113
+ from synapse.ui.tui import run_tui
114
+ except ImportError as exc: # pragma: no cover - dependency missing
115
+ print_error(f"textual is required for TUI mode: {exc}")
116
+ print_info("Install with: uv add textual (or uv sync)")
117
+ raise typer.Exit(code=1) from exc
118
+
119
+ try:
120
+ run_tui(
121
+ settings=settings,
122
+ thread_id=thread_id,
123
+ env_path=env_path,
124
+ project_root=Path.cwd(),
125
+ cli_model=model,
126
+ )
127
+ except Exception as exc: # noqa: BLE001
128
+ _print_auth_error(settings, exc)
129
+ raise typer.Exit(code=1) from exc
130
+
131
+
132
+ @app.callback(invoke_without_command=True)
133
+ def _default_tui(
134
+ ctx: typer.Context,
135
+ workspace: Path | None = typer.Option(
136
+ None, "--workspace", "-w", help="Workspace directory", exists=False, file_okay=False
137
+ ),
138
+ model: str | None = typer.Option(
139
+ None, "--model", "-m", help="Model profile alias or provider:model"
140
+ ),
141
+ require_approval: bool = typer.Option(
142
+ False,
143
+ "--require-approval/--no-require-approval",
144
+ help="Enable HITL approval (default: disabled, auto-pass)",
145
+ ),
146
+ readonly: bool = typer.Option(
147
+ False, "--readonly/--no-readonly", help="Exclude write/execute tools via harness"
148
+ ),
149
+ thread_id: str | None = typer.Option(None, "--thread-id", help="Resume a session id"),
150
+ debug: bool = typer.Option(False, "--debug", help="Enable deepagents debug mode"),
151
+ ) -> None:
152
+ """Full-screen Textual TUI - the default interface."""
153
+ if ctx.invoked_subcommand is not None:
154
+ return
155
+ _launch_tui(
156
+ workspace=workspace,
157
+ model=model,
158
+ require_approval=require_approval,
159
+ readonly=readonly,
160
+ thread_id=thread_id,
161
+ debug=debug,
162
+ )
163
+
164
+
165
+ @app.command("tui")
166
+ def tui(
167
+ workspace: Path | None = typer.Option(
168
+ None, "--workspace", "-w", help="Workspace directory", exists=False, file_okay=False
169
+ ),
170
+ model: str | None = typer.Option(
171
+ None, "--model", "-m", help="Model profile alias or provider:model"
172
+ ),
173
+ require_approval: bool = typer.Option(
174
+ False,
175
+ "--require-approval/--no-require-approval",
176
+ help="Enable HITL approval (default: disabled, auto-pass)",
177
+ ),
178
+ readonly: bool = typer.Option(
179
+ False, "--readonly/--no-readonly", help="Exclude write/execute tools via harness"
180
+ ),
181
+ thread_id: str | None = typer.Option(None, "--thread-id", help="Resume a session id"),
182
+ debug: bool = typer.Option(False, "--debug", help="Enable deepagents debug mode"),
183
+ ) -> None:
184
+ """Launch the full-screen Textual TUI."""
185
+ _launch_tui(
186
+ workspace=workspace,
187
+ model=model,
188
+ require_approval=require_approval,
189
+ readonly=readonly,
190
+ thread_id=thread_id,
191
+ debug=debug,
192
+ )
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # Sub-commands: sessions
197
+ # ---------------------------------------------------------------------------
198
+
199
+
200
+ def _import_codex_session(
201
+ native_id: str,
202
+ *,
203
+ workspace: Path | None = None,
204
+ codex_home: Path | None = None,
205
+ ):
206
+ """Import one safe Codex visible-text snapshot into a Synapse thread."""
207
+ from synapse.app.agent import build_coding_agent
208
+ from synapse.integrations.codex_import import import_codex_session
209
+
210
+ settings = load_settings(workspace=workspace) if workspace is not None else load_settings()
211
+ agent = build_coding_agent(settings, project_root=settings.workspace, load_mcp=False)
212
+ try:
213
+ return import_codex_session(
214
+ native_id=native_id,
215
+ settings=settings,
216
+ agent=agent,
217
+ workspace=workspace,
218
+ codex_home=codex_home,
219
+ )
220
+ except Exception as exc: # noqa: BLE001
221
+ prefix = "Codex session cannot be imported safely: "
222
+ message = str(exc)
223
+ if message.startswith(prefix):
224
+ codes = message.removeprefix(prefix).split(",")
225
+ reasons = ", ".join(_preview_warning_text(code) for code in codes)
226
+ raise ValueError(f"Codex session cannot be imported safely: {reasons}") from exc
227
+ raise ValueError(message) from exc
228
+
229
+
230
+ @sessions_app.command("list")
231
+ def sessions_list(
232
+ limit: int = typer.Option(50, "--limit", "-n", help="Max sessions"),
233
+ all_sessions: bool = typer.Option(
234
+ False,
235
+ "--all",
236
+ help="Include empty placeholder sessions (default: hide them)",
237
+ ),
238
+ ) -> None:
239
+ """List recent sessions."""
240
+ settings = load_settings()
241
+ store = _session_store(settings)
242
+ items = store.list(limit=limit) if all_sessions else store.list_nonempty(limit=limit)
243
+ console.print(format_session_table(items))
244
+
245
+
246
+ @sessions_app.command("codex-list")
247
+ def sessions_codex_list(
248
+ workspace: Path | None = typer.Option(
249
+ None, "--workspace", "-w", help="Filter Codex sessions to one workspace"
250
+ ),
251
+ codex_home: Path | None = typer.Option(
252
+ None, "--codex-home", help="Codex home directory (default: CODEX_HOME or ~/.codex)"
253
+ ),
254
+ limit: int = typer.Option(50, "--limit", "-n", min=1, max=200, help="Max sessions"),
255
+ ) -> None:
256
+ """List read-only Codex session metadata, optionally for one workspace."""
257
+ from synapse.integrations.codex_sessions import CodexSessionScanner
258
+
259
+ result = CodexSessionScanner(codex_home).scan(workspace, limit=limit)
260
+ scope = str(workspace.resolve()) if workspace is not None else "all workspaces"
261
+ if not result.sessions:
262
+ print_info(f"no Codex sessions found for {scope}")
263
+ else:
264
+ for session in result.sessions:
265
+ print_info(
266
+ f"{session.native_id} {session.updated_at:%Y-%m-%d %H:%M} "
267
+ f"{session.source:8s} {session.cwd} {session.title}"
268
+ )
269
+ for warning in result.warnings:
270
+ print_info(f"warning: {warning}")
271
+
272
+
273
+ @sessions_app.command("codex-inspect")
274
+ def sessions_codex_inspect(
275
+ native_id: str = typer.Argument(..., help="Codex native session id"),
276
+ workspace: Path | None = typer.Option(
277
+ None, "--workspace", "-w", help="Filter Codex sessions to one workspace"
278
+ ),
279
+ codex_home: Path | None = typer.Option(
280
+ None, "--codex-home", help="Codex home directory (default: CODEX_HOME or ~/.codex)"
281
+ ),
282
+ ) -> None:
283
+ """Show read-only metadata for one Codex session."""
284
+ from synapse.integrations.codex_sessions import CodexSessionScanner
285
+
286
+ scanner = CodexSessionScanner(codex_home)
287
+ session = scanner.inspect(native_id, workspace=workspace)
288
+ if session is None:
289
+ print_error(f"Codex session not found: {native_id}")
290
+ raise typer.Exit(code=1)
291
+ for key, value in session.to_dict().items():
292
+ if key == "warnings":
293
+ continue
294
+ print_info(f"{key}: {value}")
295
+ for warning in session.warnings:
296
+ print_info(f"warning: {warning}")
297
+
298
+
299
+ MAX_PREVIEW_MESSAGE_CHARS = 12_000
300
+
301
+
302
+ _PREVIEW_WARNING_TEXT = {
303
+ "internal_user_message": "历史包含内部提示内容",
304
+ "invalid_json": "历史文件不是有效的 JSONL",
305
+ "legacy_compaction_unsupported": "历史使用了暂不支持的旧版压缩格式",
306
+ "no_visible_messages": "历史没有可导入的已完成用户或助手消息",
307
+ "rollout_line_limit": "历史中有超过安全上限的单行内容",
308
+ "rollout_not_utf8": "历史文件不是 UTF-8 文本",
309
+ "rollout_read_failed": "历史文件无法读取",
310
+ "rollout_size_limit": "历史解压后的大小超过安全上限",
311
+ "rollout_zstd_invalid": "压缩的历史文件已损坏或不是有效 zstd 数据",
312
+ "unsupported_replacement_content": "压缩后的历史包含暂不支持的内容",
313
+ "unsupported_replacement_item": "压缩后的历史包含暂不支持的记录",
314
+ }
315
+
316
+
317
+ def _preview_warning_text(code: str) -> str:
318
+ return _PREVIEW_WARNING_TEXT.get(code, "历史包含暂不支持的记录")
319
+
320
+
321
+ def _bounded_preview_text(text: str) -> tuple[str, bool]:
322
+ if len(text) <= MAX_PREVIEW_MESSAGE_CHARS:
323
+ return text, False
324
+ return text[:MAX_PREVIEW_MESSAGE_CHARS] + "\n[message truncated]", True
325
+
326
+
327
+ @sessions_app.command("codex-preview")
328
+ def sessions_codex_preview(
329
+ native_id: str = typer.Argument(..., help="Codex native session id"),
330
+ workspace: Path | None = typer.Option(
331
+ None, "--workspace", "-w", help="Filter Codex sessions to one workspace"
332
+ ),
333
+ codex_home: Path | None = typer.Option(
334
+ None, "--codex-home", help="Codex home directory (default: CODEX_HOME or ~/.codex)"
335
+ ),
336
+ limit: int = typer.Option(100, "--limit", "-n", min=1, max=500, help="Max visible messages"),
337
+ offset: int = typer.Option(0, "--offset", min=0, help="Visible message offset"),
338
+ ) -> None:
339
+ """Preview safe, completed user and assistant text from one Codex session."""
340
+ from synapse.integrations.codex_history import CodexHistoryProjector
341
+ from synapse.integrations.codex_sessions import CodexSessionScanner
342
+
343
+ session = CodexSessionScanner(codex_home).inspect(native_id, workspace=workspace)
344
+ if session is None:
345
+ print_error(f"Codex session not found: {native_id}")
346
+ raise typer.Exit(code=1)
347
+
348
+ snapshot = CodexHistoryProjector().project_path(session.rollout_path)
349
+ if not snapshot.importable:
350
+ print_error("Codex session cannot be previewed safely")
351
+ for warning in snapshot.warnings:
352
+ print_info(f"reason: {_preview_warning_text(warning.code)}")
353
+ raise typer.Exit(code=1)
354
+
355
+ page = snapshot.messages[offset : offset + limit]
356
+ print_info(f"Codex session: {session.title}")
357
+ print_info(f"Workspace: {session.cwd}")
358
+ print_info(f"Showing messages {offset + 1}-{offset + len(page)} of {len(snapshot.messages)}")
359
+ message_was_truncated = False
360
+ for message in page:
361
+ label = "User" if message.role == "user" else "Assistant"
362
+ text, truncated = _bounded_preview_text(message.text)
363
+ message_was_truncated = message_was_truncated or truncated
364
+ console.print(f"\n[{label}]\n{text}")
365
+ if offset + len(page) < len(snapshot.messages):
366
+ print_info(f"more messages: use --offset {offset + len(page)}")
367
+ if message_was_truncated:
368
+ print_info(f"messages longer than {MAX_PREVIEW_MESSAGE_CHARS} characters were truncated")
369
+ for warning in snapshot.warnings:
370
+ print_info(f"warning: {_preview_warning_text(warning.code)}")
371
+
372
+
373
+ @sessions_app.command("codex-import")
374
+ def sessions_codex_import(
375
+ native_id: str = typer.Argument(..., help="Codex native session id"),
376
+ workspace: Path | None = typer.Option(
377
+ None, "--workspace", "-w", help="Filter Codex sessions to one workspace"
378
+ ),
379
+ codex_home: Path | None = typer.Option(
380
+ None, "--codex-home", help="Codex home directory (default: CODEX_HOME or ~/.codex)"
381
+ ),
382
+ ) -> None:
383
+ """Import one safe Codex visible-text snapshot into a new Synapse session."""
384
+ try:
385
+ result = _import_codex_session(native_id, workspace=workspace, codex_home=codex_home)
386
+ except Exception as exc: # noqa: BLE001
387
+ print_error(str(exc))
388
+ raise typer.Exit(code=1) from exc
389
+ status = "reused" if result.reused else "recovered" if result.recovered else "imported"
390
+ print_info(f"Codex session {status}: thread_id={result.thread_id}")
391
+
392
+
393
+ @sessions_app.command("prune")
394
+ def sessions_prune() -> None:
395
+ """Delete empty placeholder sessions (never got a real first message)."""
396
+ settings = load_settings()
397
+ store = _session_store(settings)
398
+ deleted = store.prune_empty()
399
+ print_info(f"pruned {len(deleted)} empty session(s)")
400
+ for tid in deleted[:20]:
401
+ print_info(f" - {tid}")
402
+ if len(deleted) > 20:
403
+ print_info(f" … and {len(deleted) - 20} more")
404
+
405
+
406
+ @sessions_app.command("delete")
407
+ def sessions_delete(
408
+ thread_id: str = typer.Argument(..., help="Session thread id"),
409
+ ) -> None:
410
+ """Delete session metadata (checkpoint rows are left to LangGraph GC)."""
411
+ settings = load_settings()
412
+ store = _session_store(settings)
413
+ ok = store.delete(thread_id)
414
+ if ok:
415
+ print_info(f"deleted session metadata: {thread_id}")
416
+ else:
417
+ print_error(f"session not found: {thread_id}")
418
+ raise typer.Exit(code=1)
419
+
420
+
421
+ @sessions_app.command("rename")
422
+ def sessions_rename(
423
+ thread_id: str = typer.Argument(..., help="Session thread id"),
424
+ title: str = typer.Argument(..., help="New title"),
425
+ ) -> None:
426
+ """Rename a session."""
427
+ settings = load_settings()
428
+ store = _session_store(settings)
429
+ info = store.rename(thread_id, title)
430
+ if info is None:
431
+ print_error(f"session not found: {thread_id}")
432
+ raise typer.Exit(code=1)
433
+ print_info(f"renamed {thread_id} -> {info.title}")
434
+
435
+
436
+ @sessions_app.command("export")
437
+ def sessions_export(
438
+ thread_id: str = typer.Argument(..., help="Session thread id"),
439
+ fmt: str = typer.Option("md", "--format", "-f", help="md or json"),
440
+ out: Path | None = typer.Option(
441
+ None,
442
+ "--out",
443
+ "-o",
444
+ help="Output file (default: .coding-agent/exports/<thread_id>.md|json)",
445
+ ),
446
+ full: bool = typer.Option(
447
+ True,
448
+ "--full/--meta-only",
449
+ help="Include checkpoint transcript when available",
450
+ ),
451
+ stdout: bool = typer.Option(
452
+ False,
453
+ "--stdout",
454
+ help="Print export body to stdout instead of writing a file",
455
+ ),
456
+ ) -> None:
457
+ """Export session transcript to a file (default). Use --stdout to pipe."""
458
+ import json as _json
459
+
460
+ from synapse.sessions.transcript import (
461
+ export_transcript_json,
462
+ export_transcript_markdown,
463
+ load_messages_from_sqlite_file,
464
+ )
465
+
466
+ settings = load_settings()
467
+ store = _session_store(settings)
468
+ info = store.get(thread_id)
469
+ if info is None:
470
+ print_error(f"session not found: {thread_id}")
471
+ raise typer.Exit(code=1)
472
+
473
+ messages = []
474
+ if full and settings.checkpoint_backend == "sqlite":
475
+ messages = load_messages_from_sqlite_file(settings.checkpoint_path, thread_id)
476
+
477
+ fmt_n = "json" if fmt.lower() in {"json", "j"} else "md"
478
+ if fmt_n == "json":
479
+ if full:
480
+ data = export_transcript_json(
481
+ thread_id=thread_id,
482
+ title=info.title,
483
+ model=info.model,
484
+ messages=messages,
485
+ meta=info.to_dict(),
486
+ )
487
+ else:
488
+ data = info.to_dict()
489
+ text = _json.dumps(data, ensure_ascii=False, indent=2)
490
+ else:
491
+ if full:
492
+ text = export_transcript_markdown(
493
+ thread_id=thread_id,
494
+ title=info.title,
495
+ model=info.model,
496
+ messages=messages,
497
+ )
498
+ if not messages:
499
+ text = (store.export_markdown(thread_id) or "") + (
500
+ "\n## Transcript\n\n(no checkpoint messages found)\n"
501
+ )
502
+ else:
503
+ text = store.export_markdown(thread_id) or ""
504
+
505
+ if stdout:
506
+ console.print(text)
507
+ return
508
+
509
+ if out is None:
510
+ safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in (thread_id or "session"))
511
+ out = settings.export_dir() / f"{safe}.{fmt_n}"
512
+ out.parent.mkdir(parents=True, exist_ok=True)
513
+ out.write_text(text, encoding="utf-8")
514
+ print_info(f"exported -> {out}")
515
+
516
+
517
+ @sessions_app.command("search")
518
+ def sessions_search(
519
+ query: str = typer.Argument(..., help="Keywords / sub-string"),
520
+ limit: int = typer.Option(20, "--limit", "-n", help="Max results"),
521
+ ) -> None:
522
+ """Search session titles / summaries."""
523
+ settings = load_settings()
524
+ store = _session_store(settings)
525
+ hits = store.search(query, limit=limit)
526
+ console.print(format_session_table(hits))
527
+
528
+
529
+ # ---------------------------------------------------------------------------
530
+ # Sub-commands: models
531
+ # ---------------------------------------------------------------------------
532
+
533
+
534
+ @models_app.command("list")
535
+ def models_list() -> None:
536
+ """List configured downstream model profiles."""
537
+ from synapse.models.registry import registry_from_settings
538
+
539
+ settings = load_settings()
540
+ reg = registry_from_settings(settings)
541
+ profiles = reg.list_profiles()
542
+ if not profiles:
543
+ print_info("No model profiles configured. Edit models.json inside .coding-agent/")
544
+ return
545
+ for pf in profiles:
546
+ alias = pf.name
547
+ if alias == reg.default:
548
+ alias += " * (default)"
549
+ print_info(f" {alias:30s} provider={pf.provider:20s} model={pf.model:20s}")
550
+ if pf.thinking:
551
+ print_info(
552
+ f" thinking: budget={pf.thinking.get('budget')} type={pf.thinking.get('type')}"
553
+ )
554
+ if pf.profile_arg:
555
+ print_info(f" extra_body: profile={pf.profile_arg}")
556
+ if pf.max_tokens:
557
+ print_info(f" max_tokens={pf.max_tokens}")
558
+
559
+
560
+ # ---------------------------------------------------------------------------
561
+ # Sub-commands: mcp
562
+ # ---------------------------------------------------------------------------
563
+
564
+
565
+ @mcp_app.command("list")
566
+ def mcp_list() -> None:
567
+ """List configured MCP servers."""
568
+ from synapse.integrations.mcp_client import load_mcp_server_configs
569
+
570
+ settings = load_settings()
571
+ servers = load_mcp_server_configs(settings)
572
+ if not servers:
573
+ print_info("No MCP servers configured.")
574
+ return
575
+ for name, cfg in servers.items():
576
+ transport = cfg.get("transport", "http")
577
+ print_info(f" {name:25s} transport={transport}")
578
+
579
+
580
+ @mcp_app.command("test")
581
+ def mcp_test(
582
+ server: str = typer.Argument(..., help="MCP server name"),
583
+ ) -> None:
584
+ """Connect to an MCP server and print available tools."""
585
+ import asyncio
586
+
587
+ from synapse.integrations.mcp_client import connect_mcp, load_mcp_server_configs
588
+
589
+ settings = load_settings()
590
+ configs = load_mcp_server_configs(settings)
591
+ if server not in configs:
592
+ print_error(f"MCP server not configured: {server}")
593
+ raise typer.Exit(code=1)
594
+
595
+ async def _connect():
596
+ return await connect_mcp(server, configs[server])
597
+
598
+ try:
599
+ session = asyncio.run(_connect())
600
+ except Exception as exc: # noqa: BLE001
601
+ print_error(f"failed to connect to MCP server '{server}': {exc}")
602
+ raise typer.Exit(code=1) from exc
603
+
604
+ tools = session.get_tools()
605
+ if not tools:
606
+ print_info(f"No tools from MCP server '{server}'")
607
+ else:
608
+ print_info(f"MCP server '{server}' — {len(tools)} tool(s):")
609
+ for t in tools:
610
+ desc = getattr(t, "description", "") or ""
611
+ print_info(f" {t.name:30s} {desc}")
612
+
613
+
614
+ @tool_output_app.command("eval")
615
+ def tool_output_eval(
616
+ fixture: Path = typer.Argument(
617
+ ..., exists=True, readable=True, help="JSON array of offline eval cases"
618
+ ),
619
+ ) -> None:
620
+ """Evaluate deterministic retention and compression against fixed fixtures."""
621
+ from synapse.runtime.tool_output_eval import evaluate_cases, load_cases, summarize_results
622
+
623
+ summary = summarize_results(evaluate_cases(load_cases(fixture)))
624
+ console.print(f"cases: {summary['cases']}; passed: {summary['passed']}")
625
+ console.print(f"savings: {summary['savings_ratio']:.1%}")
626
+ console.print(f"required retention: {summary['required_retention']:.1%}")
627
+ for result in summary["results"]:
628
+ console.print(
629
+ f"- {result['id']}: {result['type']} via {result['transformer']}; "
630
+ f"savings={result['savings_ratio']:.1%}; passed={result['passed']}"
631
+ )
632
+ if summary["passed"] != summary["cases"]:
633
+ raise typer.Exit(code=1)
634
+
635
+
636
+ @tool_output_app.command("stats")
637
+ def tool_output_stats(
638
+ thread_id: str | None = typer.Option(None, "--thread", help="Restrict metrics to a thread id"),
639
+ ) -> None:
640
+ """Show local tool-output transformation savings and retention metrics."""
641
+ from synapse.tool_output.repository import ToolOutputRepository
642
+
643
+ settings = load_settings()
644
+ stats = ToolOutputRepository(settings.resolved_tool_output_db_path()).stats(thread_id=thread_id)
645
+ console.print("Tool output transformation")
646
+ console.print(f"outputs considered: {stats['outputs_considered']}")
647
+ console.print(f"transformed: {stats['transformed']}")
648
+ console.print(f"original bytes: {stats['original_bytes']}")
649
+ console.print(f"visible bytes: {stats['visible_bytes']}")
650
+ console.print(f"saved bytes: {stats['saved_bytes']} ({stats['savings_ratio']:.1%})")
651
+ console.print(f"retrieval bytes: {stats['retrieval_bytes']}")
652
+ console.print(
653
+ f"effective saved bytes: {stats['effective_saved_bytes']} "
654
+ f"({stats['effective_savings_ratio']:.1%})"
655
+ )
656
+ console.print(f"critical retention: {stats['critical_retention']:.1%}")
657
+ paths = stats["execution_paths"]
658
+ if paths:
659
+ console.print(
660
+ "execution paths: "
661
+ + ", ".join(f"{name}={count}" for name, count in sorted(paths.items()))
662
+ )
663
+
664
+
665
+ @tool_output_app.command("status")
666
+ def tool_output_status() -> None:
667
+ """Show whether tool-output transformation and native acceleration are usable."""
668
+ from synapse.tool_output.transformers import load_native_transformers
669
+
670
+ settings = load_settings()
671
+ native_requested = settings.enable_native_tool_output_compression
672
+ native_transformers = load_native_transformers(enabled=native_requested)
673
+ console.print("Tool output transformation status")
674
+ console.print(f"transform enabled: {settings.enable_tool_output_transform}")
675
+ console.print(f"threshold bytes: {settings.tool_output_transform_threshold_bytes}")
676
+ console.print(f"database: {settings.resolved_tool_output_db_path()}")
677
+ console.print(f"native enabled by config: {native_requested}")
678
+ console.print(f"native wheel loadable: {bool(native_transformers)}")
679
+ console.print(
680
+ "active native types: "
681
+ + (
682
+ ", ".join(sorted(next(iter(item.content_types)).value for item in native_transformers))
683
+ if native_transformers
684
+ else "none"
685
+ )
686
+ )
687
+
688
+
689
+ @tool_output_app.command("events")
690
+ def tool_output_events(
691
+ thread_id: str | None = typer.Option(None, "--thread", help="Restrict events to a thread id"),
692
+ limit: int = typer.Option(50, "--limit", "-n", min=1, max=500, help="Max recent events"),
693
+ ) -> None:
694
+ """Show recent transformation decisions and retrieval usage."""
695
+ from synapse.tool_output.repository import ToolOutputRepository
696
+
697
+ settings = load_settings()
698
+ events = ToolOutputRepository(settings.resolved_tool_output_db_path()).events(
699
+ thread_id=thread_id, limit=limit
700
+ )
701
+ if not events:
702
+ console.print("No tool-output events.")
703
+ return
704
+ console.print("Tool output transformation events")
705
+ for event in events:
706
+ saved = int(event["saved_bytes"])
707
+ console.print(
708
+ f"{event['created_at']} thread={event['thread_id']} "
709
+ f"type={event['content_type']} transformer={event['transformer']}\n"
710
+ f" outcome={event['outcome']} path={event.get('execution_path', 'unknown')} "
711
+ f"original={event['original_bytes']} visible={event['visible_bytes']} "
712
+ f"saved={saved} retrieved={event['retrieval_bytes']} "
713
+ f"critical={event['critical_retained']}/{event['critical_total']}\n"
714
+ f" ref={event['ref'] or '-'}"
715
+ )
716
+
717
+
718
+ # ---------------------------------------------------------------------------
719
+ # Misc
720
+ # ---------------------------------------------------------------------------
721
+
722
+
723
+ @app.command("version")
724
+ def version_cmd() -> None:
725
+ """Print package version."""
726
+ from synapse import __version__
727
+
728
+ console.print(__version__)
729
+
730
+
731
+ # ---------------------------------------------------------------------------
732
+ # Entry point
733
+ # ---------------------------------------------------------------------------
734
+
735
+
736
+ def main() -> None:
737
+ """Console script entrypoint."""
738
+ os.environ.setdefault("PYTHONUTF8", "1")
739
+ try:
740
+ from synapse.observability.startup_trace import ensure_started, mark
741
+
742
+ ensure_started()
743
+ mark("cli:main")
744
+ except Exception: # noqa: BLE001
745
+ pass
746
+ app()
747
+
748
+
749
+ if __name__ == "__main__":
750
+ main()