autoforge-ai 0.1.0

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 (84) hide show
  1. package/.claude/commands/check-code.md +32 -0
  2. package/.claude/commands/checkpoint.md +40 -0
  3. package/.claude/commands/create-spec.md +613 -0
  4. package/.claude/commands/expand-project.md +234 -0
  5. package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
  6. package/.claude/commands/review-pr.md +75 -0
  7. package/.claude/templates/app_spec.template.txt +331 -0
  8. package/.claude/templates/coding_prompt.template.md +265 -0
  9. package/.claude/templates/initializer_prompt.template.md +354 -0
  10. package/.claude/templates/testing_prompt.template.md +146 -0
  11. package/.env.example +64 -0
  12. package/LICENSE.md +676 -0
  13. package/README.md +423 -0
  14. package/agent.py +444 -0
  15. package/api/__init__.py +10 -0
  16. package/api/database.py +536 -0
  17. package/api/dependency_resolver.py +449 -0
  18. package/api/migration.py +156 -0
  19. package/auth.py +83 -0
  20. package/autoforge_paths.py +315 -0
  21. package/autonomous_agent_demo.py +293 -0
  22. package/bin/autoforge.js +3 -0
  23. package/client.py +607 -0
  24. package/env_constants.py +27 -0
  25. package/examples/OPTIMIZE_CONFIG.md +230 -0
  26. package/examples/README.md +531 -0
  27. package/examples/org_config.yaml +172 -0
  28. package/examples/project_allowed_commands.yaml +139 -0
  29. package/lib/cli.js +791 -0
  30. package/mcp_server/__init__.py +1 -0
  31. package/mcp_server/feature_mcp.py +988 -0
  32. package/package.json +53 -0
  33. package/parallel_orchestrator.py +1800 -0
  34. package/progress.py +247 -0
  35. package/prompts.py +427 -0
  36. package/pyproject.toml +17 -0
  37. package/rate_limit_utils.py +132 -0
  38. package/registry.py +614 -0
  39. package/requirements-prod.txt +14 -0
  40. package/security.py +959 -0
  41. package/server/__init__.py +17 -0
  42. package/server/main.py +261 -0
  43. package/server/routers/__init__.py +32 -0
  44. package/server/routers/agent.py +177 -0
  45. package/server/routers/assistant_chat.py +327 -0
  46. package/server/routers/devserver.py +309 -0
  47. package/server/routers/expand_project.py +239 -0
  48. package/server/routers/features.py +746 -0
  49. package/server/routers/filesystem.py +514 -0
  50. package/server/routers/projects.py +524 -0
  51. package/server/routers/schedules.py +356 -0
  52. package/server/routers/settings.py +127 -0
  53. package/server/routers/spec_creation.py +357 -0
  54. package/server/routers/terminal.py +453 -0
  55. package/server/schemas.py +593 -0
  56. package/server/services/__init__.py +36 -0
  57. package/server/services/assistant_chat_session.py +496 -0
  58. package/server/services/assistant_database.py +304 -0
  59. package/server/services/chat_constants.py +57 -0
  60. package/server/services/dev_server_manager.py +557 -0
  61. package/server/services/expand_chat_session.py +399 -0
  62. package/server/services/process_manager.py +657 -0
  63. package/server/services/project_config.py +475 -0
  64. package/server/services/scheduler_service.py +683 -0
  65. package/server/services/spec_chat_session.py +502 -0
  66. package/server/services/terminal_manager.py +756 -0
  67. package/server/utils/__init__.py +1 -0
  68. package/server/utils/process_utils.py +134 -0
  69. package/server/utils/project_helpers.py +32 -0
  70. package/server/utils/validation.py +54 -0
  71. package/server/websocket.py +903 -0
  72. package/start.py +456 -0
  73. package/ui/dist/assets/index-8W_wmZzz.js +168 -0
  74. package/ui/dist/assets/index-B47Ubhox.css +1 -0
  75. package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
  76. package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
  77. package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
  78. package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
  79. package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
  80. package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
  81. package/ui/dist/index.html +23 -0
  82. package/ui/dist/ollama.png +0 -0
  83. package/ui/dist/vite.svg +6 -0
  84. package/ui/package.json +57 -0
