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,5 @@
1
+ """Runtime settings schema, loading, and configuration paths."""
2
+
3
+ from synapse.settings.schema import Settings, bootstrap_project_env, find_dotenv, load_settings
4
+
5
+ __all__ = ["Settings", "bootstrap_project_env", "find_dotenv", "load_settings"]
@@ -0,0 +1,184 @@
1
+ """Layered synapse configuration roots.
2
+
3
+ Two primary layers (later overrides earlier on merge):
4
+
5
+ 1. User global: ``~/.synapse/``
6
+ 2. Project local: ``<workspace>/.synapse/``
7
+
8
+ Optional portable layer: directory next to a frozen / non-python exe.
9
+
10
+ File layout (either layer)::
11
+
12
+ .synapse/
13
+ models.json # model profiles + api_key (preferred over .env)
14
+ mcp.json # MCP servers
15
+ settings.json # non-secret Settings overrides (includes theme)
16
+ themes.json # optional custom UI themes (merged user → project)
17
+ system_prompt.md # coding agent system prompt (user/project override)
18
+ sessions.sqlite # project layer typically
19
+ checkpoints.sqlite
20
+ history
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import sys
27
+ from collections.abc import Iterable
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ SYNAPSE_DIRNAME = ".synapse"
32
+ MODELS_FILENAME = "models.json"
33
+ MCP_FILENAME = "mcp.json"
34
+ SETTINGS_FILENAME = "settings.json"
35
+ THEMES_FILENAME = "themes.json"
36
+
37
+
38
+ def user_config_dir() -> Path:
39
+ return (Path.home() / SYNAPSE_DIRNAME).expanduser().resolve()
40
+
41
+
42
+ def project_config_dir(workspace: Path | str | None = None) -> Path:
43
+ base = Path(workspace).expanduser().resolve() if workspace is not None else Path.cwd().resolve()
44
+ return (base / SYNAPSE_DIRNAME).resolve()
45
+
46
+
47
+ def executable_config_dirs() -> list[Path]:
48
+ """Dirs next to the running binary (frozen exe / non-python entrypoint)."""
49
+ out: list[Path] = []
50
+ try:
51
+ if getattr(sys, "frozen", False):
52
+ out.append(Path(sys.executable).resolve().parent)
53
+ meipass = getattr(sys, "_MEIPASS", None)
54
+ if meipass:
55
+ out.append(Path(meipass).resolve())
56
+ return out
57
+ exe = Path(sys.executable).resolve()
58
+ if exe.suffix.lower() == ".exe" and not exe.stem.lower().startswith("python"):
59
+ out.append(exe.parent)
60
+ if sys.argv:
61
+ argv0 = Path(sys.argv[0]).resolve()
62
+ if argv0.suffix.lower() == ".exe":
63
+ out.append(argv0.parent)
64
+ except Exception: # noqa: BLE001
65
+ return out
66
+ return out
67
+
68
+
69
+ def layered_config_dirs(
70
+ workspace: Path | str | None = None,
71
+ *,
72
+ include_exe: bool = True,
73
+ ) -> list[Path]:
74
+ """Ordered config dirs: user → (exe) → project.
75
+
76
+ Merge rule: later entries override earlier ones for the same keys/profiles.
77
+ """
78
+ dirs: list[Path] = [user_config_dir()]
79
+ if include_exe:
80
+ for d in executable_config_dirs():
81
+ # Treat portable bundle as a layer between user and project.
82
+ dirs.append(d / SYNAPSE_DIRNAME if d.name != SYNAPSE_DIRNAME else d)
83
+ dirs.append(project_config_dir(workspace))
84
+ seen: set[Path] = set()
85
+ ordered: list[Path] = []
86
+ for d in dirs:
87
+ try:
88
+ key = d.resolve()
89
+ except Exception: # noqa: BLE001
90
+ key = d
91
+ if key in seen:
92
+ continue
93
+ seen.add(key)
94
+ ordered.append(key)
95
+ return ordered
96
+
97
+
98
+ def config_search_roots(start: Path | None = None) -> list[Path]:
99
+ """Roots used for legacy `.env` discovery (workspace/cwd/exe upward)."""
100
+ roots: list[Path] = []
101
+ if start is not None:
102
+ try:
103
+ roots.append(Path(start).expanduser().resolve())
104
+ except Exception: # noqa: BLE001
105
+ roots.append(Path(start))
106
+ try:
107
+ roots.append(Path.cwd().resolve())
108
+ except Exception: # noqa: BLE001
109
+ roots.append(Path.cwd())
110
+ roots.extend(executable_config_dirs())
111
+ roots.append(Path.home())
112
+ seen: set[Path] = set()
113
+ ordered: list[Path] = []
114
+ for r in roots:
115
+ try:
116
+ key = r.resolve()
117
+ except Exception: # noqa: BLE001
118
+ key = r
119
+ if key in seen:
120
+ continue
121
+ seen.add(key)
122
+ ordered.append(key)
123
+ return ordered
124
+
125
+
126
+ def existing_files(dirs: Iterable[Path], filename: str) -> list[Path]:
127
+ out: list[Path] = []
128
+ for d in dirs:
129
+ p = Path(d) / filename
130
+ try:
131
+ if p.is_file():
132
+ out.append(p.resolve())
133
+ except OSError:
134
+ continue
135
+ return out
136
+
137
+
138
+ def load_json_object(path: Path) -> dict[str, Any]:
139
+ data = json.loads(path.read_text(encoding="utf-8"))
140
+ if not isinstance(data, dict):
141
+ raise ValueError(f"{path} root must be a JSON object")
142
+ return data
143
+
144
+
145
+ def deep_merge_dict(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
146
+ """Recursive dict merge; override wins. Lists are replaced, not concatenated."""
147
+ out = dict(base)
148
+ for key, value in override.items():
149
+ if (
150
+ key in out
151
+ and isinstance(out[key], dict)
152
+ and isinstance(value, dict)
153
+ ):
154
+ out[key] = deep_merge_dict(out[key], value)
155
+ else:
156
+ out[key] = value
157
+ return out
158
+
159
+
160
+ def load_layered_json(
161
+ filename: str,
162
+ workspace: Path | str | None = None,
163
+ ) -> tuple[dict[str, Any] | None, list[Path]]:
164
+ """Load and merge JSON objects from layered dirs. Returns (merged, paths_used)."""
165
+ paths = existing_files(layered_config_dirs(workspace), filename)
166
+ if not paths:
167
+ return None, []
168
+ merged: dict[str, Any] = {}
169
+ for path in paths:
170
+ merged = deep_merge_dict(merged, load_json_object(path))
171
+ return merged, paths
172
+
173
+
174
+ def load_layered_settings_file(workspace: Path | str | None = None) -> dict[str, Any]:
175
+ data, _ = load_layered_json(SETTINGS_FILENAME, workspace)
176
+ return dict(data or {})
177
+
178
+
179
+ def models_config_paths(workspace: Path | str | None = None) -> list[Path]:
180
+ return existing_files(layered_config_dirs(workspace), MODELS_FILENAME)
181
+
182
+
183
+ def mcp_config_paths(workspace: Path | str | None = None) -> list[Path]:
184
+ return existing_files(layered_config_dirs(workspace), MCP_FILENAME)
@@ -0,0 +1,464 @@
1
+ """Application settings for the coding agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Literal
7
+
8
+ from dotenv import load_dotenv
9
+ from pydantic import Field, field_validator
10
+ from pydantic_settings import (
11
+ BaseSettings,
12
+ PydanticBaseSettingsSource,
13
+ SettingsConfigDict,
14
+ )
15
+
16
+ from synapse.settings.config_paths import (
17
+ config_search_roots,
18
+ executable_config_dirs,
19
+ load_layered_settings_file,
20
+ project_config_dir,
21
+ user_config_dir,
22
+ )
23
+
24
+ # Re-export for callers/tests
25
+ __all__ = [
26
+ "Settings",
27
+ "bootstrap_project_env",
28
+ "config_search_roots",
29
+ "executable_config_dirs",
30
+ "find_dotenv",
31
+ "load_settings",
32
+ "project_config_dir",
33
+ "user_config_dir",
34
+ ]
35
+
36
+
37
+ def find_dotenv(start: Path | None = None) -> Path | None:
38
+ """Search for a `.env` file (legacy; prefer models.json api_key).
39
+
40
+ Looks under workspace/cwd/exe/home upward for a few levels.
41
+ """
42
+ seen: set[Path] = set()
43
+ for base in config_search_roots(start):
44
+ cur = base
45
+ for _ in range(6):
46
+ if cur in seen:
47
+ break
48
+ seen.add(cur)
49
+ env_path = cur / ".env"
50
+ if env_path.is_file():
51
+ return env_path
52
+ if cur.parent == cur:
53
+ break
54
+ cur = cur.parent
55
+ return None
56
+
57
+
58
+ def bootstrap_project_env(project_root: Path | None = None) -> Path | None:
59
+ """Optionally load legacy `.env` (override process env).
60
+
61
+ Preferred secret location is ``api_key`` inside layered ``models.json``.
62
+ ``.env`` remains supported for migration/CI only.
63
+ """
64
+ env_path = find_dotenv(project_root)
65
+ if env_path is None:
66
+ return None
67
+ load_dotenv(dotenv_path=env_path, override=True, encoding="utf-8")
68
+ return env_path
69
+
70
+
71
+ class Settings(BaseSettings):
72
+ """Runtime configuration from layered JSON + optional env / legacy `.env`."""
73
+
74
+ model_config = SettingsConfigDict(
75
+ env_file=".env",
76
+ env_file_encoding="utf-8",
77
+ extra="ignore",
78
+ case_sensitive=False,
79
+ env_ignore_empty=True,
80
+ )
81
+
82
+ # Model
83
+ model: str = Field(default="openai:gpt-4.1", validation_alias="MODEL")
84
+ openai_api_key: str | None = Field(default=None, validation_alias="OPENAI_API_KEY")
85
+ openai_base_url: str | None = Field(default=None, validation_alias="OPENAI_BASE_URL")
86
+ openai_websocket: bool = Field(default=False, validation_alias="OPENAI_WEBSOCKET")
87
+ anthropic_api_key: str | None = Field(default=None, validation_alias="ANTHROPIC_API_KEY")
88
+ # Multi-model catalog (JSON file or inline JSON). Empty => legacy single model.
89
+ models_config_path: Path | None = Field(default=None, validation_alias="AGENT_MODELS_CONFIG")
90
+ models_json: str | None = Field(default=None, validation_alias="MODELS_JSON")
91
+ # Selected profile alias (optional; falls back to model / registry default)
92
+ active_model: str | None = Field(default=None, validation_alias="AGENT_ACTIVE_MODEL")
93
+ # Independent OpenAI-compatible image-to-text model configuration.
94
+ # Example: {"model": "qwen-vl-max", "base_url": "...", "api_key_env": "VISION_API_KEY"}
95
+ vision_model: dict[str, Any] | None = Field(default=None, validation_alias="VISION_MODEL")
96
+
97
+ # Workspace
98
+ workspace: Path = Field(default_factory=Path.cwd, validation_alias="WORKSPACE")
99
+ shell_timeout: int = Field(default=120, validation_alias="SHELL_TIMEOUT")
100
+ max_output_bytes: int = Field(default=100_000, validation_alias="MAX_OUTPUT_BYTES")
101
+ inherit_env: bool = Field(default=True, validation_alias="INHERIT_ENV")
102
+ virtual_mode: bool = Field(default=True, validation_alias="VIRTUAL_MODE")
103
+ # Shell executable. None => auto-detect (pwsh on Windows, bash elsewhere).
104
+ # Values: pwsh | powershell | cmd | bash | system | absolute path | None (auto)
105
+ shell_executable: str | None = Field(default=None, validation_alias="SHELL_EXECUTABLE")
106
+ # Decode shell stdout/stderr with this codec (avoids GBK UnicodeDecodeError on Windows).
107
+ shell_encoding: str = Field(default="utf-8", validation_alias="SHELL_ENCODING")
108
+ shell_encoding_errors: str = Field(default="replace", validation_alias="SHELL_ENCODING_ERRORS")
109
+
110
+ # Approval: default OFF, auto-pass (user requirement)
111
+ require_approval: bool = Field(default=False, validation_alias="AGENT_REQUIRE_APPROVAL")
112
+ auto_approve: bool = Field(default=True, validation_alias="AGENT_AUTO_APPROVE")
113
+ safety_profile: str = Field(default="dev-autopass", validation_alias="AGENT_SAFETY_PROFILE")
114
+
115
+ # Safety blacklist is advisory when auto_approve=True; still used for warnings
116
+ enable_command_blacklist: bool = Field(
117
+ default=True, validation_alias="ENABLE_COMMAND_BLACKLIST"
118
+ )
119
+ enable_compact_tool: bool = Field(default=True, validation_alias="AGENT_ENABLE_COMPACT_TOOL")
120
+ # Rewrite large tool results through a content-aware, reversible pipeline.
121
+ # Originals are stored in a content-addressed SQLite database only after a
122
+ # successful rewrite; small results bypass both transformation and storage.
123
+ enable_tool_output_transform: bool = Field(
124
+ default=True, validation_alias="AGENT_ENABLE_TOOL_OUTPUT_TRANSFORM"
125
+ )
126
+ tool_output_transform_threshold_bytes: int = Field(
127
+ default=512, validation_alias="AGENT_TOOL_OUTPUT_TRANSFORM_THRESHOLD_BYTES"
128
+ )
129
+ tool_output_disabled_types: list[str] = Field(
130
+ default_factory=list, validation_alias="AGENT_TOOL_OUTPUT_DISABLED_TYPES"
131
+ )
132
+ tool_output_transform_plugins: list[str] = Field(
133
+ default_factory=list, validation_alias="AGENT_TOOL_OUTPUT_TRANSFORM_PLUGINS"
134
+ )
135
+ # Prefer the optional prebuilt native compression wheel when installed.
136
+ # Python transformers remain the automatic fallback on unsupported platforms.
137
+ enable_native_tool_output_compression: bool = Field(
138
+ default=True, validation_alias="AGENT_ENABLE_NATIVE_TOOL_OUTPUT_COMPRESSION"
139
+ )
140
+
141
+ # Session / checkpoint
142
+ checkpoint_backend: Literal["memory", "sqlite"] = Field(
143
+ default="sqlite", validation_alias="CHECKPOINT_BACKEND"
144
+ )
145
+ checkpoint_path: Path = Field(
146
+ default=Path(".synapse/checkpoints.sqlite"),
147
+ validation_alias="CHECKPOINT_PATH",
148
+ )
149
+ sessions_path: Path | None = Field(default=None, validation_alias="SESSIONS_PATH")
150
+
151
+ # Project memory / skills (paths relative to project root or absolute)
152
+ enable_memory: bool = Field(default=False, validation_alias="AGENT_ENABLE_MEMORY")
153
+ memory_paths: list[str] = Field(
154
+ default_factory=lambda: ["MEMORY.md", ".synapse/MEMORY.md"]
155
+ )
156
+ skills_paths: list[str] = Field(default_factory=lambda: ["skills"])
157
+
158
+ # Framework wiring (deepagents native)
159
+ enable_subagents: bool = Field(default=False, validation_alias="AGENT_ENABLE_SUBAGENTS")
160
+ subagent_tester_model: str | None = Field(
161
+ default=None, validation_alias="AGENT_SUBAGENT_TESTER_MODEL"
162
+ )
163
+ subagent_reviewer_model: str | None = Field(
164
+ default=None, validation_alias="AGENT_SUBAGENT_REVIEWER_MODEL"
165
+ )
166
+ readonly: bool = Field(default=False, validation_alias="AGENT_READONLY")
167
+ excluded_tools: list[str] = Field(default_factory=list, validation_alias="AGENT_EXCLUDED_TOOLS")
168
+ enable_fs_permissions: bool = Field(
169
+ default=False, validation_alias="AGENT_ENABLE_FS_PERMISSIONS"
170
+ )
171
+ deny_fs_paths: list[str] = Field(default_factory=list, validation_alias="AGENT_DENY_FS_PATHS")
172
+
173
+ # MCP extension (tools= injection)
174
+ enable_mcp: bool = Field(default=True, validation_alias="AGENT_ENABLE_MCP")
175
+ # When False (default), skip MCP connect during agent build; TUI attaches later.
176
+ mcp_eager: bool = Field(default=False, validation_alias="AGENT_MCP_EAGER")
177
+ # TUI: show UI first, build agent in a background thread.
178
+ tui_defer_agent: bool = Field(default=True, validation_alias="AGENT_TUI_DEFER_AGENT")
179
+ mcp_config_path: Path | None = Field(default=None, validation_alias="AGENT_MCP_CONFIG")
180
+
181
+ mcp_servers_json: str | None = Field(default=None, validation_alias="MCP_SERVERS_JSON")
182
+
183
+ # Observability
184
+ langsmith_tracing: bool = Field(default=False, validation_alias="LANGSMITH_TRACING")
185
+ langsmith_api_key: str | None = Field(default=None, validation_alias="LANGSMITH_API_KEY")
186
+ langsmith_project: str = Field(default="coding-agent", validation_alias="LANGSMITH_PROJECT")
187
+
188
+ # Debug
189
+ debug: bool = Field(default=False, validation_alias="AGENT_DEBUG")
190
+
191
+ # Streaming / concurrency
192
+ token_stream: bool = Field(default=True, validation_alias="TOKEN_STREAM")
193
+ parallel_tool_calls: bool = Field(default=True, validation_alias="PARALLEL_TOOL_CALLS")
194
+ max_concurrency: int = Field(default=8, validation_alias="MAX_CONCURRENCY")
195
+ # langchain-openai async per-chunk silence timeout (seconds).
196
+ # Default None disables StreamChunkTimeoutError (library default is 120s), which
197
+ # otherwise aborts long reasoning / slow OpenAI-compatible gateways mid-turn.
198
+ # Set a positive number to re-enable; 0 also disables. Profile params can override.
199
+ stream_chunk_timeout: float | None = Field(
200
+ default=None, validation_alias="STREAM_CHUNK_TIMEOUT"
201
+ )
202
+ # TUI / CLI appearance (see synapse.ui.theme).
203
+ # Terminal inherit (transparent): ansi (aliases: inherit, terminal, auto)
204
+ # Built-in dark: cursor-dark github-dark dracula nord solarized-dark
205
+ # catppuccin-mocha one-dark
206
+ # Built-in light: solarized-light github-light one-light gruvbox-light
207
+ # catppuccin-latte tokyo-night-light ayu-light nord-light
208
+ # Custom palettes: layered .coding-agent/themes.json
209
+ theme: str = Field(default="cursor-dark", validation_alias="AGENT_THEME")
210
+
211
+ # TUI tool timeline: keep tool-detail rows expanded under group headers.
212
+ # Set false to auto-collapse batches after they finish (summary only).
213
+ tool_details_expanded: bool = Field(
214
+ default=True, validation_alias="AGENT_TOOL_DETAILS_EXPANDED"
215
+ )
216
+ # TUI session recap: after idle following a completed turn, show one-line summary.
217
+ session_recap_enabled: bool = Field(default=True, validation_alias="AGENT_SESSION_RECAP")
218
+ session_recap_idle_seconds: float = Field(
219
+ default=180.0, validation_alias="AGENT_SESSION_RECAP_IDLE_SECONDS"
220
+ )
221
+ session_recap_min_turns: int = Field(
222
+ default=3, validation_alias="AGENT_SESSION_RECAP_MIN_TURNS"
223
+ )
224
+ # DeepSeek V4 thinking / other reasoning models via OpenAI-compatible API
225
+ enable_thinking: bool = Field(default=True, validation_alias="ENABLE_THINKING")
226
+ reasoning_effort: str = Field(default="high", validation_alias="REASONING_EFFORT")
227
+
228
+ @classmethod
229
+ def settings_customise_sources(
230
+ cls,
231
+ settings_cls: type[BaseSettings],
232
+ init_settings: PydanticBaseSettingsSource,
233
+ env_settings: PydanticBaseSettingsSource,
234
+ dotenv_settings: PydanticBaseSettingsSource,
235
+ file_secret_settings: PydanticBaseSettingsSource,
236
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
237
+ """Prefer project `.env` over process/user environment variables.
238
+
239
+ Default pydantic-settings order lets a stale system OPENAI_API_KEY
240
+ override the project-local key and cause 401 against private gateways.
241
+ """
242
+ return (
243
+ init_settings,
244
+ dotenv_settings,
245
+ env_settings,
246
+ file_secret_settings,
247
+ )
248
+
249
+ @field_validator("openai_api_key", "anthropic_api_key", "openai_base_url", mode="before")
250
+ @classmethod
251
+ def _strip_secret_like(cls, value: object) -> object:
252
+ if isinstance(value, str):
253
+ text = value.strip().strip("\"'")
254
+ return text or None
255
+ return value
256
+
257
+ @field_validator("model", "active_model", "theme", mode="before")
258
+ @classmethod
259
+ def _strip_model(cls, value: object) -> object:
260
+ if isinstance(value, str):
261
+ return value.strip().strip("\"'")
262
+ return value
263
+
264
+ @field_validator("workspace", "checkpoint_path", mode="before")
265
+ @classmethod
266
+ def _coerce_required_path(cls, value: object) -> Path:
267
+ if value is None or value == "":
268
+ return Path.cwd() if value is None else Path(value)
269
+ return Path(value).expanduser().resolve()
270
+
271
+ @field_validator(
272
+ "sessions_path",
273
+ "models_config_path",
274
+ "mcp_config_path",
275
+ mode="before",
276
+ )
277
+ @classmethod
278
+ def _coerce_optional_path(cls, value: object) -> Path | None:
279
+ if value is None or value == "":
280
+ return None
281
+ # Keep relative paths relative so load_settings can resolve against workspace.
282
+ return Path(str(value)).expanduser()
283
+
284
+ @field_validator(
285
+ "excluded_tools",
286
+ "deny_fs_paths",
287
+ "tool_output_disabled_types",
288
+ "tool_output_transform_plugins",
289
+ mode="before",
290
+ )
291
+ @classmethod
292
+ def _split_csv(cls, value: object) -> object:
293
+ if value is None:
294
+ return []
295
+ if isinstance(value, str):
296
+ return [part.strip() for part in value.split(",") if part.strip()]
297
+ return value
298
+
299
+ @field_validator("stream_chunk_timeout", mode="before")
300
+ @classmethod
301
+ def _coerce_stream_chunk_timeout(cls, value: object) -> object:
302
+ if value is None or value == "":
303
+ return None
304
+ if isinstance(value, str):
305
+ text = value.strip().casefold()
306
+ if text in {"none", "null", "off", "disable", "disabled"}:
307
+ return None
308
+ return float(text)
309
+ return value
310
+
311
+ def ensure_dirs(self) -> None:
312
+ """Create local state directories if needed."""
313
+ self.checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
314
+ if self.sessions_path is not None:
315
+ self.sessions_path.parent.mkdir(parents=True, exist_ok=True)
316
+ self.resolved_tool_output_db_path().parent.mkdir(parents=True, exist_ok=True)
317
+
318
+ def resolved_sessions_path(self) -> Path:
319
+ if self.sessions_path is not None:
320
+ return Path(self.sessions_path).expanduser().resolve()
321
+ return self.checkpoint_path.parent / "sessions.sqlite"
322
+
323
+ def resolved_tool_output_db_path(self) -> Path:
324
+ """SQLite database for reversible transformed tool outputs."""
325
+ return self.resolved_sessions_path().parent / "tool-outputs.sqlite"
326
+
327
+ def resolved_memory_paths(self, project_root: Path | None = None) -> list[str]:
328
+ root = project_root or Path.cwd()
329
+ resolved: list[str] = []
330
+ for p in self.memory_paths:
331
+ path = Path(p) if Path(p).is_absolute() else (root / p)
332
+ resolved.append(str(path.resolve()))
333
+ return resolved
334
+
335
+ def resolved_skills_paths(self, project_root: Path | None = None) -> list[str]:
336
+ root = project_root or Path.cwd()
337
+ paths: list[str] = []
338
+ for p in self.skills_paths:
339
+ path = Path(p) if Path(p).is_absolute() else (root / p)
340
+ if path.exists():
341
+ paths.append(str(path.resolve()))
342
+ return paths
343
+
344
+ def mask_openai_key(self) -> str:
345
+ key = self.openai_api_key or ""
346
+ if not key:
347
+ return "<empty>"
348
+ if len(key) <= 8:
349
+ return "***"
350
+ return f"len={len(key)} head={key[:4]}***tail=***{key[-4:]}"
351
+
352
+
353
+ def load_settings(**overrides: Any) -> Settings:
354
+ """Load settings from layered config + optional env/legacy `.env`.
355
+
356
+ Layers (later wins for settings.json / models / mcp):
357
+ 1. ``~/.coding-agent/``
358
+ 2. exe-adjacent ``.coding-agent/`` (portable bundle)
359
+ 3. ``<workspace>/.coding-agent/``
360
+
361
+ Secrets: prefer ``api_key`` in ``models.json``. ``.env`` is legacy only.
362
+ """
363
+ project_root = overrides.get("workspace")
364
+ root_path = Path(project_root).expanduser().resolve() if project_root is not None else None
365
+ # Legacy: optional .env for migration
366
+ bootstrap_project_env(root_path)
367
+
368
+ env_path = find_dotenv(root_path)
369
+ if env_path is not None:
370
+ settings = Settings(_env_file=str(env_path))
371
+ else:
372
+ settings = Settings(_env_file=None)
373
+
374
+ # Layered settings.json (user → project). Applied before CLI overrides.
375
+ workspace_hint = root_path or settings.workspace
376
+ file_cfg = load_layered_settings_file(workspace_hint)
377
+ if file_cfg:
378
+ allowed = set(Settings.model_fields.keys())
379
+ updates: dict[str, Any] = {}
380
+ for key, value in file_cfg.items():
381
+ if key in allowed and value is not None:
382
+ updates[key] = value
383
+ if updates:
384
+ # Path-like coercion for known path fields
385
+ for pk in (
386
+ "workspace",
387
+ "checkpoint_path",
388
+ "sessions_path",
389
+ "models_config_path",
390
+ "mcp_config_path",
391
+ ):
392
+ if pk in updates and updates[pk] is not None:
393
+ updates[pk] = Path(str(updates[pk])).expanduser()
394
+ settings = settings.model_copy(update=updates)
395
+
396
+ if overrides:
397
+ # Non-path fields: ignore None (means "leave default / env").
398
+ normal = {
399
+ k: v
400
+ for k, v in overrides.items()
401
+ if v is not None
402
+ and k
403
+ not in {
404
+ "models_config_path",
405
+ "mcp_config_path",
406
+ "sessions_path",
407
+ "workspace",
408
+ "checkpoint_path",
409
+ }
410
+ }
411
+ if normal:
412
+ settings = settings.model_copy(update=normal)
413
+ # Optional paths may be explicitly cleared with None.
414
+ pathish = {
415
+ k: overrides[k]
416
+ for k in ("models_config_path", "mcp_config_path", "sessions_path")
417
+ if k in overrides
418
+ }
419
+ if pathish:
420
+ coerced: dict[str, Any] = {}
421
+ for k, v in pathish.items():
422
+ if v is None or v == "":
423
+ coerced[k] = None
424
+ else:
425
+ p = Path(v).expanduser()
426
+ coerced[k] = p.resolve() if p.is_absolute() else p
427
+ settings = settings.model_copy(update=coerced)
428
+ if "workspace" in overrides and overrides["workspace"] is not None:
429
+ settings.workspace = Path(overrides["workspace"]).expanduser().resolve()
430
+ if "checkpoint_path" in overrides and overrides["checkpoint_path"] is not None:
431
+ settings.checkpoint_path = Path(overrides["checkpoint_path"]).expanduser().resolve()
432
+
433
+ # Default state files live under project .synapse when possible.
434
+ proj = project_config_dir(settings.workspace)
435
+ path_updates: dict[str, Any] = {}
436
+ try:
437
+ default_ckpt = (Path.cwd() / ".synapse" / "checkpoints.sqlite").resolve()
438
+ except Exception: # noqa: BLE001
439
+ default_ckpt = None
440
+ if default_ckpt is not None and settings.checkpoint_path == default_ckpt:
441
+ path_updates["checkpoint_path"] = proj / "checkpoints.sqlite"
442
+ if settings.sessions_path is None:
443
+ path_updates["sessions_path"] = proj / "sessions.sqlite"
444
+
445
+ # Resolve relative models/mcp config paths against workspace (not process cwd).
446
+ if settings.models_config_path is not None:
447
+ mp = Path(settings.models_config_path).expanduser()
448
+ if not mp.is_absolute():
449
+ path_updates["models_config_path"] = (Path(settings.workspace) / mp).resolve()
450
+ if settings.mcp_config_path is not None:
451
+ cp = Path(settings.mcp_config_path).expanduser()
452
+ if not cp.is_absolute():
453
+ path_updates["mcp_config_path"] = (Path(settings.workspace) / cp).resolve()
454
+ if path_updates:
455
+ settings = settings.model_copy(update=path_updates)
456
+
457
+ settings.ensure_dirs()
458
+
459
+ # Layered models.json → selected profile (api_key / base_url / thinking).
460
+ # Model profile resolution is runtime configuration, while UI activation is
461
+ # intentionally left to the concrete frontend bootstrap.
462
+ from synapse.models.registry import apply_models_config_to_settings
463
+
464
+ return apply_models_config_to_settings(settings)