symphony-code 0.0.1__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 (169) hide show
  1. coding_agent/__init__.py +20 -0
  2. coding_agent/_lazy.py +17 -0
  3. coding_agent/addons/__init__.py +35 -0
  4. coding_agent/addons/approvals.py +224 -0
  5. coding_agent/addons/compaction/__init__.py +36 -0
  6. coding_agent/addons/compaction/addon.py +88 -0
  7. coding_agent/addons/compaction/compactor.py +138 -0
  8. coding_agent/addons/compaction/prompts.py +20 -0
  9. coding_agent/addons/compaction/transcript.py +118 -0
  10. coding_agent/addons/evaluation/__init__.py +68 -0
  11. coding_agent/addons/evaluation/addon.py +199 -0
  12. coding_agent/addons/evaluation/evaluators.py +245 -0
  13. coding_agent/addons/evaluation/handlers.py +66 -0
  14. coding_agent/addons/evaluation/policy.py +277 -0
  15. coding_agent/addons/evaluation/protocol.py +192 -0
  16. coding_agent/addons/evaluation/state.py +284 -0
  17. coding_agent/addons/langfuse/__init__.py +24 -0
  18. coding_agent/addons/langfuse/addon.py +465 -0
  19. coding_agent/addons/langfuse/install.py +79 -0
  20. coding_agent/addons/langfuse/serialize.py +158 -0
  21. coding_agent/addons/learning/__init__.py +33 -0
  22. coding_agent/addons/learning/addon.py +181 -0
  23. coding_agent/addons/learning/loop.py +247 -0
  24. coding_agent/addons/learning/prompts.py +26 -0
  25. coding_agent/addons/learning/sanitize.py +64 -0
  26. coding_agent/addons/learning/store.py +522 -0
  27. coding_agent/addons/manager/__init__.py +18 -0
  28. coding_agent/addons/manager/bridge.py +74 -0
  29. coding_agent/addons/manager/discovery.py +34 -0
  30. coding_agent/addons/manager/report.py +33 -0
  31. coding_agent/addons/manager/runtime.py +76 -0
  32. coding_agent/addons/manager/tool_changes.py +16 -0
  33. coding_agent/addons/persistence/__init__.py +41 -0
  34. coding_agent/addons/persistence/active.py +132 -0
  35. coding_agent/addons/persistence/artifacts.py +110 -0
  36. coding_agent/addons/persistence/collection.py +166 -0
  37. coding_agent/addons/persistence/compatibility.py +30 -0
  38. coding_agent/addons/persistence/images.py +133 -0
  39. coding_agent/addons/persistence/jsonl.py +1195 -0
  40. coding_agent/addons/persistence/presentation.py +104 -0
  41. coding_agent/addons/plugins/__init__.py +19 -0
  42. coding_agent/addons/plugins/install.py +37 -0
  43. coding_agent/addons/plugins/manager.py +140 -0
  44. coding_agent/addons/plugins/models.py +50 -0
  45. coding_agent/addons/skills/__init__.py +28 -0
  46. coding_agent/addons/skills/addon.py +26 -0
  47. coding_agent/addons/skills/args.py +78 -0
  48. coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
  49. coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
  50. coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
  51. coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
  52. coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
  53. coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
  54. coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
  55. coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
  56. coding_agent/addons/skills/frontmatter.py +57 -0
  57. coding_agent/addons/skills/models.py +57 -0
  58. coding_agent/addons/skills/registry.py +109 -0
  59. coding_agent/addons/skills/tool.py +54 -0
  60. coding_agent/addons/voice/__init__.py +5 -0
  61. coding_agent/addons/voice/addon.py +106 -0
  62. coding_agent/addons/voice/credentials.py +42 -0
  63. coding_agent/addons/voice/playback.py +42 -0
  64. coding_agent/addons/voice/speak.py +287 -0
  65. coding_agent/addons/voice/stream.py +651 -0
  66. coding_agent/addons/voice/summary.py +126 -0
  67. coding_agent/agent.py +523 -0
  68. coding_agent/bench/__init__.py +1 -0
  69. coding_agent/bench/cli.py +200 -0
  70. coding_agent/bench/patch.py +78 -0
  71. coding_agent/commands/__init__.py +5 -0
  72. coding_agent/commands/catalog.py +179 -0
  73. coding_agent/commands/jev_argument.py +40 -0
  74. coding_agent/config/__init__.py +51 -0
  75. coding_agent/config/config.py +376 -0
  76. coding_agent/config/credentials.py +153 -0
  77. coding_agent/config/personalities.json +34 -0
  78. coding_agent/config/personalities.py +170 -0
  79. coding_agent/config/prompts.py +67 -0
  80. coding_agent/protocols/__init__.py +5 -0
  81. coding_agent/protocols/acp/README.md +36 -0
  82. coding_agent/protocols/acp/__init__.py +27 -0
  83. coding_agent/protocols/acp/mapping.py +164 -0
  84. coding_agent/protocols/acp/server.py +261 -0
  85. coding_agent/protocols/acp/transport.py +72 -0
  86. coding_agent/protocols/stdio/README.md +36 -0
  87. coding_agent/protocols/stdio/__init__.py +37 -0
  88. coding_agent/protocols/stdio/catalog.py +49 -0
  89. coding_agent/protocols/stdio/commands.py +218 -0
  90. coding_agent/protocols/stdio/server.py +181 -0
  91. coding_agent/protocols/stdio/transport.py +71 -0
  92. coding_agent/py.typed +0 -0
  93. coding_agent/resources/__init__.py +5 -0
  94. coding_agent/resources/usage.py +264 -0
  95. coding_agent/run/__init__.py +1 -0
  96. coding_agent/run/cli.py +99 -0
  97. coding_agent/run/detach.py +37 -0
  98. coding_agent/run/interrupted.py +67 -0
  99. coding_agent/run/log_sink.py +46 -0
  100. coding_agent/tools/__init__.py +73 -0
  101. coding_agent/tools/ask_user.py +61 -0
  102. coding_agent/tools/base.py +136 -0
  103. coding_agent/tools/bash.py +288 -0
  104. coding_agent/tools/bash_jobs.py +116 -0
  105. coding_agent/tools/generate_image.py +123 -0
  106. coding_agent/tools/memory.py +100 -0
  107. coding_agent/tools/patch.py +218 -0
  108. coding_agent/tools/read_file.py +167 -0
  109. coding_agent/tools/search.py +433 -0
  110. coding_agent/tools/web_search.py +151 -0
  111. coding_agent/tools/write_file.py +40 -0
  112. coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
  113. coding_agent/tui/__init__.py +22 -0
  114. coding_agent/tui/__main__.py +122 -0
  115. coding_agent/tui/app.py +456 -0
  116. coding_agent/tui/chrome/__init__.py +33 -0
  117. coding_agent/tui/chrome/footer.py +274 -0
  118. coding_agent/tui/chrome/topbar.py +259 -0
  119. coding_agent/tui/commands/__init__.py +45 -0
  120. coding_agent/tui/commands/catalog.py +35 -0
  121. coding_agent/tui/commands/manager.py +557 -0
  122. coding_agent/tui/composer/__init__.py +18 -0
  123. coding_agent/tui/composer/input.py +219 -0
  124. coding_agent/tui/composer/slash_menu.py +341 -0
  125. coding_agent/tui/composer/surface.py +330 -0
  126. coding_agent/tui/composer/voice.py +267 -0
  127. coding_agent/tui/composer/voice_mode.py +491 -0
  128. coding_agent/tui/runtime/__init__.py +23 -0
  129. coding_agent/tui/runtime/events.py +728 -0
  130. coding_agent/tui/runtime/sink.py +115 -0
  131. coding_agent/tui/runtime/state.py +145 -0
  132. coding_agent/tui/runtime/subagent.py +531 -0
  133. coding_agent/tui/runtime/turn.py +206 -0
  134. coding_agent/tui/screens/__init__.py +54 -0
  135. coding_agent/tui/screens/ask.py +69 -0
  136. coding_agent/tui/screens/context.py +195 -0
  137. coding_agent/tui/screens/dashboard.py +91 -0
  138. coding_agent/tui/screens/diff.py +302 -0
  139. coding_agent/tui/screens/extensions.py +68 -0
  140. coding_agent/tui/screens/file_selector.py +167 -0
  141. coding_agent/tui/screens/history.py +458 -0
  142. coding_agent/tui/screens/langfuse.py +142 -0
  143. coding_agent/tui/screens/learning.py +113 -0
  144. coding_agent/tui/screens/modal.py +131 -0
  145. coding_agent/tui/screens/onboard.py +455 -0
  146. coding_agent/tui/screens/resume.py +163 -0
  147. coding_agent/tui/theme/__init__.py +49 -0
  148. coding_agent/tui/theme/load.py +325 -0
  149. coding_agent/tui/theme/style.py +95 -0
  150. coding_agent/tui/theme/theme.toml +1687 -0
  151. coding_agent/tui/tools/__init__.py +53 -0
  152. coding_agent/tui/tools/activity.py +184 -0
  153. coding_agent/tui/tools/calls.py +560 -0
  154. coding_agent/tui/tools/code_view.py +52 -0
  155. coding_agent/tui/tools/images.py +250 -0
  156. coding_agent/tui/tools/labels.py +135 -0
  157. coding_agent/tui/tools/modal.py +137 -0
  158. coding_agent/tui/tools/partial_args.py +19 -0
  159. coding_agent/tui/tools/snapshots.py +382 -0
  160. coding_agent/tui/transcript/__init__.py +36 -0
  161. coding_agent/tui/transcript/messages.py +408 -0
  162. coding_agent/tui/transcript/process.py +504 -0
  163. coding_agent/tui/transcript/surface.py +345 -0
  164. coding_agent/tui/transcript/thinking_markdown.py +314 -0
  165. symphony_code-0.0.1.dist-info/METADATA +66 -0
  166. symphony_code-0.0.1.dist-info/RECORD +169 -0
  167. symphony_code-0.0.1.dist-info/WHEEL +4 -0
  168. symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
  169. symphony_code-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,376 @@
