onecoder 0.0.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.
- onecoder/agent.py +95 -0
- onecoder/agentic_tool_search/__init__.py +0 -0
- onecoder/agentic_tool_search/dynamic_tool_search.py +64 -0
- onecoder/agentic_tool_search/registry.py +33 -0
- onecoder/agents/__init__.py +7 -0
- onecoder/agents/documentation_agent.py +12 -0
- onecoder/agents/file_reader_agent.py +19 -0
- onecoder/agents/file_writer_agent.py +19 -0
- onecoder/agents/orchestrator_agent.py +51 -0
- onecoder/agents/refactoring_agent.py +12 -0
- onecoder/agents/research_agent.py +31 -0
- onecoder/agents/task_suggestion_agent.py +88 -0
- onecoder/alignment.py +236 -0
- onecoder/api.py +162 -0
- onecoder/api_client.py +112 -0
- onecoder/backends/base.py +22 -0
- onecoder/backends/local_tui.py +65 -0
- onecoder/blackboard.py +102 -0
- onecoder/cli.py +108 -0
- onecoder/commands/__init__.py +1 -0
- onecoder/commands/auth.py +78 -0
- onecoder/commands/ci.py +29 -0
- onecoder/commands/delegate.py +557 -0
- onecoder/commands/doctor.py +40 -0
- onecoder/commands/issue.py +136 -0
- onecoder/commands/logs.py +45 -0
- onecoder/commands/project.py +270 -0
- onecoder/commands/server.py +170 -0
- onecoder/config_manager.py +87 -0
- onecoder/constants.py +9 -0
- onecoder/diagnostics/__init__.py +2 -0
- onecoder/diagnostics/env_scan.py +207 -0
- onecoder/discovery.py +101 -0
- onecoder/distillation.py +236 -0
- onecoder/evaluation/__init__.py +1 -0
- onecoder/evaluation/ttu.py +176 -0
- onecoder/governance/__init__.py +0 -0
- onecoder/governance/probllm.py +91 -0
- onecoder/hooks.py +74 -0
- onecoder/ipc_auth.py +200 -0
- onecoder/issues.py +188 -0
- onecoder/jules_client.py +343 -0
- onecoder/knowledge.py +106 -0
- onecoder/llm.py +61 -0
- onecoder/logger.py +42 -0
- onecoder/metrics.py +129 -0
- onecoder/models/delegation.py +46 -0
- onecoder/onboarding.py +264 -0
- onecoder/review.py +233 -0
- onecoder/services/delegation_service.py +209 -0
- onecoder/services/validation_service.py +104 -0
- onecoder/sessions.py +186 -0
- onecoder/sprint_collector.py +165 -0
- onecoder/sync.py +167 -0
- onecoder/tmux.py +86 -0
- onecoder/tools/__init__.py +10 -0
- onecoder/tools/executor.py +53 -0
- onecoder/tools/external_tools.py +106 -0
- onecoder/tools/file_tools.py +77 -0
- onecoder/tools/interface.py +25 -0
- onecoder/tools/jules_tools.py +122 -0
- onecoder/tools/kit_tools.py +122 -0
- onecoder/tools/registry.py +32 -0
- onecoder/tui/__init__.py +5 -0
- onecoder/tui/app.py +263 -0
- onecoder/tui/commands.py +150 -0
- onecoder/tui/widgets.py +92 -0
- onecoder/worktree.py +186 -0
- onecoder-0.0.2.dist-info/METADATA +17 -0
- onecoder-0.0.2.dist-info/RECORD +73 -0
- onecoder-0.0.2.dist-info/WHEEL +5 -0
- onecoder-0.0.2.dist-info/entry_points.txt +2 -0
- onecoder-0.0.2.dist-info/top_level.txt +1 -0
onecoder/alignment.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Dict, Any, Optional
|
|
7
|
+
from .agent import get_model
|
|
8
|
+
|
|
9
|
+
class AlignmentTracker:
|
|
10
|
+
def __init__(self, repo_root: Optional[Path] = None):
|
|
11
|
+
self.repo_root = repo_root or self._find_repo_root()
|
|
12
|
+
self.alignment_dir = self.repo_root / ".onecoder" / "alignment"
|
|
13
|
+
self.logs_dir = self.alignment_dir / "logs"
|
|
14
|
+
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
|
|
16
|
+
def _find_repo_root(self) -> Path:
|
|
17
|
+
current = Path.cwd().resolve()
|
|
18
|
+
while current != current.parent:
|
|
19
|
+
if (current / ".git").exists():
|
|
20
|
+
return current
|
|
21
|
+
current = current.parent
|
|
22
|
+
return Path.cwd().resolve()
|
|
23
|
+
|
|
24
|
+
def get_time_window(self, hour: int) -> str:
|
|
25
|
+
if 6 <= hour < 12:
|
|
26
|
+
return "Morning"
|
|
27
|
+
elif 12 <= hour < 18:
|
|
28
|
+
return "Afternoon"
|
|
29
|
+
elif 18 <= hour < 24:
|
|
30
|
+
return "Evening"
|
|
31
|
+
else:
|
|
32
|
+
return "Night"
|
|
33
|
+
|
|
34
|
+
def fetch_recent_prs(self, limit: int = 5) -> List[Dict[str, Any]]:
|
|
35
|
+
"""Fetches recent merged PRs using gh CLI."""
|
|
36
|
+
try:
|
|
37
|
+
result = subprocess.run(
|
|
38
|
+
["gh", "pr", "list", "--state", "merged", "--limit", str(limit), "--json", "number,title,mergedAt,author,url"],
|
|
39
|
+
capture_output=True,
|
|
40
|
+
text=True,
|
|
41
|
+
check=True
|
|
42
|
+
)
|
|
43
|
+
return json.loads(result.stdout)
|
|
44
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
def log_current_state(self) -> str:
|
|
48
|
+
"""Logs the current commit and PR status to the filesystem."""
|
|
49
|
+
now = datetime.datetime.now()
|
|
50
|
+
date_str = now.strftime("%Y-%m-%d")
|
|
51
|
+
window = self.get_time_window(now.hour)
|
|
52
|
+
|
|
53
|
+
# Get latest commit
|
|
54
|
+
try:
|
|
55
|
+
commit_hash = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
|
|
56
|
+
commit_msg = subprocess.check_output(["git", "log", "-1", "--pretty=%B"], text=True).strip()
|
|
57
|
+
except subprocess.CalledProcessError:
|
|
58
|
+
commit_hash = "unknown"
|
|
59
|
+
commit_msg = "unknown"
|
|
60
|
+
|
|
61
|
+
recent_prs = self.fetch_recent_prs()
|
|
62
|
+
|
|
63
|
+
log_file = self.logs_dir / f"{date_str}.json"
|
|
64
|
+
|
|
65
|
+
if log_file.exists():
|
|
66
|
+
with open(log_file, "r") as f:
|
|
67
|
+
try:
|
|
68
|
+
daily_logs = json.load(f)
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
daily_logs = {}
|
|
71
|
+
else:
|
|
72
|
+
daily_logs = {}
|
|
73
|
+
|
|
74
|
+
if window not in daily_logs:
|
|
75
|
+
daily_logs[window] = []
|
|
76
|
+
|
|
77
|
+
entry = {
|
|
78
|
+
"timestamp": now.isoformat(),
|
|
79
|
+
"commit_hash": commit_hash,
|
|
80
|
+
"commit_message": commit_msg,
|
|
81
|
+
"recent_prs": recent_prs
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
daily_logs[window].append(entry)
|
|
85
|
+
|
|
86
|
+
with open(log_file, "w") as f:
|
|
87
|
+
json.dump(daily_logs, f, indent=4)
|
|
88
|
+
|
|
89
|
+
return f"Logged state for {window} in {log_file.name}"
|
|
90
|
+
|
|
91
|
+
def check_roadmap_alignment(self) -> Dict[str, Any]:
|
|
92
|
+
"""Checks current activity against MVP_ROADMAP.md."""
|
|
93
|
+
roadmap_path = self.repo_root / "MVP_ROADMAP.md"
|
|
94
|
+
if not roadmap_path.exists():
|
|
95
|
+
return {"status": "unknown", "message": "MVP_ROADMAP.md not found"}
|
|
96
|
+
|
|
97
|
+
roadmap_content = roadmap_path.read_text()
|
|
98
|
+
|
|
99
|
+
# Simple heuristic check: find active sections mentioned in recent commits
|
|
100
|
+
try:
|
|
101
|
+
recent_commits = subprocess.check_output(["git", "log", "-n", "10", "--pretty=%s"], text=True).split("\n")
|
|
102
|
+
except subprocess.CalledProcessError:
|
|
103
|
+
recent_commits = []
|
|
104
|
+
|
|
105
|
+
aligned_items = []
|
|
106
|
+
for line in roadmap_content.split("\n"):
|
|
107
|
+
if "###" in line or "- **" in line:
|
|
108
|
+
item = line.strip("#-* ").split(" (")[0]
|
|
109
|
+
for commit in recent_commits:
|
|
110
|
+
if item.lower() in commit.lower():
|
|
111
|
+
aligned_items.append(item)
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
"status": "aligned" if aligned_items else "diverged",
|
|
116
|
+
"aligned_items": list(set(aligned_items)),
|
|
117
|
+
"message": f"Found {len(aligned_items)} items aligned with roadmap."
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
def capture_suggestions(self) -> List[str]:
|
|
121
|
+
"""Scans ANTIGRAVITY.md and recent RETRO.md files for suggestions."""
|
|
122
|
+
suggestions = []
|
|
123
|
+
|
|
124
|
+
# Scan ANTIGRAVITY.md
|
|
125
|
+
antigravity_path = self.repo_root / "ANTIGRAVITY.md"
|
|
126
|
+
if antigravity_path.exists():
|
|
127
|
+
content = antigravity_path.read_text()
|
|
128
|
+
# Simple heuristic: lines in 'Future' or 'To Watch' sections
|
|
129
|
+
capture = False
|
|
130
|
+
for line in content.split("\n"):
|
|
131
|
+
if "##" in line and any(x in line for x in ["Future", "Watch", "Improvement"]):
|
|
132
|
+
capture = True
|
|
133
|
+
elif "##" in line:
|
|
134
|
+
capture = False
|
|
135
|
+
|
|
136
|
+
if capture and line.strip().startswith("-"):
|
|
137
|
+
suggestions.append(line.strip("- ").strip())
|
|
138
|
+
|
|
139
|
+
# Scan recent RETRO.md
|
|
140
|
+
sprint_dir = self.repo_root / ".sprint"
|
|
141
|
+
if sprint_dir.exists():
|
|
142
|
+
retro_files = sorted(list(sprint_dir.glob("*/RETRO.md")), key=os.path.getmtime, reverse=True)[:3]
|
|
143
|
+
for retro_path in retro_files:
|
|
144
|
+
content = retro_path.read_text()
|
|
145
|
+
# Simple heuristic: bullet points under 'To Improve' or 'Learnings'
|
|
146
|
+
capture = False
|
|
147
|
+
for line in content.split("\n"):
|
|
148
|
+
if "##" in line and any(x in line for x in ["Improve", "Learning", "Next"]):
|
|
149
|
+
capture = True
|
|
150
|
+
elif "##" in line:
|
|
151
|
+
capture = False
|
|
152
|
+
|
|
153
|
+
if capture and line.strip().startswith("-"):
|
|
154
|
+
suggestions.append(line.strip("- ").strip())
|
|
155
|
+
|
|
156
|
+
return list(set(suggestions))
|
|
157
|
+
|
|
158
|
+
def summarize_alignment_agentic(self, alignment_data: Dict[str, Any], recent_prs: List[Dict[str, Any]], suggestions: List[str]) -> str:
|
|
159
|
+
"""Uses LLM to summarize the alignment status."""
|
|
160
|
+
try:
|
|
161
|
+
from google.adk.agents import LlmAgent
|
|
162
|
+
model = get_model()
|
|
163
|
+
agent = LlmAgent(
|
|
164
|
+
name="alignment_summarizer",
|
|
165
|
+
model=model,
|
|
166
|
+
instruction="You are a project manager analyst. Provide a 2-3 sentence summary of project health."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
prompt = f"""
|
|
170
|
+
Analyze current alignment:
|
|
171
|
+
Roadmap Status: {alignment_data['status']}
|
|
172
|
+
Aligned Items: {alignment_data['aligned_items']}
|
|
173
|
+
Recent PRs: {json.dumps(recent_prs)}
|
|
174
|
+
Suggestions: {json.dumps(suggestions[:5])}
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
response = agent.run(prompt)
|
|
178
|
+
return response
|
|
179
|
+
|
|
180
|
+
except Exception as e:
|
|
181
|
+
return f"Agentic summary unavailable: {e}"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
except Exception as e:
|
|
185
|
+
return {"status": "DRIFTING", "message": f"Semantic check fallback: {e}", "aligned_items": [], "drift_items": []}
|
|
186
|
+
|
|
187
|
+
def check_roadmap_alignment_agentic(self) -> Dict[str, Any]:
|
|
188
|
+
"""Uses LLM to semantically compare activity against MVP_ROADMAP.md."""
|
|
189
|
+
roadmap_path = self.repo_root / "MVP_ROADMAP.md"
|
|
190
|
+
if not roadmap_path.exists():
|
|
191
|
+
return {"status": "unknown", "message": "MVP_ROADMAP.md not found"}
|
|
192
|
+
|
|
193
|
+
roadmap_content = roadmap_path.read_text()
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
recent_commits = subprocess.check_output(["git", "log", "-n", "20", "--pretty=%s"], text=True)
|
|
197
|
+
recent_prs = self.fetch_recent_prs(limit=10)
|
|
198
|
+
|
|
199
|
+
model = get_model()
|
|
200
|
+
|
|
201
|
+
prompt = f"""
|
|
202
|
+
Analyze the following Roadmap and recent development activity (commits and PRs).
|
|
203
|
+
Perform a semantic mapping of the activity to the Roadmap items.
|
|
204
|
+
|
|
205
|
+
ROADMAP:
|
|
206
|
+
{roadmap_content}
|
|
207
|
+
|
|
208
|
+
RECENT COMMITS:
|
|
209
|
+
{recent_commits}
|
|
210
|
+
|
|
211
|
+
RECENT PRS:
|
|
212
|
+
{json.dumps(recent_prs)}
|
|
213
|
+
|
|
214
|
+
Categorize the project status as:
|
|
215
|
+
- **ALIGNED**: Work is directly advancing items in the Roadmap.
|
|
216
|
+
- **DRIFTING**: Work is valuable but not explicitly in the Roadmap.
|
|
217
|
+
- **DIVERGED**: Work is unrelated to the Roadmap goals.
|
|
218
|
+
|
|
219
|
+
Format your response as a JSON object:
|
|
220
|
+
{{
|
|
221
|
+
"status": "ALIGNED" | "DRIFTING" | "DIVERGED",
|
|
222
|
+
"aligned_items": ["List of items from the roadmap that the work aligns with"],
|
|
223
|
+
"drift_items": ["List of work items that are not in the roadmap"],
|
|
224
|
+
"message": "A brief explanation of the alignment status (1-2 sentences)"
|
|
225
|
+
}}
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
if hasattr(model, "completion"):
|
|
229
|
+
response = model.completion(messages=[{"role": "user", "content": prompt}], response_format={ "type": "json_object" })
|
|
230
|
+
data = json.loads(response.choices[0].message.content)
|
|
231
|
+
return data
|
|
232
|
+
else:
|
|
233
|
+
return {"status": "DRIFTING", "aligned_items": [], "drift_items": [], "message": "LiteLLM fallback. Run with GEMINI_API_KEY for semantic analysis."}
|
|
234
|
+
|
|
235
|
+
except Exception as e:
|
|
236
|
+
return {"status": "error", "message": f"Semantic check failed: {e}", "aligned_items": [], "drift_items": []}
|
onecoder/api.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import asyncio
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from fastapi import FastAPI, Request, HTTPException, Depends
|
|
7
|
+
from fastapi.responses import StreamingResponse, RedirectResponse
|
|
8
|
+
from fastapi.staticfiles import StaticFiles
|
|
9
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
10
|
+
from google.adk.runners import Runner
|
|
11
|
+
from .sessions import DurableSessionService
|
|
12
|
+
from google.genai.types import Content, Part
|
|
13
|
+
from .ipc_auth import TOKEN_STORE
|
|
14
|
+
from .agent import get_root_agent
|
|
15
|
+
from .distillation import capture_engine
|
|
16
|
+
|
|
17
|
+
app = FastAPI(title="OneCoder Agent API")
|
|
18
|
+
|
|
19
|
+
# Add CORS middleware for local development
|
|
20
|
+
app.add_middleware(
|
|
21
|
+
CORSMiddleware,
|
|
22
|
+
allow_origins=["*"],
|
|
23
|
+
allow_credentials=True,
|
|
24
|
+
allow_methods=["*"],
|
|
25
|
+
allow_headers=["*"],
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Mount static files for web UI
|
|
29
|
+
static_dir = Path(__file__).parent / "static"
|
|
30
|
+
if static_dir.exists():
|
|
31
|
+
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
|
32
|
+
|
|
33
|
+
# Setup ADK session service and runner
|
|
34
|
+
session_service = DurableSessionService()
|
|
35
|
+
runner = Runner(
|
|
36
|
+
session_service=session_service, agent=get_root_agent(), app_name="onecoder-unified-api"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.get("/")
|
|
41
|
+
async def root(token: str | None = None):
|
|
42
|
+
"""Root endpoint - redirects to web UI if token provided."""
|
|
43
|
+
if token:
|
|
44
|
+
return RedirectResponse(url=f"/static/index.html?token={token}")
|
|
45
|
+
return {
|
|
46
|
+
"message": "OneCoder API",
|
|
47
|
+
"version": "0.1.0",
|
|
48
|
+
"endpoints": {
|
|
49
|
+
"web_ui": "/static/index.html?token=<token>",
|
|
50
|
+
"chat": "POST /chat",
|
|
51
|
+
"stream": "GET /stream",
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def verify_token(request: Request):
|
|
57
|
+
"""
|
|
58
|
+
Middleware-like dependency to verify session-based tokens.
|
|
59
|
+
The token can be passed in 'Authorization: Bearer <token>' or '?token=<token>'.
|
|
60
|
+
"""
|
|
61
|
+
token = request.query_params.get("token")
|
|
62
|
+
auth_header = request.headers.get("Authorization")
|
|
63
|
+
|
|
64
|
+
if auth_header and auth_header.startswith("Bearer "):
|
|
65
|
+
token = auth_header.split(" ")[1]
|
|
66
|
+
|
|
67
|
+
if not token or not TOKEN_STORE.validate_token(token):
|
|
68
|
+
raise HTTPException(
|
|
69
|
+
status_code=401, detail="Unauthorized: Invalid or expired token"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.post("/chat")
|
|
74
|
+
async def chat(user_id: str, session_id: str, message: str, _=Depends(verify_token)):
|
|
75
|
+
"""
|
|
76
|
+
Standard synchronous chat endpoint (returns full response).
|
|
77
|
+
"""
|
|
78
|
+
session = await session_service.get_session(
|
|
79
|
+
app_name="onecoder-unified-api", user_id=user_id, session_id=session_id
|
|
80
|
+
)
|
|
81
|
+
if not session:
|
|
82
|
+
session = await session_service.create_session(
|
|
83
|
+
app_name="onecoder-unified-api", user_id=user_id, session_id=session_id
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
final_text = ""
|
|
87
|
+
async for event in runner.run_async(
|
|
88
|
+
user_id=user_id,
|
|
89
|
+
session_id=session_id,
|
|
90
|
+
new_message=Content(parts=[Part(text=message)], role="user"),
|
|
91
|
+
):
|
|
92
|
+
if hasattr(event, "content") and event.content and event.content.parts:
|
|
93
|
+
text_part = next((part for part in event.content.parts if part.text), None)
|
|
94
|
+
if text_part and text_part.text:
|
|
95
|
+
final_text += text_part.text
|
|
96
|
+
|
|
97
|
+
return {"response": final_text}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.get("/stream")
|
|
101
|
+
async def stream(user_id: str, session_id: str, message: str, token: str):
|
|
102
|
+
"""
|
|
103
|
+
SSE endpoint for streaming agent responses and events.
|
|
104
|
+
FastAPI handles StreamingResponse well for this.
|
|
105
|
+
Note: Token validation is done inside since SSE doesn't always support headers easily.
|
|
106
|
+
"""
|
|
107
|
+
if not TOKEN_STORE.validate_token(token):
|
|
108
|
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
109
|
+
|
|
110
|
+
session = await session_service.get_session(
|
|
111
|
+
app_name="onecoder-unified-api", user_id=user_id, session_id=session_id
|
|
112
|
+
)
|
|
113
|
+
if not session:
|
|
114
|
+
session = await session_service.create_session(
|
|
115
|
+
app_name="onecoder-unified-api", user_id=user_id, session_id=session_id
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
async def event_generator():
|
|
119
|
+
capture_engine.start_session(session_id)
|
|
120
|
+
try:
|
|
121
|
+
async for event in runner.run_async(
|
|
122
|
+
user_id=user_id,
|
|
123
|
+
session_id=session_id,
|
|
124
|
+
new_message=Content(parts=[Part(text=message)], role="user"),
|
|
125
|
+
):
|
|
126
|
+
# Format event for SSE
|
|
127
|
+
event_data: dict[str, str | dict[str, Any] | None] = {
|
|
128
|
+
"type": type(event).__name__,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if hasattr(event, "content") and event.content and event.content.parts:
|
|
132
|
+
text_part = next(
|
|
133
|
+
(part for part in event.content.parts if part.text), None
|
|
134
|
+
)
|
|
135
|
+
if text_part and text_part.text:
|
|
136
|
+
event_data["text"] = text_part.text
|
|
137
|
+
|
|
138
|
+
# Capture tool calls or delegation events if present
|
|
139
|
+
tool_call = next(
|
|
140
|
+
(part for part in event.content.parts if part.function_call),
|
|
141
|
+
None,
|
|
142
|
+
)
|
|
143
|
+
if tool_call and tool_call.function_call:
|
|
144
|
+
event_data["tool_call"] = {
|
|
145
|
+
"name": tool_call.function_call.name,
|
|
146
|
+
"args": tool_call.function_call.args,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
capture_engine.log_event(event_data)
|
|
150
|
+
yield f"data: {json.dumps(event_data)}\n\n"
|
|
151
|
+
|
|
152
|
+
capture_engine.save_session()
|
|
153
|
+
except Exception as e:
|
|
154
|
+
yield f"data: {json.dumps({'type': 'Error', 'message': str(e)})}\n\n"
|
|
155
|
+
|
|
156
|
+
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
import uvicorn
|
|
161
|
+
|
|
162
|
+
uvicorn.run(app, host="127.0.0.1", port=8000)
|
onecoder/api_client.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
from typing import Optional, Dict, Any, List
|
|
5
|
+
|
|
6
|
+
class OneCoderAPIClient:
|
|
7
|
+
def __init__(self, base_url: str, token: Optional[str] = None):
|
|
8
|
+
self.base_url = base_url.rstrip("/")
|
|
9
|
+
self.token = token
|
|
10
|
+
self.headers = {
|
|
11
|
+
"Content-Type": "application/json",
|
|
12
|
+
"Accept": "application/json",
|
|
13
|
+
}
|
|
14
|
+
if self.token:
|
|
15
|
+
self.headers["Authorization"] = f"Bearer {self.token}"
|
|
16
|
+
|
|
17
|
+
def set_token(self, token: str):
|
|
18
|
+
"""Update the client's token."""
|
|
19
|
+
self.token = token
|
|
20
|
+
self.headers["Authorization"] = f"Bearer {token}"
|
|
21
|
+
|
|
22
|
+
async def _request(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
|
|
23
|
+
"""Internal helper for API requests."""
|
|
24
|
+
url = f"{self.base_url}/api/v1{path}"
|
|
25
|
+
if path.startswith("/api/v1"):
|
|
26
|
+
url = f"{self.base_url}{path}"
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
logging.debug(f"Making request to: {url}")
|
|
30
|
+
|
|
31
|
+
async with httpx.AsyncClient() as client:
|
|
32
|
+
response = await client.request(method, url, headers=self.headers, **kwargs)
|
|
33
|
+
response.raise_for_status()
|
|
34
|
+
return response.json()
|
|
35
|
+
|
|
36
|
+
async def login_with_github(self, code: str) -> Dict[str, Any]:
|
|
37
|
+
"""Exchange GitHub OAuth code for a JWT token."""
|
|
38
|
+
data = await self._request("POST", "/auth/github", json={"code": code})
|
|
39
|
+
if "token" not in data:
|
|
40
|
+
raise ValueError(f"Invalid API response: Missing 'token' field. Got keys: {list(data.keys())}")
|
|
41
|
+
self.set_token(data["token"])
|
|
42
|
+
return data
|
|
43
|
+
|
|
44
|
+
async def get_preferences(self) -> Dict[str, Any]:
|
|
45
|
+
"""Fetch user preferences from the API."""
|
|
46
|
+
return await self._request("GET", "/users/me/preferences")
|
|
47
|
+
|
|
48
|
+
async def update_preferences(self, preferences: Dict[str, Any]) -> Dict[str, Any]:
|
|
49
|
+
"""Update user preferences via the API."""
|
|
50
|
+
return await self._request("PUT", "/users/me/preferences", json=preferences)
|
|
51
|
+
|
|
52
|
+
async def get_me(self) -> Dict[str, Any]:
|
|
53
|
+
"""Get current user information."""
|
|
54
|
+
url = f"{self.base_url}/api/v1/users/me"
|
|
55
|
+
# Since /api/v1/users/me is not explicitly implemented in my last edit,
|
|
56
|
+
# I'll use /api/v1/auth/me or similar if I implement it, or just stick to what exists.
|
|
57
|
+
# Actually, let's implement /api/v1/users/me in the API later or use the token payload.
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
async def sync_project(self, project_id: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
61
|
+
"""Sync project durable context (specs, metadata) to the API."""
|
|
62
|
+
url = f"{self.base_url}/api/v1/projects/{project_id}/sync"
|
|
63
|
+
return await self._request("POST", f"/projects/{project_id}/sync", json=data)
|
|
64
|
+
|
|
65
|
+
async def sync_sprint(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
66
|
+
"""Sync sprint state (tasks, status) to the API."""
|
|
67
|
+
return await self._request("POST", "/sprints/sync", json=data)
|
|
68
|
+
|
|
69
|
+
async def sync_issues(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
70
|
+
"""Syncs issue data."""
|
|
71
|
+
return await self._request("POST", "/issues/sync", json=data)
|
|
72
|
+
|
|
73
|
+
async def get_workspaces(self) -> List[Dict[str, Any]]:
|
|
74
|
+
"""Fetch list of available workspaces."""
|
|
75
|
+
data = await self._request("GET", "/workspaces")
|
|
76
|
+
return data.get("workspaces", [])
|
|
77
|
+
|
|
78
|
+
async def create_workspace(self, name: str) -> Dict[str, Any]:
|
|
79
|
+
"""Create a new workspace."""
|
|
80
|
+
data = await self._request("POST", "/workspaces", json={"name": name})
|
|
81
|
+
return data.get("workspace", {})
|
|
82
|
+
|
|
83
|
+
async def create_project(self, name: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
|
|
84
|
+
"""Create a new project."""
|
|
85
|
+
payload = {"name": name}
|
|
86
|
+
if workspace_id:
|
|
87
|
+
payload["workspaceId"] = workspace_id
|
|
88
|
+
data = await self._request("POST", "/projects", json=payload)
|
|
89
|
+
return data.get("project", {})
|
|
90
|
+
|
|
91
|
+
async def join_workspace(self, workspace_id: str, project_id: str) -> Dict[str, Any]:
|
|
92
|
+
"""Associate a project with a workspace."""
|
|
93
|
+
return await self._request("POST", f"/workspaces/{workspace_id}/projects", json={"projectId": project_id})
|
|
94
|
+
|
|
95
|
+
async def upload_failure(self, failure_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
96
|
+
"""Upload a single failure mode to the API."""
|
|
97
|
+
return await self._request("POST", "/failure-modes", json=failure_data)
|
|
98
|
+
|
|
99
|
+
async def analyze_project(self, scan_data: Dict[str, Any], user_feedback: Optional[str] = None) -> Dict[str, Any]:
|
|
100
|
+
"""Request server-side analysis of a project."""
|
|
101
|
+
payload = {"scanData": scan_data}
|
|
102
|
+
if user_feedback:
|
|
103
|
+
payload["userFeedback"] = user_feedback
|
|
104
|
+
|
|
105
|
+
# Increase timeout for analysis as it involves LLM processing
|
|
106
|
+
return await self._request("POST", "/projects/analyze", json=payload, timeout=60.0)
|
|
107
|
+
|
|
108
|
+
from .constants import ONECODER_API_URL
|
|
109
|
+
|
|
110
|
+
def get_api_client(token: Optional[str] = None) -> OneCoderAPIClient:
|
|
111
|
+
base_url = ONECODER_API_URL
|
|
112
|
+
return OneCoderAPIClient(base_url, token)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from ..models.delegation import DelegationSession
|
|
4
|
+
|
|
5
|
+
class BaseBackend(ABC):
|
|
6
|
+
"""
|
|
7
|
+
Base class for delegation backends.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
async def spawn(self, session: DelegationSession) -> str:
|
|
12
|
+
"""
|
|
13
|
+
Spawns the delegated task and returns a connection string or identifier.
|
|
14
|
+
"""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
async def cleanup(self, session: DelegationSession):
|
|
19
|
+
"""
|
|
20
|
+
Cleans up resources associated with the session.
|
|
21
|
+
"""
|
|
22
|
+
pass
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from .base import BaseBackend
|
|
5
|
+
from ..models.delegation import DelegationSession
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from ..services.delegation_service import DelegationService
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
class LocalTUIBackend(BaseBackend):
|
|
14
|
+
"""
|
|
15
|
+
Spawns a local terminal UI (via tmux) in an isolated worktree.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, delegation_service: 'DelegationService'):
|
|
19
|
+
self.service = delegation_service
|
|
20
|
+
|
|
21
|
+
async def spawn(self, session: DelegationSession) -> str:
|
|
22
|
+
# Use delegation service to handle the heavy lifting
|
|
23
|
+
logger.info(f"Spawning local TUI for session {session.id}")
|
|
24
|
+
|
|
25
|
+
# 1. Create worktree
|
|
26
|
+
wt_path = self.service.worktree_mgr.create_worktree(session.id)
|
|
27
|
+
|
|
28
|
+
# 2. Reduce TTU: Inject context
|
|
29
|
+
self.service._inject_context(wt_path, session)
|
|
30
|
+
|
|
31
|
+
# 3. Define tmux session name
|
|
32
|
+
tmux_name = f"onecoder-{session.id[:8]}"
|
|
33
|
+
|
|
34
|
+
# 4. Create tmux session
|
|
35
|
+
# Use gemini as default command if available, fallback to shell
|
|
36
|
+
# We also pass the task prompt to a temporary file for the agent to read
|
|
37
|
+
agent_cmd = "gemini"
|
|
38
|
+
|
|
39
|
+
# Check if gemini is in path
|
|
40
|
+
import shutil
|
|
41
|
+
if not shutil.which("gemini"):
|
|
42
|
+
logger.warning("Gemini CLI not found in PATH, falling back to shell.")
|
|
43
|
+
agent_cmd = "$SHELL"
|
|
44
|
+
|
|
45
|
+
cmd = f"cd {wt_path} && export ACTIVE_SPRINT_ID={os.environ.get('ACTIVE_SPRINT_ID', '')} && {agent_cmd}"
|
|
46
|
+
if session.command:
|
|
47
|
+
# Provide the prompt to the agent via a dedicated instruction file
|
|
48
|
+
instruction_path = wt_path / "INSTRUCTIONS.md"
|
|
49
|
+
instruction_path.write_text(f"# Task\n{session.command}")
|
|
50
|
+
# In some agents, we can pass the prompt as an argument
|
|
51
|
+
if agent_cmd == "gemini":
|
|
52
|
+
cmd = f"cd {wt_path} && export ACTIVE_SPRINT_ID={os.environ.get('ACTIVE_SPRINT_ID', '')} && {agent_cmd} 'Review INSTRUCTIONS.md and execute the task.'"
|
|
53
|
+
else:
|
|
54
|
+
cmd = f"cd {wt_path} && echo '# {session.command}' >> BOOTSTRAP.md && {agent_cmd}"
|
|
55
|
+
|
|
56
|
+
self.service.tmux_mgr.create_session(tmux_name, cmd, cwd=str(wt_path))
|
|
57
|
+
|
|
58
|
+
# 5. Update session status
|
|
59
|
+
session.mark_running(tmux_session=tmux_name, worktree_path=str(wt_path))
|
|
60
|
+
self.service._save_session(session)
|
|
61
|
+
|
|
62
|
+
return f"tmux attach -t {tmux_name}"
|
|
63
|
+
|
|
64
|
+
async def cleanup(self, session: DelegationSession):
|
|
65
|
+
self.service.stop_session(session.id)
|