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
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()
|