overcode 0.1.0__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 (43) hide show
  1. overcode/__init__.py +5 -0
  2. overcode/cli.py +812 -0
  3. overcode/config.py +72 -0
  4. overcode/daemon.py +1184 -0
  5. overcode/daemon_claude_skill.md +180 -0
  6. overcode/daemon_state.py +113 -0
  7. overcode/data_export.py +257 -0
  8. overcode/dependency_check.py +227 -0
  9. overcode/exceptions.py +219 -0
  10. overcode/history_reader.py +448 -0
  11. overcode/implementations.py +214 -0
  12. overcode/interfaces.py +49 -0
  13. overcode/launcher.py +434 -0
  14. overcode/logging_config.py +193 -0
  15. overcode/mocks.py +152 -0
  16. overcode/monitor_daemon.py +808 -0
  17. overcode/monitor_daemon_state.py +358 -0
  18. overcode/pid_utils.py +225 -0
  19. overcode/presence_logger.py +454 -0
  20. overcode/protocols.py +143 -0
  21. overcode/session_manager.py +606 -0
  22. overcode/settings.py +412 -0
  23. overcode/standing_instructions.py +276 -0
  24. overcode/status_constants.py +190 -0
  25. overcode/status_detector.py +339 -0
  26. overcode/status_history.py +164 -0
  27. overcode/status_patterns.py +264 -0
  28. overcode/summarizer_client.py +136 -0
  29. overcode/summarizer_component.py +312 -0
  30. overcode/supervisor_daemon.py +1000 -0
  31. overcode/supervisor_layout.sh +50 -0
  32. overcode/tmux_manager.py +228 -0
  33. overcode/tui.py +2549 -0
  34. overcode/tui_helpers.py +495 -0
  35. overcode/web_api.py +279 -0
  36. overcode/web_server.py +138 -0
  37. overcode/web_templates.py +563 -0
  38. overcode-0.1.0.dist-info/METADATA +87 -0
  39. overcode-0.1.0.dist-info/RECORD +43 -0
  40. overcode-0.1.0.dist-info/WHEEL +5 -0
  41. overcode-0.1.0.dist-info/entry_points.txt +2 -0
  42. overcode-0.1.0.dist-info/licenses/LICENSE +21 -0
  43. overcode-0.1.0.dist-info/top_level.txt +1 -0