@@ -0,0 +1,304 @@
1
+ """
2
+ Assistant Database
3
+ ==================
4
+
5
+ SQLAlchemy models and functions for persisting assistant conversations.
6
+ Each project has its own assistant.db file in the project directory.
7
+ """
8
+
9
+ import logging
10
+ import threading
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, create_engine, func
16
+ from sqlalchemy.engine import Engine
17
+ from sqlalchemy.orm import DeclarativeBase, relationship, sessionmaker
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ class Base(DeclarativeBase):
22
+ """SQLAlchemy 2.0 style declarative base."""
23
+ pass
24
+
25
+ # Engine cache to avoid creating new engines for each request
26
+ # Key: project directory path (as posix string), Value: SQLAlchemy engine
27
+ _engine_cache: dict[str, Engine] = {}
28
+
29
+ # Lock for thread-safe access to the engine cache
30
+ # Prevents race conditions when multiple threads create engines simultaneously
31
+ _cache_lock = threading.Lock()
32
+
33
+
34
+ def _utc_now() -> datetime:
35
+ """Return current UTC time. Replacement for deprecated datetime.utcnow()."""
36
+ return datetime.now(timezone.utc)
37
+
38
+
39
+ class Conversation(Base):
40
+ """A conversation with the assistant for a project."""
41
+ __tablename__ = "conversations"
42
+
43
+ id = Column(Integer, primary_key=True, index=True)
44
+ project_name = Column(String(100), nullable=False, index=True)
45
+ title = Column(String(200), nullable=True) # Optional title, derived from first message
46
+ created_at = Column(DateTime, default=_utc_now)
47
+ updated_at = Column(DateTime, default=_utc_now, onupdate=_utc_now)
48
+
49
+ messages = relationship("ConversationMessage", back_populates="conversation", cascade="all, delete-orphan")
50
+
51
+
52
+ class ConversationMessage(Base):
53
+ """A single message within a conversation."""
54
+ __tablename__ = "conversation_messages"
55
+
56
+ id = Column(Integer, primary_key=True, index=True)
57
+ conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False, index=True)
58
+ role = Column(String(20), nullable=False) # "user" | "assistant" | "system"
59
+ content = Column(Text, nullable=False)
60
+ timestamp = Column(DateTime, default=_utc_now)
61
+
62
+ conversation = relationship("Conversation", back_populates="messages")
63
+
64
+
65
+ def get_db_path(project_dir: Path) -> Path:
66
+ """Get the path to the assistant database for a project."""
67
+ from autoforge_paths import get_assistant_db_path
68
+ return get_assistant_db_path(project_dir)
69
+
70
+
71
+ def get_engine(project_dir: Path):
72
+ """Get or create a SQLAlchemy engine for a project's assistant database.
73
+
74
+ Uses a cache to avoid creating new engines for each request, which improves
75
+ performance by reusing database connections.
76
+
77
+ Thread-safe: Uses a lock to prevent race conditions when multiple threads
78
+ try to create engines simultaneously for the same project.
79
+ """
80
+ cache_key = project_dir.as_posix()
81
+
82
+ # Double-checked locking for thread safety and performance
83
+ if cache_key in _engine_cache:
84
+ return _engine_cache[cache_key]
85
+
86
+ with _cache_lock:
87
+ # Check again inside the lock in case another thread created it
88
+ if cache_key not in _engine_cache:
89
+ db_path = get_db_path(project_dir)
90
+ # Use as_posix() for cross-platform compatibility with SQLite connection strings
91
+ db_url = f"sqlite:///{db_path.as_posix()}"
92
+ engine = create_engine(
93
+ db_url,
94
+ echo=False,
95
+ connect_args={
96
+ "check_same_thread": False,
97
+ "timeout": 30, # Wait up to 30s for locks
98
+ }
99
+ )
100
+ Base.metadata.create_all(engine)
101
+ _engine_cache[cache_key] = engine
102
+ logger.debug(f"Created new database engine for {cache_key}")
103
+
104
+ return _engine_cache[cache_key]
105
+
106
+
107
+ def dispose_engine(project_dir: Path) -> bool:
108
+ """Dispose of and remove the cached engine for a project.
109
+
110
+ This closes all database connections, releasing file locks on Windows.
111
+ Should be called before deleting the database file.
112
+
113
+ Returns:
114
+ True if an engine was disposed, False if no engine was cached.
115
+ """
116
+ cache_key = project_dir.as_posix()
117
+
118
+ if cache_key in _engine_cache:
119
+ engine = _engine_cache.pop(cache_key)
120
+ engine.dispose()
121
+ logger.debug(f"Disposed database engine for {cache_key}")
122
+ return True
123
+
124
+ return False
125
+
126
+
127
+ def get_session(project_dir: Path):
128
+ """Get a new database session for a project."""
129
+ engine = get_engine(project_dir)
130
+ Session = sessionmaker(bind=engine)
131
+ return Session()
132
+
133
+
134
+ # ============================================================================
135
+ # Conversation Operations
136
+ # ============================================================================
137
+
138
+ def create_conversation(project_dir: Path, project_name: str, title: Optional[str] = None) -> Conversation:
139
+ """Create a new conversation for a project."""
140
+ session = get_session(project_dir)
141
+ try:
142
+ conversation = Conversation(
143
+ project_name=project_name,
144
+ title=title,
145
+ )
146
+ session.add(conversation)
147
+ session.commit()
148
+ session.refresh(conversation)
149
+ logger.info(f"Created conversation {conversation.id} for project {project_name}")
150
+ return conversation
151
+ finally:
152
+ session.close()
153
+
154
+
155
+ def get_conversations(project_dir: Path, project_name: str) -> list[dict]:
156
+ """Get all conversations for a project with message counts.
157
+
158
+ Uses a subquery for message_count to avoid N+1 query problem.
159
+ """
160
+ session = get_session(project_dir)
161
+ try:
162
+ # Subquery to count messages per conversation (avoids N+1 query)
163
+ message_count_subquery = (
164
+ session.query(
165
+ ConversationMessage.conversation_id,
166
+ func.count(ConversationMessage.id).label("message_count")
167
+ )
168
+ .group_by(ConversationMessage.conversation_id)
169
+ .subquery()
170
+ )
171
+
172
+ # Join conversation with message counts
173
+ conversations = (
174
+ session.query(
175
+ Conversation,
176
+ func.coalesce(message_count_subquery.c.message_count, 0).label("message_count")
177
+ )
178
+ .outerjoin(
179
+ message_count_subquery,
180
+ Conversation.id == message_count_subquery.c.conversation_id
181
+ )
182
+ .filter(Conversation.project_name == project_name)
183
+ .order_by(Conversation.updated_at.desc())
184
+ .all()
185
+ )
186
+ return [
187
+ {
188
+ "id": c.Conversation.id,
189
+ "project_name": c.Conversation.project_name,
190
+ "title": c.Conversation.title,
191
+ "created_at": c.Conversation.created_at.isoformat() if c.Conversation.created_at else None,
192
+ "updated_at": c.Conversation.updated_at.isoformat() if c.Conversation.updated_at else None,
193
+ "message_count": c.message_count,
194
+ }
195
+ for c in conversations
196
+ ]
197
+ finally:
198
+ session.close()
199
+
200
+
201
+ def get_conversation(project_dir: Path, conversation_id: int) -> Optional[dict]:
202
+ """Get a conversation with all its messages."""
203
+ session = get_session(project_dir)
204
+ try:
205
+ conversation = session.query(Conversation).filter(Conversation.id == conversation_id).first()
206
+ if not conversation:
207
+ return None
208
+ return {
209
+ "id": conversation.id,
210
+ "project_name": conversation.project_name,
211
+ "title": conversation.title,
212
+ "created_at": conversation.created_at.isoformat() if conversation.created_at else None,
213
+ "updated_at": conversation.updated_at.isoformat() if conversation.updated_at else None,
214
+ "messages": [
215
+ {
216
+ "id": m.id,
217
+ "role": m.role,
218
+ "content": m.content,
219
+ "timestamp": m.timestamp.isoformat() if m.timestamp else None,
220
+ }
221
+ for m in sorted(conversation.messages, key=lambda x: x.timestamp or datetime.min)
222
+ ],
223
+ }
224
+ finally:
225
+ session.close()
226
+
227
+
228
+ def delete_conversation(project_dir: Path, conversation_id: int) -> bool:
229
+ """Delete a conversation and all its messages."""
230
+ session = get_session(project_dir)
231
+ try:
232
+ conversation = session.query(Conversation).filter(Conversation.id == conversation_id).first()
233
+ if not conversation:
234
+ return False
235
+ session.delete(conversation)
236
+ session.commit()
237
+ logger.info(f"Deleted conversation {conversation_id}")
238
+ return True
239
+ finally:
240
+ session.close()
241
+
242
+
243
+ # ============================================================================
244
+ # Message Operations
245
+ # ============================================================================
246
+
247
+ def add_message(project_dir: Path, conversation_id: int, role: str, content: str) -> Optional[dict]:
248
+ """Add a message to a conversation."""
249
+ session = get_session(project_dir)
250
+ try:
251
+ conversation = session.query(Conversation).filter(Conversation.id == conversation_id).first()
252
+ if not conversation:
253
+ return None
254
+
255
+ message = ConversationMessage(
256
+ conversation_id=conversation_id,
257
+ role=role,
258
+ content=content,
259
+ )
260
+ session.add(message)
261
+
262
+ # Update conversation's updated_at timestamp
263
+ conversation.updated_at = _utc_now()
264
+
265
+ # Auto-generate title from first user message if not set
266
+ if not conversation.title and role == "user":
267
+ # Take first 50 chars of first user message as title
268
+ conversation.title = content[:50] + ("..." if len(content) > 50 else "")
269
+
270
+ session.commit()
271
+ session.refresh(message)
272
+
273
+ logger.debug(f"Added {role} message to conversation {conversation_id}")
274
+ return {
275
+ "id": message.id,
276
+ "role": message.role,
277
+ "content": message.content,
278
+ "timestamp": message.timestamp.isoformat() if message.timestamp else None,
279
+ }
280
+ finally:
281
+ session.close()
282
+
283
+
284
+ def get_messages(project_dir: Path, conversation_id: int) -> list[dict]:
285
+ """Get all messages for a conversation."""
286
+ session = get_session(project_dir)
287
+ try:
288
+ messages = (
289
+ session.query(ConversationMessage)
290
+ .filter(ConversationMessage.conversation_id == conversation_id)
291
+ .order_by(ConversationMessage.timestamp.asc())
292
+ .all()
293
+ )
294
+ return [
295
+ {
296
+ "id": m.id,
297
+ "role": m.role,
298
+ "content": m.content,
299
+ "timestamp": m.timestamp.isoformat() if m.timestamp else None,
300
+ }
301
+ for m in messages
302
+ ]
303
+ finally:
304
+ session.close()
@@ -0,0 +1,57 @@
1
+ """
2
+ Chat Session Constants
3
+ ======================
4
+
5
+ Shared constants for all chat session types (assistant, spec, expand).
6
+
7
+ The canonical ``API_ENV_VARS`` list lives in ``env_constants.py`` at the
8
+ project root and is re-exported here for convenience so that existing
9
+ imports (``from .chat_constants import API_ENV_VARS``) continue to work.
10
+ """
11
+
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import AsyncGenerator
15
+
16
+ # -------------------------------------------------------------------
17
+ # Root directory of the autoforge project (repository root).
18
+ # Used throughout the server package whenever the repo root is needed.
19
+ # -------------------------------------------------------------------
20
+ ROOT_DIR = Path(__file__).parent.parent.parent
21
+
22
+ # Ensure the project root is on sys.path so we can import env_constants
23
+ # from the root-level module without requiring a package install.
24
+ _root_str = str(ROOT_DIR)
25
+ if _root_str not in sys.path:
26
+ sys.path.insert(0, _root_str)
27
+
28
+ # -------------------------------------------------------------------
29
+ # Environment variables forwarded to Claude CLI subprocesses.
30
+ # Single source of truth lives in env_constants.py at the project root.
31
+ # Re-exported here so existing ``from .chat_constants import API_ENV_VARS``
32
+ # imports continue to work unchanged.
33
+ # -------------------------------------------------------------------
34
+ from env_constants import API_ENV_VARS # noqa: E402, F401
35
+
36
+
37
+ async def make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:
38
+ """Yield a single multimodal user message in Claude Agent SDK format.
39
+
40
+ The Claude Agent SDK's ``query()`` method accepts either a plain string
41
+ or an ``AsyncIterable[dict]`` for custom message formats. This helper
42
+ wraps a list of content blocks (text and/or images) in the expected
43
+ envelope.
44
+
45
+ Args:
46
+ content_blocks: List of content-block dicts, e.g.
47
+ ``[{"type": "text", "text": "..."}, {"type": "image", ...}]``.
48
+
49
+ Yields:
50
+ A single dict representing the user message.
51
+ """
52
+ yield {
53
+ "type": "user",
54
+ "message": {"role": "user", "content": content_blocks},
55
+ "parent_tool_use_id": None,
56
+ "session_id": "default",
57
+ }