devorch 0.1.2__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.
core/modes.py ADDED
@@ -0,0 +1,141 @@
1
+ """
2
+ Agent execution modes - controls how DevOrch handles tool execution.
3
+
4
+ Modes:
5
+ - PLAN: Create a plan first, show it, ask for approval, then execute
6
+ - AUTO: Execute tools automatically without asking (trusted mode)
7
+ - ASK: Ask for confirmation before each tool execution (default)
8
+ """
9
+
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+
14
+
15
+ class AgentMode(str, Enum):
16
+ PLAN = "plan" # Plan first, then execute after approval
17
+ AUTO = "auto" # Execute automatically (no confirmations)
18
+ ASK = "ask" # Ask before each tool execution (default)
19
+
20
+
21
+ @dataclass
22
+ class PlanStep:
23
+ """A single step in an execution plan."""
24
+
25
+ description: str
26
+ tool_name: str | None = None
27
+ tool_args: dict | None = None
28
+ status: str = "pending" # pending, approved, rejected, completed, failed
29
+
30
+
31
+ @dataclass
32
+ class ExecutionPlan:
33
+ """A plan of steps to execute."""
34
+
35
+ goal: str
36
+ steps: list[PlanStep] = field(default_factory=list)
37
+ approved: bool = False
38
+
39
+ def add_step(self, description: str, tool_name: str = None, tool_args: dict = None):
40
+ self.steps.append(
41
+ PlanStep(description=description, tool_name=tool_name, tool_args=tool_args)
42
+ )
43
+
44
+ def to_display(self) -> str:
45
+ """Format plan for display."""
46
+ lines = [f"📋 **Plan: {self.goal}**\n"]
47
+ for i, step in enumerate(self.steps, 1):
48
+ status_icon = {
49
+ "pending": "⬜",
50
+ "approved": "✅",
51
+ "rejected": "❌",
52
+ "completed": "✔️",
53
+ "failed": "💥",
54
+ }.get(step.status, "⬜")
55
+
56
+ tool_info = f" [{step.tool_name}]" if step.tool_name else ""
57
+ lines.append(f"{status_icon} {i}. {step.description}{tool_info}")
58
+
59
+ return "\n".join(lines)
60
+
61
+
62
+ class ModeManager:
63
+ """Manages the current execution mode and plan state."""
64
+
65
+ def __init__(self, default_mode: AgentMode = AgentMode.ASK):
66
+ self._mode = default_mode
67
+ self._current_plan: ExecutionPlan | None = None
68
+ self._on_mode_change: Callable[[AgentMode], None] | None = None
69
+
70
+ @property
71
+ def mode(self) -> AgentMode:
72
+ return self._mode
73
+
74
+ @mode.setter
75
+ def mode(self, value: AgentMode):
76
+ old_mode = self._mode
77
+ self._mode = value
78
+ if self._on_mode_change and old_mode != value:
79
+ self._on_mode_change(value)
80
+
81
+ @property
82
+ def current_plan(self) -> ExecutionPlan | None:
83
+ return self._current_plan
84
+
85
+ def set_on_mode_change(self, callback: Callable[[AgentMode], None]):
86
+ """Set callback for mode changes."""
87
+ self._on_mode_change = callback
88
+
89
+ def start_plan(self, goal: str) -> ExecutionPlan:
90
+ """Start a new execution plan."""
91
+ self._current_plan = ExecutionPlan(goal=goal)
92
+ return self._current_plan
93
+
94
+ def clear_plan(self):
95
+ """Clear the current plan."""
96
+ self._current_plan = None
97
+
98
+ def approve_plan(self) -> bool:
99
+ """Approve the current plan for execution."""
100
+ if self._current_plan:
101
+ self._current_plan.approved = True
102
+ return True
103
+ return False
104
+
105
+ def should_ask_permission(self) -> bool:
106
+ """Check if we should ask for tool permission based on mode."""
107
+ if self._mode == AgentMode.AUTO:
108
+ return False
109
+ elif self._mode == AgentMode.PLAN:
110
+ # In plan mode, don't ask during planning, only during execution
111
+ if self._current_plan and self._current_plan.approved:
112
+ return False # Plan approved, execute without asking
113
+ return True
114
+ else: # ASK mode
115
+ return True
116
+
117
+ def is_planning(self) -> bool:
118
+ """Check if we're currently in planning phase."""
119
+ return (
120
+ self._mode == AgentMode.PLAN
121
+ and self._current_plan is not None
122
+ and not self._current_plan.approved
123
+ )
124
+
125
+ def get_mode_display(self) -> str:
126
+ """Get a short display string for the current mode."""
127
+ mode_displays = {
128
+ AgentMode.PLAN: "[yellow]PLAN[/yellow]",
129
+ AgentMode.AUTO: "[green]AUTO[/green]",
130
+ AgentMode.ASK: "[blue]ASK[/blue]",
131
+ }
132
+ return mode_displays.get(self._mode, str(self._mode))
133
+
134
+ def get_mode_description(self) -> str:
135
+ """Get description of current mode."""
136
+ descriptions = {
137
+ AgentMode.PLAN: "Plan mode - I'll show you the plan before executing",
138
+ AgentMode.AUTO: "Auto mode - I'll execute tools automatically",
139
+ AgentMode.ASK: "Ask mode - I'll ask before each tool execution",
140
+ }
141
+ return descriptions.get(self._mode, "")
core/planner.py ADDED
@@ -0,0 +1,16 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from schemas.message import Message
4
+
5
+
6
+ class Planner(ABC):
7
+ """
8
+ Decides the next step for the agent.
9
+ """
10
+
11
+ @abstractmethod
12
+ def plan(self, history: list[Message]) -> list[Message]:
13
+ """
14
+ Returns updated messages to send to the LLM.
15
+ """
16
+ pass
core/sessions.py ADDED
@@ -0,0 +1,271 @@
1
+ import json
2
+ import sqlite3
3
+ import uuid
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ from schemas.message import Message
8
+
9
+ DATA_DIR = Path.home() / ".devorch"
10
+ DB_PATH = DATA_DIR / "sessions.db"
11
+
12
+ # Default limits
13
+ DEFAULT_MESSAGE_LIMIT = 50 # Max messages before summarization
14
+ DEFAULT_TOKEN_ESTIMATE_LIMIT = 100000 # Rough token estimate
15
+
16
+
17
+ class SessionManager:
18
+ """Manages chat session persistence using SQLite."""
19
+
20
+ def __init__(self, message_limit: int = DEFAULT_MESSAGE_LIMIT):
21
+ self._ensure_db()
22
+ self.current_session_id: str | None = None
23
+ self.message_limit = message_limit
24
+ self._message_count = 0
25
+
26
+ def _ensure_db(self):
27
+ """Create database and tables if they don't exist."""
28
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
29
+
30
+ conn = sqlite3.connect(DB_PATH)
31
+ cursor = conn.cursor()
32
+
33
+ cursor.execute("""
34
+ CREATE TABLE IF NOT EXISTS sessions (
35
+ id TEXT PRIMARY KEY,
36
+ name TEXT,
37
+ provider TEXT NOT NULL,
38
+ model TEXT NOT NULL,
39
+ parent_session_id TEXT,
40
+ summary TEXT,
41
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
42
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
43
+ FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
44
+ )
45
+ """)
46
+
47
+ cursor.execute("""
48
+ CREATE TABLE IF NOT EXISTS messages (
49
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
50
+ session_id TEXT NOT NULL,
51
+ role TEXT NOT NULL,
52
+ content TEXT NOT NULL,
53
+ name TEXT,
54
+ tool_call_id TEXT,
55
+ metadata TEXT,
56
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
57
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
58
+ )
59
+ """)
60
+
61
+ # Add parent_session_id and summary columns if they don't exist (migration)
62
+ try:
63
+ cursor.execute("ALTER TABLE sessions ADD COLUMN parent_session_id TEXT")
64
+ except sqlite3.OperationalError:
65
+ pass # Column already exists
66
+
67
+ try:
68
+ cursor.execute("ALTER TABLE sessions ADD COLUMN summary TEXT")
69
+ except sqlite3.OperationalError:
70
+ pass # Column already exists
71
+
72
+ conn.commit()
73
+ conn.close()
74
+
75
+ def create_session(
76
+ self,
77
+ provider: str,
78
+ model: str,
79
+ name: str | None = None,
80
+ parent_session_id: str | None = None,
81
+ summary: str | None = None,
82
+ ) -> str:
83
+ """Create a new session and return its ID."""
84
+ session_id = str(uuid.uuid4())[:8]
85
+ if not name:
86
+ name = f"Session {datetime.now().strftime('%Y-%m-%d %H:%M')}"
87
+
88
+ conn = sqlite3.connect(DB_PATH)
89
+ cursor = conn.cursor()
90
+
91
+ cursor.execute(
92
+ """INSERT INTO sessions (id, name, provider, model, parent_session_id, summary)
93
+ VALUES (?, ?, ?, ?, ?, ?)""",
94
+ (session_id, name, provider, model, parent_session_id, summary),
95
+ )
96
+
97
+ conn.commit()
98
+ conn.close()
99
+
100
+ self.current_session_id = session_id
101
+ self._message_count = 0
102
+ return session_id
103
+
104
+ def save_message(self, message: Message, session_id: str | None = None):
105
+ """Save a message to the current or specified session."""
106
+ sid = session_id or self.current_session_id
107
+ if not sid:
108
+ return
109
+
110
+ conn = sqlite3.connect(DB_PATH)
111
+ cursor = conn.cursor()
112
+
113
+ metadata_json = json.dumps(message.metadata) if message.metadata else None
114
+
115
+ cursor.execute(
116
+ """INSERT INTO messages (session_id, role, content, name, tool_call_id, metadata)
117
+ VALUES (?, ?, ?, ?, ?, ?)""",
118
+ (sid, message.role, message.content, message.name, message.tool_call_id, metadata_json),
119
+ )
120
+
121
+ # Update session timestamp
122
+ cursor.execute("UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE id = ?", (sid,))
123
+
124
+ conn.commit()
125
+ conn.close()
126
+
127
+ if sid == self.current_session_id:
128
+ self._message_count += 1
129
+
130
+ def get_message_count(self, session_id: str | None = None) -> int:
131
+ """Get the number of messages in a session."""
132
+ sid = session_id or self.current_session_id
133
+ if not sid:
134
+ return 0
135
+
136
+ conn = sqlite3.connect(DB_PATH)
137
+ cursor = conn.cursor()
138
+
139
+ cursor.execute("SELECT COUNT(*) FROM messages WHERE session_id = ?", (sid,))
140
+ count = cursor.fetchone()[0]
141
+
142
+ conn.close()
143
+ return count
144
+
145
+ def should_summarize(self) -> bool:
146
+ """Check if the current session should be summarized."""
147
+ return self._message_count >= self.message_limit
148
+
149
+ def load_session(self, session_id: str) -> tuple[dict, list[Message]]:
150
+ """Load a session and its messages. Returns (session_info, messages)."""
151
+ conn = sqlite3.connect(DB_PATH)
152
+ conn.row_factory = sqlite3.Row
153
+ cursor = conn.cursor()
154
+
155
+ # Get session info
156
+ cursor.execute("SELECT * FROM sessions WHERE id = ?", (session_id,))
157
+ session_row = cursor.fetchone()
158
+
159
+ if not session_row:
160
+ conn.close()
161
+ raise ValueError(f"Session '{session_id}' not found")
162
+
163
+ session_info = dict(session_row)
164
+
165
+ # Get messages
166
+ cursor.execute(
167
+ "SELECT * FROM messages WHERE session_id = ? ORDER BY created_at", (session_id,)
168
+ )
169
+ message_rows = cursor.fetchall()
170
+
171
+ messages = []
172
+ for row in message_rows:
173
+ metadata = json.loads(row["metadata"]) if row["metadata"] else None
174
+ msg = Message(
175
+ role=row["role"],
176
+ content=row["content"],
177
+ name=row["name"],
178
+ tool_call_id=row["tool_call_id"],
179
+ metadata=metadata,
180
+ )
181
+ messages.append(msg)
182
+
183
+ conn.close()
184
+
185
+ self.current_session_id = session_id
186
+ self._message_count = len(messages)
187
+ return session_info, messages
188
+
189
+ def create_continuation_session(self, provider: str, model: str, summary: str) -> str:
190
+ """Create a new session that continues from the current one with a summary."""
191
+ parent_id = self.current_session_id
192
+
193
+ # Get parent session name for naming continuation
194
+ name = f"Continuation {datetime.now().strftime('%Y-%m-%d %H:%M')}"
195
+
196
+ new_session_id = self.create_session(
197
+ provider=provider, model=model, name=name, parent_session_id=parent_id, summary=summary
198
+ )
199
+
200
+ return new_session_id
201
+
202
+ def get_session_chain(self, session_id: str) -> list[dict]:
203
+ """Get the chain of sessions (parent -> child) for context."""
204
+ conn = sqlite3.connect(DB_PATH)
205
+ conn.row_factory = sqlite3.Row
206
+ cursor = conn.cursor()
207
+
208
+ chain = []
209
+ current_id = session_id
210
+
211
+ # Walk up the parent chain
212
+ while current_id:
213
+ cursor.execute("SELECT * FROM sessions WHERE id = ?", (current_id,))
214
+ row = cursor.fetchone()
215
+ if row:
216
+ chain.insert(0, dict(row))
217
+ current_id = row["parent_session_id"]
218
+ else:
219
+ break
220
+
221
+ conn.close()
222
+ return chain
223
+
224
+ def list_sessions(self, limit: int = 20) -> list[dict]:
225
+ """List recent sessions."""
226
+ conn = sqlite3.connect(DB_PATH)
227
+ conn.row_factory = sqlite3.Row
228
+ cursor = conn.cursor()
229
+
230
+ cursor.execute(
231
+ """SELECT s.*, COUNT(m.id) as message_count
232
+ FROM sessions s
233
+ LEFT JOIN messages m ON s.id = m.session_id
234
+ GROUP BY s.id
235
+ ORDER BY s.updated_at DESC
236
+ LIMIT ?""",
237
+ (limit,),
238
+ )
239
+
240
+ sessions = [dict(row) for row in cursor.fetchall()]
241
+ conn.close()
242
+
243
+ return sessions
244
+
245
+ def delete_session(self, session_id: str) -> bool:
246
+ """Delete a session and its messages."""
247
+ conn = sqlite3.connect(DB_PATH)
248
+ cursor = conn.cursor()
249
+
250
+ # Enable foreign keys for cascade delete
251
+ cursor.execute("PRAGMA foreign_keys = ON")
252
+
253
+ cursor.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
254
+ cursor.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
255
+
256
+ deleted = cursor.rowcount > 0
257
+ conn.commit()
258
+ conn.close()
259
+
260
+ return deleted
261
+
262
+ def session_exists(self, session_id: str) -> bool:
263
+ """Check if a session exists."""
264
+ conn = sqlite3.connect(DB_PATH)
265
+ cursor = conn.cursor()
266
+
267
+ cursor.execute("SELECT 1 FROM sessions WHERE id = ?", (session_id,))
268
+ exists = cursor.fetchone() is not None
269
+
270
+ conn.close()
271
+ return exists
core/tasks.py ADDED
@@ -0,0 +1,142 @@
1
+ """Task manager for tracking work progress with visual display."""
2
+
3
+ from collections.abc import Callable
4
+
5
+ from rich.console import Console
6
+ from rich.live import Live
7
+ from rich.panel import Panel
8
+
9
+ from schemas.task import Task, TaskList, TaskStatus
10
+
11
+ console = Console()
12
+
13
+
14
+ class TaskManager:
15
+ """Manages tasks and displays progress."""
16
+
17
+ def __init__(self, on_update: Callable | None = None):
18
+ self.task_list = TaskList()
19
+ self.on_update = on_update # Callback when tasks change
20
+ self._live: Live | None = None
21
+
22
+ def set_tasks(self, tasks: list[dict]) -> None:
23
+ """Set tasks from a list of dictionaries (from AI tool call)."""
24
+ self.task_list.clear()
25
+ for task_data in tasks:
26
+ task = Task(
27
+ content=task_data["content"],
28
+ status=TaskStatus(task_data.get("status", "pending")),
29
+ active_form=task_data.get("activeForm") or task_data.get("active_form"),
30
+ )
31
+ self.task_list.add(task)
32
+ self._display()
33
+
34
+ def add_task(self, content: str, active_form: str | None = None) -> Task:
35
+ """Add a new task."""
36
+ task = Task(content=content, active_form=active_form or content)
37
+ self.task_list.add(task)
38
+ self._display()
39
+ return task
40
+
41
+ def start_task(self, task_id: str) -> Task | None:
42
+ """Mark a task as in progress."""
43
+ task = self.task_list.update_status(task_id, TaskStatus.IN_PROGRESS)
44
+ self._display()
45
+ return task
46
+
47
+ def complete_task(self, task_id: str) -> Task | None:
48
+ """Mark a task as completed."""
49
+ task = self.task_list.update_status(task_id, TaskStatus.COMPLETED)
50
+ self._display()
51
+ return task
52
+
53
+ def get_current_task(self) -> Task | None:
54
+ """Get the currently active task."""
55
+ return self.task_list.get_current()
56
+
57
+ def clear_tasks(self) -> None:
58
+ """Clear all tasks."""
59
+ self.task_list.clear()
60
+
61
+ def _display(self) -> None:
62
+ """Display the current task list."""
63
+ if self.task_list.total_count == 0:
64
+ return
65
+
66
+ panel = self._create_panel()
67
+ console.print(panel)
68
+
69
+ if self.on_update:
70
+ self.on_update(self.task_list)
71
+
72
+ def _create_panel(self) -> Panel:
73
+ """Create a Rich panel showing task progress."""
74
+ # Build task display
75
+ lines = []
76
+
77
+ for task in self.task_list.tasks:
78
+ if task.status == TaskStatus.COMPLETED:
79
+ icon = "[green]✓[/green]"
80
+ style = "dim"
81
+ text = task.content
82
+ elif task.status == TaskStatus.IN_PROGRESS:
83
+ icon = "[cyan]●[/cyan]"
84
+ style = "bold cyan"
85
+ text = task.active_form or task.content
86
+ else: # PENDING
87
+ icon = "[dim]○[/dim]"
88
+ style = "dim"
89
+ text = task.content
90
+
91
+ lines.append(f" {icon} [{style}]{text}[/{style}]")
92
+
93
+ content = "\n".join(lines)
94
+
95
+ # Progress info
96
+ completed = self.task_list.completed_count
97
+ total = self.task_list.total_count
98
+ progress = f"{completed}/{total}"
99
+
100
+ # Create panel with progress in title
101
+ return Panel(
102
+ content,
103
+ title=f"[bold]Tasks[/bold] [dim]({progress})[/dim]",
104
+ border_style="blue",
105
+ padding=(0, 1),
106
+ )
107
+
108
+ def get_status_line(self) -> str:
109
+ """Get a short status line for the current task."""
110
+ current = self.get_current_task()
111
+ if current:
112
+ completed = self.task_list.completed_count
113
+ total = self.task_list.total_count
114
+ return f"[{completed}/{total}] {current.active_form or current.content}"
115
+ return ""
116
+
117
+ def to_dict(self) -> dict:
118
+ """Export task list as dictionary."""
119
+ return {
120
+ "tasks": self.task_list.to_list(),
121
+ "completed": self.task_list.completed_count,
122
+ "total": self.task_list.total_count,
123
+ }
124
+
125
+
126
+ # Global task manager instance
127
+ _task_manager: TaskManager | None = None
128
+
129
+
130
+ def get_task_manager() -> TaskManager:
131
+ """Get or create the global task manager."""
132
+ global _task_manager
133
+ if _task_manager is None:
134
+ _task_manager = TaskManager()
135
+ return _task_manager
136
+
137
+
138
+ def reset_task_manager() -> TaskManager:
139
+ """Reset and return a new task manager."""
140
+ global _task_manager
141
+ _task_manager = TaskManager()
142
+ return _task_manager