overcode/settings.py ADDED
@@ -0,0 +1,412 @@
1
+ """
2
+ Centralized configuration and settings for Overcode.
3
+
4
+ This module consolidates all configuration constants, paths, and settings
5
+ that were previously scattered across multiple modules.
6
+
7
+ Configuration hierarchy:
8
+ 1. Environment variables (highest priority)
9
+ 2. Config file (~/.overcode/config.yaml)
10
+ 3. Default values (lowest priority)
11
+ """
12
+
13
+ import os
14
+
15
+ # =============================================================================
16
+ # Version - increment when daemon code changes significantly
17
+ # =============================================================================
18
+
19
+ DAEMON_VERSION = 2 # Increment when daemon behavior changes
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Optional
23
+
24
+ import yaml
25
+
26
+
27
+ # =============================================================================
28
+ # Base Paths
29
+ # =============================================================================
30
+
31
+ def get_overcode_dir() -> Path:
32
+ """Get the overcode data directory.
33
+
34
+ Can be overridden with OVERCODE_DIR environment variable.
35
+ """
36
+ env_dir = os.environ.get("OVERCODE_DIR")
37
+ if env_dir:
38
+ return Path(env_dir)
39
+ return Path.home() / ".overcode"
40
+
41
+
42
+ def get_state_dir() -> Path:
43
+ """Get the state directory for session files.
44
+
45
+ Can be overridden with OVERCODE_STATE_DIR environment variable.
46
+ """
47
+ env_dir = os.environ.get("OVERCODE_STATE_DIR")
48
+ if env_dir:
49
+ return Path(env_dir)
50
+ return get_overcode_dir() / "sessions"
51
+
52
+
53
+ def get_log_dir() -> Path:
54
+ """Get the log directory."""
55
+ return get_overcode_dir() / "logs"
56
+
57
+
58
+ # =============================================================================
59
+ # File Paths
60
+ # =============================================================================
61
+
62
+ @dataclass
63
+ class OvercodePaths:
64
+ """All file paths used by Overcode."""
65
+
66
+ # Base directory
67
+ base_dir: Path = field(default_factory=get_overcode_dir)
68
+
69
+ @property
70
+ def config_file(self) -> Path:
71
+ """Configuration file path."""
72
+ return self.base_dir / "config.yaml"
73
+
74
+ @property
75
+ def state_dir(self) -> Path:
76
+ """Session state directory."""
77
+ return get_state_dir()
78
+
79
+ @property
80
+ def sessions_file(self) -> Path:
81
+ """Sessions state file."""
82
+ return self.state_dir / "sessions.json"
83
+
84
+ @property
85
+ def log_dir(self) -> Path:
86
+ """Log directory."""
87
+ return get_log_dir()
88
+
89
+ @property
90
+ def daemon_log(self) -> Path:
91
+ """Daemon log file."""
92
+ return self.base_dir / "daemon.log"
93
+
94
+ @property
95
+ def daemon_pid(self) -> Path:
96
+ """Daemon PID file."""
97
+ return self.base_dir / "daemon.pid"
98
+
99
+ @property
100
+ def daemon_state(self) -> Path:
101
+ """Daemon state file (legacy - supervisor daemon)."""
102
+ return self.base_dir / "daemon_state.json"
103
+
104
+ @property
105
+ def monitor_daemon_state(self) -> Path:
106
+ """Monitor daemon state file (new - single source of truth)."""
107
+ return self.base_dir / "monitor_daemon_state.json"
108
+
109
+ @property
110
+ def monitor_daemon_pid(self) -> Path:
111
+ """Monitor daemon PID file."""
112
+ return self.base_dir / "monitor_daemon.pid"
113
+
114
+ @property
115
+ def supervisor_daemon_pid(self) -> Path:
116
+ """Supervisor daemon PID file."""
117
+ return self.base_dir / "supervisor_daemon.pid"
118
+
119
+ @property
120
+ def presence_pid(self) -> Path:
121
+ """Presence logger PID file."""
122
+ return self.base_dir / "presence.pid"
123
+
124
+ @property
125
+ def presence_log(self) -> Path:
126
+ """Presence log file."""
127
+ return self.base_dir / "presence_log.csv"
128
+
129
+ @property
130
+ def activity_signal(self) -> Path:
131
+ """Activity signal file for daemon."""
132
+ return self.base_dir / "activity_signal"
133
+
134
+ @property
135
+ def agent_history(self) -> Path:
136
+ """Agent status history CSV."""
137
+ return self.base_dir / "agent_status_history.csv"
138
+
139
+ @property
140
+ def supervisor_log(self) -> Path:
141
+ """Supervisor log file."""
142
+ return self.base_dir / "supervisor.log"
143
+
144
+
145
+ # Global paths instance
146
+ PATHS = OvercodePaths()
147
+
148
+
149
+ # =============================================================================
150
+ # Daemon Settings
151
+ # =============================================================================
152
+
153
+ @dataclass
154
+ class DaemonSettings:
155
+ """Settings for the daemon."""
156
+
157
+ # Polling intervals (seconds)
158
+ interval_fast: int = 10 # When active or agents working
159
+ interval_slow: int = 300 # When all agents need user input (5 min)
160
+ interval_idle: int = 3600 # When no agents at all (1 hour)
161
+
162
+ # Daemon Claude settings
163
+ daemon_claude_timeout: int = 300 # Max wait for daemon claude (5 min)
164
+ daemon_claude_poll: int = 5 # Poll interval for daemon claude
165
+
166
+ # Default tmux session name
167
+ default_tmux_session: str = "agents"
168
+
169
+
170
+ # Global daemon settings
171
+ DAEMON = DaemonSettings()
172
+
173
+
174
+ # =============================================================================
175
+ # Presence Logger Settings
176
+ # =============================================================================
177
+
178
+ @dataclass
179
+ class PresenceSettings:
180
+ """Settings for the presence logger."""
181
+
182
+ sample_interval: int = 60 # Seconds between samples
183
+ idle_threshold: int = 60 # Seconds before considered idle
184
+
185
+
186
+ # Global presence settings
187
+ PRESENCE = PresenceSettings()
188
+
189
+
190
+ # =============================================================================
191
+ # TUI Settings
192
+ # =============================================================================
193
+
194
+ @dataclass
195
+ class TUISettings:
196
+ """Settings for the TUI monitor."""
197
+
198
+ default_timeline_width: int = 60
199
+ refresh_interval: float = 1.0 # Seconds
200
+
201
+
202
+ # Global TUI settings
203
+ TUI = TUISettings()
204
+
205
+
206
+ # =============================================================================
207
+ # Config File Loading
208
+ # =============================================================================
209
+
210
+ @dataclass
211
+ class UserConfig:
212
+ """User-configurable settings from config.yaml."""
213
+
214
+ default_standing_instructions: str = ""
215
+ tmux_session: str = "agents"
216
+
217
+ @classmethod
218
+ def load(cls) -> "UserConfig":
219
+ """Load configuration from config file."""
220
+ config_path = PATHS.config_file
221
+
222
+ if not config_path.exists():
223
+ return cls()
224
+
225
+ try:
226
+ with open(config_path) as f:
227
+ data = yaml.safe_load(f)
228
+ if not isinstance(data, dict):
229
+ return cls()
230
+
231
+ return cls(
232
+ default_standing_instructions=data.get(
233
+ "default_standing_instructions", ""
234
+ ),
235
+ tmux_session=data.get("tmux_session", "agents"),
236
+ )
237
+ except (yaml.YAMLError, IOError):
238
+ return cls()
239
+
240
+
241
+ # Cached user config (lazy loaded)
242
+ _user_config: Optional[UserConfig] = None
243
+
244
+
245
+ def get_user_config() -> UserConfig:
246
+ """Get the user configuration (cached)."""
247
+ global _user_config
248
+ if _user_config is None:
249
+ _user_config = UserConfig.load()
250
+ return _user_config
251
+
252
+
253
+ def reload_user_config() -> UserConfig:
254
+ """Reload the user configuration from disk."""
255
+ global _user_config
256
+ _user_config = UserConfig.load()
257
+ return _user_config
258
+
259
+
260
+ # =============================================================================
261
+ # Session-Specific Paths
262
+ # =============================================================================
263
+
264
+ def get_session_dir(session: str) -> Path:
265
+ """Get the directory for session-specific files.
266
+
267
+ Each overcode session (tmux session) gets its own subdirectory
268
+ for isolation. This allows running multiple overcode instances
269
+ (e.g., one for work, one for development).
270
+
271
+ Respects OVERCODE_STATE_DIR environment variable for test isolation.
272
+ """
273
+ # Use get_state_dir() as base to respect OVERCODE_STATE_DIR
274
+ state_dir = get_state_dir()
275
+ # state_dir is already the sessions directory
276
+ return state_dir / session
277
+
278
+
279
+ def get_monitor_daemon_pid_path(session: str) -> Path:
280
+ """Get monitor daemon PID file path for a specific session."""
281
+ return get_session_dir(session) / "monitor_daemon.pid"
282
+
283
+
284
+ def get_monitor_daemon_state_path(session: str) -> Path:
285
+ """Get monitor daemon state file path for a specific session."""
286
+ return get_session_dir(session) / "monitor_daemon_state.json"
287
+
288
+
289
+ def get_supervisor_daemon_pid_path(session: str) -> Path:
290
+ """Get supervisor daemon PID file path for a specific session."""
291
+ return get_session_dir(session) / "supervisor_daemon.pid"
292
+
293
+
294
+ def get_agent_history_path(session: str) -> Path:
295
+ """Get agent status history CSV path for a specific session."""
296
+ return get_session_dir(session) / "agent_status_history.csv"
297
+
298
+
299
+ def get_activity_signal_path(session: str) -> Path:
300
+ """Get activity signal file path for a specific session."""
301
+ return get_session_dir(session) / "activity_signal"
302
+
303
+
304
+ def signal_activity(session: str = None) -> None:
305
+ """Signal user activity to the daemon (called by TUI on keypress).
306
+
307
+ Creates a signal file that the daemon checks each loop.
308
+ When it sees this file, it wakes up and runs immediately.
309
+ This provides responsiveness when users interact with TUI.
310
+ """
311
+ if session is None:
312
+ session = DAEMON.default_tmux_session
313
+ signal_path = get_activity_signal_path(session)
314
+ try:
315
+ signal_path.parent.mkdir(parents=True, exist_ok=True)
316
+ signal_path.touch()
317
+ except OSError:
318
+ pass # Best effort
319
+
320
+
321
+ def get_supervisor_stats_path(session: str) -> Path:
322
+ """Get supervisor stats file path for a specific session."""
323
+ return get_session_dir(session) / "supervisor_stats.json"
324
+
325
+
326
+ def get_supervisor_log_path(session: str) -> Path:
327
+ """Get supervisor log file path for a specific session."""
328
+ return get_session_dir(session) / "supervisor.log"
329
+
330
+
331
+ def ensure_session_dir(session: str) -> Path:
332
+ """Ensure session directory exists and return it."""
333
+ session_dir = get_session_dir(session)
334
+ session_dir.mkdir(parents=True, exist_ok=True)
335
+ return session_dir
336
+
337
+
338
+ # =============================================================================
339
+ # Convenience Functions
340
+ # =============================================================================
341
+
342
+ def get_default_standing_instructions() -> str:
343
+ """Get default standing instructions from config."""
344
+ return get_user_config().default_standing_instructions
345
+
346
+
347
+ def get_default_tmux_session() -> str:
348
+ """Get default tmux session name from config."""
349
+ return get_user_config().tmux_session
350
+
351
+
352
+ # =============================================================================
353
+ # TUI Preferences (persisted between launches)
354
+ # =============================================================================
355
+
356
+ def get_tui_preferences_path(session: str) -> Path:
357
+ """Get TUI preferences file path for a specific session."""
358
+ return get_session_dir(session) / "tui_preferences.json"
359
+
360
+
361
+ @dataclass
362
+ class TUIPreferences:
363
+ """TUI preferences that persist between launches."""
364
+
365
+ summary_detail: str = "low" # low, med, full
366
+ detail_lines: int = 5 # 5, 10, 20, 50
367
+ timeline_visible: bool = True
368
+ daemon_panel_visible: bool = False
369
+ view_mode: str = "tree" # tree, list_preview
370
+
371
+ @classmethod
372
+ def load(cls, session: str) -> "TUIPreferences":
373
+ """Load TUI preferences from file."""
374
+ import json
375
+ prefs_path = get_tui_preferences_path(session)
376
+
377
+ if not prefs_path.exists():
378
+ return cls()
379
+
380
+ try:
381
+ with open(prefs_path) as f:
382
+ data = json.load(f)
383
+ if not isinstance(data, dict):
384
+ return cls()
385
+
386
+ return cls(
387
+ summary_detail=data.get("summary_detail", "low"),
388
+ detail_lines=data.get("detail_lines", 5),
389
+ timeline_visible=data.get("timeline_visible", True),
390
+ daemon_panel_visible=data.get("daemon_panel_visible", False),
391
+ view_mode=data.get("view_mode", "tree"),
392
+ )
393
+ except (json.JSONDecodeError, IOError):
394
+ return cls()
395
+
396
+ def save(self, session: str) -> None:
397
+ """Save TUI preferences to file."""
398
+ import json
399
+ prefs_path = get_tui_preferences_path(session)
400
+
401
+ try:
402
+ prefs_path.parent.mkdir(parents=True, exist_ok=True)
403
+ with open(prefs_path, 'w') as f:
404
+ json.dump({
405
+ "summary_detail": self.summary_detail,
406
+ "detail_lines": self.detail_lines,
407
+ "timeline_visible": self.timeline_visible,
408
+ "daemon_panel_visible": self.daemon_panel_visible,
409
+ "view_mode": self.view_mode,
410
+ }, f, indent=2)
411
+ except (IOError, OSError):
412
+ pass # Best effort
@@ -0,0 +1,276 @@
1
+ """
2
+ Standing instructions library for overcode agents.
3
+
4
+ Provides a library of pre-defined instruction presets that users can apply
5
+ to agents. Presets are stored in ~/.overcode/presets.json and can be
6
+ customized by the user.
7
+ """
8
+
9
+ import json
10
+ from dataclasses import dataclass, asdict
11
+ from pathlib import Path
12
+ from typing import Optional, Dict, List
13
+
14
+ PRESETS_PATH = Path.home() / ".overcode" / "presets.json"
15
+
16
+
17
+ @dataclass
18
+ class InstructionPreset:
19
+ """A pre-defined standing instruction preset."""
20
+ name: str # Short name: DEFAULT, CODING, etc.
21
+ description: str # One-line description for CLI help
22
+ instructions: str # Full instruction text for daemon claude
23
+
24
+
25
+ # Default presets - used to generate initial presets.json
26
+ DEFAULT_PRESETS: Dict[str, InstructionPreset] = {
27
+ "DEFAULT": InstructionPreset(
28
+ name="DEFAULT",
29
+ description="General-purpose safe automation",
30
+ instructions=(
31
+ "Approve safe operations within the working directory: file reads/writes/edits, "
32
+ "web fetches, git status/add/commit, running tests. Reject operations outside "
33
+ "the project, rm -rf, or anything that could affect system stability. When "
34
+ "uncertain, err on the side of caution."
35
+ ),
36
+ ),
37
+ "PERMISSIVE": InstructionPreset(
38
+ name="PERMISSIVE",
39
+ description="Trusted agent, minimal friction",
40
+ instructions=(
41
+ "Approve most permission requests to keep work flowing. Trust the agent's "
42
+ "judgment on file operations, web access, and shell commands within reason. "
43
+ "Only reject clearly dangerous operations like rm -rf on large directories, "
44
+ "operations on system files, or commands that could crash the system."
45
+ ),
46
+ ),
47
+ "CAUTIOUS": InstructionPreset(
48
+ name="CAUTIOUS",
49
+ description="Sensitive project, careful oversight",
50
+ instructions=(
51
+ "Take a conservative approach. Approve read operations freely. For writes, "
52
+ "check they're within the project and make sense for the task. Reject any "
53
+ "git push, deployment commands, or operations that can't be easily undone. "
54
+ "When in doubt, let the session wait for the human user."
55
+ ),
56
+ ),
57
+ "RESEARCH": InstructionPreset(
58
+ name="RESEARCH",
59
+ description="Information gathering, exploration",
60
+ instructions=(
61
+ "Approve all read operations: file reads, web searches, web fetches, grep, "
62
+ "glob, directory listings. Approve writing notes or summary files. Be "
63
+ "cautious with code modifications - the goal is gathering information, not "
64
+ "making changes. Reject shell commands that modify state."
65
+ ),
66
+ ),
67
+ "CODING": InstructionPreset(
68
+ name="CODING",
69
+ description="Active development work",
70
+ instructions=(
71
+ "Approve file operations within the project: reads, writes, edits. Approve "
72
+ "running tests, linters, and build commands. Approve git add and commit. "
73
+ "Be cautious with git push - only if tests pass and work looks complete. "
74
+ "Reject operations outside the project directory."
75
+ ),
76
+ ),
77
+ "TESTING": InstructionPreset(
78
+ name="TESTING",
79
+ description="Running and fixing tests",
80
+ instructions=(
81
+ "Approve running test suites (pytest, jest, etc.) and viewing results. "
82
+ "Approve file edits to fix failing tests. Approve re-running tests after "
83
+ "fixes. Keep the agent focused on making tests pass. Reject unrelated "
84
+ "changes or scope creep beyond test fixes."
85
+ ),
86
+ ),
87
+ "REVIEW": InstructionPreset(
88
+ name="REVIEW",
89
+ description="Code review, analysis only",
90
+ instructions=(
91
+ "Approve only read operations: file reads, git log, git diff, grep searches. "
92
+ "The agent should analyze and report, not modify. Reject all write operations, "
93
+ "edits, and shell commands that change state. Let the agent provide analysis "
94
+ "and recommendations only."
95
+ ),
96
+ ),
97
+ "DEPLOY": InstructionPreset(
98
+ name="DEPLOY",
99
+ description="Deployment and release tasks",
100
+ instructions=(
101
+ "Approve deployment-related commands: git push, npm publish, docker build/push, "
102
+ "deployment scripts. Verify tests pass before approving pushes. Approve version "
103
+ "bumps and changelog updates. Be careful with production database commands - "
104
+ "verify they're read-only or explicitly requested."
105
+ ),
106
+ ),
107
+ "AUTONOMOUS": InstructionPreset(
108
+ name="AUTONOMOUS",
109
+ description="Fully autonomous operation",
110
+ instructions=(
111
+ "Approve all reasonable operations to maximize autonomous progress. Trust the "
112
+ "agent to make good decisions. Only intervene for clearly dangerous operations "
113
+ "(system file modifications, recursive deletes, credential exposure). The goal "
114
+ "is minimal human interruption."
115
+ ),
116
+ ),
117
+ "MINIMAL": InstructionPreset(
118
+ name="MINIMAL",
119
+ description="Just keep it from stalling",
120
+ instructions=(
121
+ "Only intervene when the agent is completely stuck on a permission prompt. "
122
+ "Approve simple, safe operations. For anything complex or uncertain, let it "
123
+ "wait for the human user. Don't provide guidance or redirect the agent - just "
124
+ "handle permission gates."
125
+ ),
126
+ ),
127
+ }
128
+
129
+
130
+ def _ensure_presets_file() -> None:
131
+ """Create presets.json with defaults if it doesn't exist."""
132
+ if PRESETS_PATH.exists():
133
+ return
134
+
135
+ # Ensure directory exists
136
+ PRESETS_PATH.parent.mkdir(parents=True, exist_ok=True)
137
+
138
+ # Write default presets
139
+ presets_data = {
140
+ name: asdict(preset)
141
+ for name, preset in DEFAULT_PRESETS.items()
142
+ }
143
+ with open(PRESETS_PATH, 'w') as f:
144
+ json.dump(presets_data, f, indent=2)
145
+
146
+
147
+ def load_presets() -> Dict[str, InstructionPreset]:
148
+ """Load presets from ~/.overcode/presets.json.
149
+
150
+ Creates the file with defaults if it doesn't exist.
151
+
152
+ Returns:
153
+ Dict mapping preset names to InstructionPreset objects
154
+ """
155
+ _ensure_presets_file()
156
+
157
+ try:
158
+ with open(PRESETS_PATH, 'r') as f:
159
+ data = json.load(f)
160
+
161
+ presets = {}
162
+ for name, preset_data in data.items():
163
+ presets[name.upper()] = InstructionPreset(
164
+ name=preset_data.get("name", name),
165
+ description=preset_data.get("description", ""),
166
+ instructions=preset_data.get("instructions", ""),
167
+ )
168
+ return presets
169
+
170
+ except (json.JSONDecodeError, IOError):
171
+ # Fall back to defaults if file is corrupted
172
+ return DEFAULT_PRESETS.copy()
173
+
174
+
175
+ def save_presets(presets: Dict[str, InstructionPreset]) -> None:
176
+ """Save presets to ~/.overcode/presets.json.
177
+
178
+ Args:
179
+ presets: Dict mapping preset names to InstructionPreset objects
180
+ """
181
+ PRESETS_PATH.parent.mkdir(parents=True, exist_ok=True)
182
+
183
+ presets_data = {
184
+ name: asdict(preset)
185
+ for name, preset in presets.items()
186
+ }
187
+ with open(PRESETS_PATH, 'w') as f:
188
+ json.dump(presets_data, f, indent=2)
189
+
190
+
191
+ def get_preset(name: str) -> Optional[InstructionPreset]:
192
+ """Get a preset by name (case-insensitive).
193
+
194
+ Args:
195
+ name: Preset name to look up
196
+
197
+ Returns:
198
+ InstructionPreset if found, None otherwise
199
+ """
200
+ presets = load_presets()
201
+ return presets.get(name.upper())
202
+
203
+
204
+ def get_preset_names() -> List[str]:
205
+ """Get all preset names in order.
206
+
207
+ Returns:
208
+ List of preset names
209
+ """
210
+ presets = load_presets()
211
+ # Return in a consistent order (DEFAULT first, then alphabetical)
212
+ names = list(presets.keys())
213
+ if "DEFAULT" in names:
214
+ names.remove("DEFAULT")
215
+ names = ["DEFAULT"] + sorted(names)
216
+ else:
217
+ names = sorted(names)
218
+ return names
219
+
220
+
221
+ def resolve_instructions(input_text: str) -> tuple[str, Optional[str]]:
222
+ """Resolve input to (full_instructions, preset_name_or_none).
223
+
224
+ If input matches a preset name (case-insensitive), returns the preset's
225
+ instructions and name. Otherwise returns the input as custom instructions.
226
+
227
+ Args:
228
+ input_text: User input - either a preset name or custom instructions
229
+
230
+ Returns:
231
+ Tuple of (full_instructions, preset_name_if_used)
232
+ """
233
+ preset = get_preset(input_text)
234
+ if preset:
235
+ return preset.instructions, preset.name
236
+ return input_text, None
237
+
238
+
239
+ def add_preset(name: str, description: str, instructions: str) -> None:
240
+ """Add or update a preset.
241
+
242
+ Args:
243
+ name: Preset name (will be uppercased)
244
+ description: Short description
245
+ instructions: Full instruction text
246
+ """
247
+ presets = load_presets()
248
+ presets[name.upper()] = InstructionPreset(
249
+ name=name.upper(),
250
+ description=description,
251
+ instructions=instructions,
252
+ )
253
+ save_presets(presets)
254
+
255
+
256
+ def remove_preset(name: str) -> bool:
257
+ """Remove a preset.
258
+
259
+ Args:
260
+ name: Preset name to remove
261
+
262
+ Returns:
263
+ True if preset was removed, False if not found
264
+ """
265
+ presets = load_presets()
266
+ name_upper = name.upper()
267
+ if name_upper in presets:
268
+ del presets[name_upper]
269
+ save_presets(presets)
270
+ return True
271
+ return False
272
+
273
+
274
+ def reset_presets() -> None:
275
+ """Reset presets to defaults."""
276
+ save_presets(DEFAULT_PRESETS.copy())