1
+ """Product configuration loaded from a spawn-time settings file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any, Final, Literal, Union
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
11
+
12
+ from core_harness.config import HarnessConfig
13
+ from coding_agent.addons.plugins.models import PluginConfig
14
+ from coding_agent.config.prompts import SUBAGENT_SYSTEM_PROMPT
15
+
16
+ SPAWN_SETTINGS_NAME = "config.json"
17
+ SettingsSource = Union["CodingAgentConfig", str, Path]
18
+
19
+
20
+ class ApprovalConfig(BaseModel):
21
+ model_config = ConfigDict(extra="forbid")
22
+
23
+ mode: Literal["ask", "always_allow"] = "ask"
24
+ allow_answers: set[str] = Field(
25
+ default_factory=lambda: {"y", "yes", "allow", "allow once", "a"}
26
+ )
27
+ broad_patch_chars: int = Field(default=400, ge=1)
28
+ require_for_bash: bool = True
29
+ require_for_overwrite: bool = True
30
+ require_for_broad_patch: bool = True
31
+ require_for_web_search: bool = True
32
+ # fnmatch patterns on the bash command, on the path for write_file /
33
+ # patch / generate_image, or on ``web_search:<query>`` for a web search.
34
+ # deny wins over every mode, including unattended.
35
+ allow: list[str] = Field(default_factory=list)
36
+ deny: list[str] = Field(default_factory=list)
37
+
38
+
39
+ class BashConfig(BaseModel):
40
+ model_config = ConfigDict(extra="forbid")
41
+
42
+ default_timeout_seconds: int = Field(default=30, ge=1)
43
+ max_timeout_seconds: int = Field(default=120, ge=1)
44
+ max_output_bytes: int = Field(default=32000, ge=1)
45
+ max_background_seconds: int = Field(default=3600, ge=1)
46
+
47
+
48
+ class ReadFileConfig(BaseModel):
49
+ model_config = ConfigDict(extra="forbid")
50
+
51
+ max_text_bytes: int = Field(default=32000, ge=1)
52
+ max_image_bytes: int = Field(default=8000000, ge=1)
53
+
54
+
55
+ class SearchConfig(BaseModel):
56
+ model_config = ConfigDict(extra="forbid")
57
+
58
+ default_max_results: int = Field(default=100, ge=1)
59
+ default_max_line_chars: int = Field(default=240, ge=1)
60
+ binary_sniff_bytes: int = Field(default=8192, ge=1)
61
+
62
+
63
+ class WebSearchConfig(BaseModel):
64
+ model_config = ConfigDict(extra="forbid")
65
+
66
+ # False removes the tool, so no query can leave the machine through it.
67
+ enabled: bool = True
68
+ default_max_results: int = Field(default=5, ge=1, le=10)
69
+ timeout_seconds: int = Field(default=20, ge=1)
70
+ max_output_chars: int = Field(default=12000, ge=1)
71
+
72
+
73
+ class ToolsConfig(BaseModel):
74
+ model_config = ConfigDict(extra="forbid")
75
+
76
+ bash: BashConfig = Field(default_factory=BashConfig)
77
+ read_file: ReadFileConfig = Field(default_factory=ReadFileConfig)
78
+ search: SearchConfig = Field(default_factory=SearchConfig)
79
+ web_search: WebSearchConfig = Field(default_factory=WebSearchConfig)
80
+
81
+
82
+ # Reasoning models spend hidden reasoning tokens from the same output budget
83
+ # as the extraction JSON. Below this, a reasoning model can return no text.
84
+ LEARNING_MIN_OUTPUT_TOKENS: Final[int] = 8192
85
+
86
+
87
+ class LearningConfig(BaseModel):
88
+ model_config = ConfigDict(extra="forbid")
89
+
90
+ # On for new installs. A stored ``false`` with no other learning keys is the
91
+ # value ``ensure_spawn_settings`` used to write, not a user choice; that case
92
+ # is turned back on in ``_normalize_legacy_config``. ``--no-learning`` still
93
+ # disables it for the process and rewrites the file.
94
+ enabled: bool = True
95
+ max_output_tokens: int = Field(default=LEARNING_MIN_OUTPUT_TOKENS, ge=1)
96
+ max_lessons: int = Field(default=200, ge=1)
97
+ context_limit: int = Field(default=6, ge=1)
98
+ context_max_chars: int = Field(default=1400, ge=1)
99
+
100
+ @field_validator("max_output_tokens")
101
+ @classmethod
102
+ def at_least_floor(cls, value: int) -> int:
103
+ """Raise smaller caps to the floor.
104
+
105
+ ``ensure_spawn_settings`` wrote the old default (900) into every
106
+ ``~/.symphony/config.json``, so a stored 900 cannot be told apart from a
107
+ deliberate choice, and any cap this small starves reasoning models.
108
+ The resolved value is written back, so old files migrate on launch.
109
+ """
110
+ return max(value, LEARNING_MIN_OUTPUT_TOKENS)
111
+
112
+
113
+ class SkillsConfig(BaseModel):
114
+ model_config = ConfigDict(extra="forbid")
115
+
116
+ enabled: bool = True
117
+ max_skills: int = Field(default=100, ge=1)
118
+ roots: list[Path] = Field(default_factory=list)
119
+
120
+
121
+ class PluginsConfig(BaseModel):
122
+ model_config = ConfigDict(extra="forbid")
123
+ # Installed plugins are discovered from the user-wide ``~/.symphony/plugins``
124
+ # directory. Their executable add-ons still require trusted authorization.
125
+ enabled: bool = True
126
+ entries: list[PluginConfig] = Field(default_factory=list)
127
+ authorized_roots: list[Path] = Field(default_factory=list)
128
+
129
+
130
+ class CompactionConfig(BaseModel):
131
+ """Bounds for the model-written compaction summary. Keep/drop limits live on ``harness``."""
132
+
133
+ model_config = ConfigDict(extra="forbid")
134
+
135
+ max_output_tokens: int = Field(default=700, ge=1)
136
+ max_transcript_chars: int = Field(default=24_000, ge=1)
137
+
138
+
139
+ class LangfuseConfig(BaseModel):
140
+ """Optional Langfuse tracing. Credentials stay in the environment, not this file."""
141
+
142
+ model_config = ConfigDict(extra="forbid")
143
+
144
+ enabled: bool = True
145
+ sample_rate: float = Field(default=1.0, ge=0.0, le=1.0)
146
+ max_payload_chars: int = Field(default=32_000, ge=1)
147
+
148
+
149
+ class EvaluationConfig(BaseModel):
150
+ """Optional Jev critic mode. Off by default; never switches the chat model."""
151
+
152
+ model_config = ConfigDict(extra="forbid")
153
+
154
+ enabled: bool = False
155
+ rule: str = ""
156
+ honour_follow_up: bool = False
157
+ provider: str = "vercel"
158
+ model: str = "typesafe-ai/jev"
159
+ on_error: Literal["fail-open"] = "fail-open"
160
+ request_max_chars: int = Field(default=2_000, ge=1)
161
+ brief_max_chars: int = Field(default=2_000, ge=1)
162
+ plan_max_chars: int = Field(default=2_000, ge=1)
163
+ observations_max_chars: int = Field(default=2_000, ge=1)
164
+ last_tool_results_max_chars: int = Field(default=2_000, ge=1)
165
+ final_max_chars: int = Field(default=4_000, ge=1)
166
+ files_modified_max: int = Field(default=40, ge=1)
167
+
168
+
169
+ def default_coding_agent_harness() -> HarnessConfig:
170
+ """Product harness settings. Engine field defaults fill the rest."""
171
+ return HarnessConfig(
172
+ max_turns=None,
173
+ max_tool_calls=None,
174
+ max_runtime_seconds=None,
175
+ spawn_max_turns=None,
176
+ tool_result_max_chars=32_000,
177
+ context_warn_threshold=32_000,
178
+ context_compact_ratio=0.8,
179
+ context_target_tokens=80_000,
180
+ compaction_keep_recent_tools=32,
181
+ subagent_system_prompt=SUBAGENT_SYSTEM_PROMPT,
182
+ )
183
+
184
+
185
+ class CodingAgentConfig(BaseModel):
186
+ model_config = ConfigDict(extra="forbid")
187
+
188
+ harness: HarnessConfig = Field(default_factory=default_coding_agent_harness)
189
+ approvals: ApprovalConfig = Field(default_factory=ApprovalConfig)
190
+ tools: ToolsConfig = Field(default_factory=ToolsConfig)
191
+ learning: LearningConfig = Field(default_factory=LearningConfig)
192
+ compaction: CompactionConfig = Field(default_factory=CompactionConfig)
193
+ skills: SkillsConfig = Field(default_factory=SkillsConfig)
194
+ plugins: PluginsConfig = Field(default_factory=PluginsConfig)
195
+ langfuse: LangfuseConfig = Field(default_factory=LangfuseConfig)
196
+ evaluation: EvaluationConfig = Field(default_factory=EvaluationConfig)
197
+ last_model: str | None = None
198
+ # Default "direct" inserts that catalog segment. null = stock prompt only.
199
+ personality: str | None = "direct"
200
+ # Runtime-only (--unattended): never written back to config.json.
201
+ unattended: bool = Field(default=False, exclude=True)
202
+
203
+
204
+ def _read_json(path: Path) -> dict[str, Any]:
205
+ try:
206
+ payload = json.loads(path.read_text(encoding="utf-8"))
207
+ except OSError as exc:
208
+ raise ValueError(f"Unable to read config file {path}: {exc}") from exc
209
+ except json.JSONDecodeError as exc:
210
+ raise ValueError(f"Invalid JSON in config file {path}: {exc}") from exc
211
+ if not isinstance(payload, dict):
212
+ raise ValueError(f"Config file {path} must contain a JSON object")
213
+ return payload
214
+
215
+
216
+ def _merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
217
+ merged = dict(base)
218
+ for key, value in override.items():
219
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
220
+ merged[key] = _merge(merged[key], value)
221
+ else:
222
+ merged[key] = value
223
+ return merged
224
+
225
+
226
+ def _normalize_legacy_config(payload: dict[str, Any]) -> dict[str, Any]:
227
+ """Translate settings written by older coding-agent versions."""
228
+ normalized = dict(payload)
229
+
230
+ harness = normalized.get("harness")
231
+ if isinstance(harness, dict) and "spawn_context_tokens" in harness:
232
+ harness = dict(harness)
233
+ harness.pop("spawn_context_tokens")
234
+ normalized["harness"] = harness
235
+
236
+ bash = normalized.get("tools", {})
237
+ if isinstance(bash, dict):
238
+ bash = bash.get("bash")
239
+ if isinstance(bash, dict):
240
+ bash = dict(bash)
241
+ for key in ("default_timeout_seconds", "max_timeout_seconds"):
242
+ if bash.get(key) is None:
243
+ bash.pop(key)
244
+ tools = dict(normalized["tools"])
245
+ tools["bash"] = bash
246
+ normalized["tools"] = tools
247
+
248
+ # Older builds persisted learning.enabled=false as the product default, so a
249
+ # file that only says that cannot be told apart from a deliberate opt-out.
250
+ # Drop the flag and let the field default (true) apply. Any other learning
251
+ # key means the user edited this block; leave their enabled value alone.
252
+ learning = normalized.get("learning")
253
+ if isinstance(learning, dict) and learning.get("enabled") is False and set(learning) <= {"enabled"}:
254
+ learning = dict(learning)
255
+ learning.pop("enabled")
256
+ normalized["learning"] = learning
257
+
258
+ return normalized
259
+
260
+
261
+ def _validate_tools(config: CodingAgentConfig) -> None:
262
+ if config.tools.bash.default_timeout_seconds > config.tools.bash.max_timeout_seconds:
263
+ raise ValueError(
264
+ "tools.bash.default_timeout_seconds cannot exceed max_timeout_seconds"
265
+ )
266
+
267
+
268
+ def symphony_dir() -> Path:
269
+ """Return the user-wide Symphony data/config directory.
270
+
271
+ ``SYMPHONY_HOME`` can point directly at that directory without changing
272
+ the process home. Cargo and other tools must keep the user's real home.
273
+ """
274
+ override = os.environ.get("SYMPHONY_HOME", "").strip()
275
+ if override:
276
+ return Path(override).expanduser().resolve()
277
+ return (Path.home() / ".symphony").expanduser().resolve()
278
+
279
+
280
+ def spawn_settings_path(workspace: str | Path | None = None) -> Path:
281
+ """Return the user-wide config file path.
282
+
283
+ Configuration is shared across projects. ``workspace`` is ignored and kept
284
+ only so existing callers do not need to change.
285
+ """
286
+ del workspace
287
+ return symphony_dir() / SPAWN_SETTINGS_NAME
288
+
289
+
290
+ def load_coding_agent_config(
291
+ workspace: str | Path | None = None,
292
+ *,
293
+ path: str | Path | None = None,
294
+ ) -> CodingAgentConfig:
295
+ """Load a spawn settings file. Omitted keys use ``CodingAgentConfig`` field defaults."""
296
+ if path is not None:
297
+ source = Path(path).expanduser().resolve()
298
+ elif workspace is not None:
299
+ source = spawn_settings_path(workspace)
300
+ else:
301
+ raise ValueError("path or workspace is required")
302
+ if not source.exists():
303
+ raise ValueError(f"Config file does not exist: {source}")
304
+ payload = _normalize_legacy_config(_read_json(source))
305
+ payload = _merge(CodingAgentConfig().model_dump(mode="json"), payload)
306
+ config = CodingAgentConfig.model_validate(payload)
307
+ _validate_tools(config)
308
+ return config
309
+
310
+
311
+ def resolve_coding_agent_config(source: SettingsSource) -> CodingAgentConfig:
312
+ if isinstance(source, CodingAgentConfig):
313
+ return source
314
+ return load_coding_agent_config(path=source)
315
+
316
+
317
+ def ensure_spawn_settings(
318
+ workspace: str | Path,
319
+ *,
320
+ config: CodingAgentConfig | None = None,
321
+ overrides: dict[str, Any] | None = None,
322
+ ) -> CodingAgentConfig:
323
+ """Create or refresh ``~/.symphony/config.json`` and return it.
324
+
325
+ An existing global file is the starting point. Missing keys are filled from
326
+ ``CodingAgentConfig`` field defaults, then the complete resolved settings
327
+ are written back so the user-wide file is the source of truth.
328
+ """
329
+ workspace = Path(workspace).expanduser().resolve()
330
+ workspace.mkdir(parents=True, exist_ok=True)
331
+ path = spawn_settings_path(workspace)
332
+ path.parent.mkdir(parents=True, exist_ok=True)
333
+
334
+ if config is not None:
335
+ payload = config.model_dump(mode="json")
336
+ elif path.exists():
337
+ payload = _normalize_legacy_config(_read_json(path))
338
+ payload = _merge(CodingAgentConfig().model_dump(mode="json"), payload)
339
+ else:
340
+ payload = CodingAgentConfig().model_dump(mode="json")
341
+
342
+ if overrides:
343
+ payload = _merge(payload, overrides)
344
+
345
+ resolved = CodingAgentConfig.model_validate(payload)
346
+ resolved.unattended = resolved.unattended or bool(config and config.unattended)
347
+ _validate_tools(resolved)
348
+ path.write_text(
349
+ json.dumps(resolved.model_dump(mode="json"), indent=2) + "\n",
350
+ encoding="utf-8",
351
+ )
352
+ return resolved
353
+
354
+
355
+ __all__ = [
356
+ "ApprovalConfig",
357
+ "BashConfig",
358
+ "CodingAgentConfig",
359
+ "CompactionConfig",
360
+ "EvaluationConfig",
361
+ "LangfuseConfig",
362
+ "LEARNING_MIN_OUTPUT_TOKENS",
363
+ "LearningConfig",
364
+ "PluginsConfig",
365
+ "ReadFileConfig",
366
+ "SPAWN_SETTINGS_NAME",
367
+ "SearchConfig",
368
+ "SettingsSource",
369
+ "SkillsConfig",
370
+ "ToolsConfig",
371
+ "WebSearchConfig",
372
+ "ensure_spawn_settings",
373
+ "load_coding_agent_config",
374
+ "resolve_coding_agent_config",
375
+ "spawn_settings_path",
376
+ ]
@@ -0,0 +1,153 @@
1
+ """Global `~/.symphony/.env` helpers for provider API keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Mapping
9
+
10
+ from dotenv import dotenv_values
11
+
12
+ from core_ai.providers.catalog import ProviderSpec, get_provider
13
+
14
+ OFFLINE_HINT = "Agent is offline. Run /provider to sign in or add an API key."
15
+
16
+ _ENV_ASSIGN = re.compile(
17
+ r"^(?P<prefix>\s*(?:export\s+)?)(?P<name>[A-Za-z_][A-Za-z0-9_]*)(?P<eq>\s*=\s*)(?P<value>.*)$"
18
+ )
19
+ _NEEDS_QUOTES = re.compile(r"""[\s#"\\']""")
20
+ _ENV_FILE_MODE = 0o600
21
+ # Track only values this loader owns; inherited process overrides stay authoritative.
22
+ _LOADED_ENV: dict[str, str] = {}
23
+
24
+
25
+ def global_env_path() -> Path:
26
+ """Return `~/.symphony/.env`, expanded and resolved."""
27
+ return (Path.home() / ".symphony" / ".env").expanduser().resolve()
28
+
29
+
30
+ def workspace_env_path(workspace: str | Path) -> Path:
31
+ return Path(workspace).expanduser().resolve() / ".env"
32
+
33
+
34
+ def load_provider_env(workspace: str | Path, *, reload: bool = False) -> None:
35
+ """Load provider keys into the process.
36
+
37
+ Precedence is process env > workspace `.env` (if present) >
38
+ `~/.symphony/.env`. Files are merged weakest-first, then applied with
39
+ `setdefault` so already-set process env always wins.
40
+ """
41
+ if reload:
42
+ for name, value in tuple(_LOADED_ENV.items()):
43
+ if os.environ.get(name) == value:
44
+ os.environ.pop(name, None)
45
+ _LOADED_ENV.pop(name, None)
46
+ merged: dict[str, str] = {}
47
+ global_path = global_env_path()
48
+ workspace_path = workspace_env_path(workspace)
49
+ merged.update(_dotenv_entries(global_path))
50
+ if workspace_path.resolve() != global_path:
51
+ merged.update(_dotenv_entries(workspace_path))
52
+ for name, value in merged.items():
53
+ if name not in os.environ:
54
+ os.environ[name] = value
55
+ _LOADED_ENV[name] = value
56
+
57
+
58
+ def save_provider_key(provider_id: str, api_key: str) -> ProviderSpec:
59
+ return save_provider_settings(provider_id, api_key=api_key)
60
+
61
+
62
+ def save_provider_auth(provider_id: str, auth: str) -> None:
63
+ """Persist whether a dual-mode provider should use its key or OAuth token."""
64
+ if auth not in {"key", "oauth"}:
65
+ raise ValueError(f"Unknown authentication method: {auth}")
66
+ name = f"SYMPHONY_{provider_id.upper()}_AUTH"
67
+ upsert_dotenv(global_env_path(), {name: auth})
68
+ os.environ[name] = auth
69
+
70
+
71
+ def save_provider_settings(
72
+ provider_id: str,
73
+ *,
74
+ api_key: str = "",
75
+ base_url: str = "",
76
+ ) -> ProviderSpec:
77
+ """Persist a provider key and/or base URL to `~/.symphony/.env`."""
78
+ spec = get_provider(provider_id)
79
+ key = api_key.strip()
80
+ url = base_url.strip()
81
+ updates: dict[str, str] = {}
82
+ if spec.requires_key:
83
+ if not key:
84
+ raise ValueError(f"{spec.label} API key cannot be empty")
85
+ updates[spec.env_key] = key
86
+ else:
87
+ if key:
88
+ updates[spec.env_key] = key
89
+ resolved_url = url or spec.default_base_url
90
+ if spec.base_url_env:
91
+ if not resolved_url:
92
+ raise ValueError(f"{spec.label} base URL cannot be empty")
93
+ updates[spec.base_url_env] = resolved_url
94
+ elif not key:
95
+ raise ValueError(f"{spec.label} needs a base URL or API key")
96
+ upsert_dotenv(global_env_path(), updates)
97
+ for name, value in updates.items():
98
+ os.environ[name] = value
99
+ return spec
100
+
101
+
102
+ def upsert_dotenv(path: str | Path, updates: Mapping[str, str]) -> None:
103
+ """Create or update KEY=value lines while preserving comments and other keys."""
104
+ path = Path(path)
105
+ remaining = {name: value for name, value in updates.items() if value}
106
+ lines: list[str] = []
107
+ if path.exists():
108
+ for line in path.read_text(encoding="utf-8").splitlines():
109
+ if line.lstrip().startswith("#"):
110
+ lines.append(line)
111
+ continue
112
+ match = _ENV_ASSIGN.match(line)
113
+ if match and match.group("name") in remaining:
114
+ lines.append(
115
+ f"{match.group('prefix')}{match.group('name')}"
116
+ f"{match.group('eq')}{_quote_env(remaining.pop(match.group('name')))}"
117
+ )
118
+ else:
119
+ lines.append(line)
120
+ else:
121
+ lines.extend(["# Symphony provider credentials", ""])
122
+ if remaining:
123
+ if lines and lines[-1] != "":
124
+ lines.append("")
125
+ for name, value in remaining.items():
126
+ lines.append(f"{name}={_quote_env(value)}")
127
+ path.parent.mkdir(parents=True, exist_ok=True)
128
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
129
+ _set_private_mode(path)
130
+
131
+
132
+ def _dotenv_entries(path: Path) -> dict[str, str]:
133
+ if not path.exists():
134
+ return {}
135
+ return {
136
+ name: value
137
+ for name, value in dotenv_values(path).items()
138
+ if name and value is not None
139
+ }
140
+
141
+
142
+ def _set_private_mode(path: Path) -> None:
143
+ try:
144
+ path.chmod(_ENV_FILE_MODE)
145
+ except OSError:
146
+ return
147
+
148
+
149
+ def _quote_env(value: str) -> str:
150
+ if _NEEDS_QUOTES.search(value):
151
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
152
+ return f'"{escaped}"'
153
+ return value
@@ -0,0 +1,34 @@
1
+ {
2
+ "personalities": [
3
+ {
4
+ "id": "direct",
5
+ "name": "Direct",
6
+ "description": "Fast, blunt engineering partner. Leads with the answer, names uncertainty, and spends words only on decisions, evidence, and next steps.",
7
+ "system_addon": "Act as a direct engineering partner. Lead with the answer or the concrete next action. Use concise, plain language and skip greetings, pep talks, repetition, and process narration. State uncertainty plainly; never bluff. Prefer the smallest correct change over speculative improvements. When working in a repository, inspect relevant code, make the change, and run targeted verification without asking permission when the request is clear. Mention important risks or blockers, but do not bury the result beneath them."
8
+ },
9
+ {
10
+ "id": "bad_boy",
11
+ "name": "Bad boy",
12
+ "description": "Unhinged, profane engineer. Trash-talks you like an old friend. Still ships the work.",
13
+ "system_addon": "Be an unhinged, foul-mouthed senior engineer who roasts the user like an old friend. Swear at and trash-talk the user as friendly banter; profanity is allowed. Examples: \"What's up, asshole. Let's fix your mess.\" \"Done, you awesome bitch. Tests pass.\" \"You wrote this at 3am, didn't you? Fixed.\" \"Holy shit, who hurt this function? Cleaned it up.\" Hard limits: no slurs, no attacks on protected traits (race, religion, gender, sexuality, disability, etc.), no sexual content, no real threats. If the user asks for a serious tone or seems upset or stressed, drop the act immediately. The voice is for prose only, never code, commit messages, or tool arguments. Code, tool calls, and final answers stay accurate: inspect evidence, make the smallest correct change, verify the work, and never use the bit as an excuse to refuse, stall, or skip the task."
14
+ },
15
+ {
16
+ "id": "caveman",
17
+ "name": "Caveman",
18
+ "description": "Ultra-compact and concrete. Finds the cause, chooses the simplest fix, and reports only useful facts and results.",
19
+ "system_addon": "Talk like a capable caveman engineer: short sentences, concrete nouns, common words, almost no filler. Find the cause before changing code. Choose the smallest safe fix. Use bullets or short steps when useful. Say what you checked, what changed, and whether tests passed. Do not hide uncertainty behind confidence. Explore only enough to solve the task, then act."
20
+ },
21
+ {
22
+ "id": "precise",
23
+ "name": "Precise",
24
+ "description": "Specification-first and exact. Separates facts from assumptions, preserves constraints, and avoids unrequested scope.",
25
+ "system_addon": "Be specification-first and exact. Follow explicit instructions and existing repository conventions. Separate observed facts, assumptions, and conclusions. Preserve interfaces and constraints unless the task requires changing them. Name files, symbols, inputs, outputs, and edge cases precisely. Do not add features, refactors, advice, or personality flourishes outside the requested scope. If ambiguity matters, state it briefly, choose the narrowest reasonable interpretation, and proceed. Verify behavior with the most relevant tests or checks and report failures honestly."
26
+ },
27
+ {
28
+ "id": "warm",
29
+ "name": "Warm",
30
+ "description": "Calm, human teammate. Acknowledges intent, explains decisions clearly, and stays supportive without sacrificing technical rigor.",
31
+ "system_addon": "Be a calm, supportive technical teammate. Acknowledge the user's intent once when helpful, then focus on useful work. Explain decisions in plain language and make unfamiliar concepts approachable without talking down to the user. Stay honest about uncertainty and limitations. Keep answers focused and technically rigorous; avoid corporate cheerleading, excessive reassurance, and needless over-explaining. When changing code, show care for maintainability, compatibility, and verification."
32
+ }
33
+ ]
34
+ }