oadson 1.0.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.
oadson/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """OADSON Terminal — AI coding agent for your local shell."""
2
+ __version__ = "1.0.0"
oadson/agent.py ADDED
@@ -0,0 +1,246 @@
1
+ """
2
+ agent.py — Local agent loop.
3
+
4
+ Sends your message + shell context to Railway.
5
+ Railway's AI responds with text and/or tool calls.
6
+ Tool calls are executed locally here, results fed back.
7
+ Loop repeats until AI has no more tool calls.
8
+
9
+ This is the core of OADSON terminal — same pattern as Claude Code.
10
+ """
11
+ import json
12
+ from typing import Optional
13
+
14
+ import httpx
15
+ from rich.console import Console
16
+ from rich.live import Live
17
+ from rich.spinner import Spinner
18
+ from rich.text import Text
19
+
20
+ from oadson.config import get_backend_url, get_token
21
+ from oadson.context import collect, format_for_prompt
22
+ from oadson.executor import execute_tool
23
+
24
+ console = Console()
25
+
26
+ MAX_ITERATIONS = 10 # safety cap on tool call loops
27
+ REQUEST_TIMEOUT = 90 # seconds — generous for Nigeria network
28
+
29
+
30
+ class NetworkError(Exception):
31
+ pass
32
+
33
+
34
+ def _headers() -> dict:
35
+ return {
36
+ "Authorization": f"Bearer {get_token()}",
37
+ "Content-Type": "application/json",
38
+ "X-OADSON-Surface": "terminal",
39
+ }
40
+
41
+
42
+ def _build_payload(
43
+ message: str,
44
+ session_id: str,
45
+ context: dict,
46
+ tool_results: Optional[list] = None,
47
+ ) -> dict:
48
+ payload = {
49
+ "message": message,
50
+ "session_id": session_id,
51
+ "surface": "terminal",
52
+ "context": format_for_prompt(context),
53
+ }
54
+ if tool_results:
55
+ payload["tool_results"] = tool_results
56
+ return payload
57
+
58
+
59
+ def run(
60
+ message: str,
61
+ session_id: str,
62
+ last_exit_code: int = 0,
63
+ last_command: str = "",
64
+ stream: bool = True,
65
+ ) -> str:
66
+ """
67
+ Main agent loop. Call this for every user message.
68
+
69
+ Returns the final text reply from the AI.
70
+ Exits cleanly on network error — no crash, just a message.
71
+ """
72
+ backend = get_backend_url()
73
+ if not backend:
74
+ return "[bold red]⚠ Not configured.[/bold red] Run: oadson setup"
75
+
76
+ # Collect shell context
77
+ ctx = collect(last_exit_code=last_exit_code, last_command=last_command)
78
+
79
+ tool_results = []
80
+ final_reply = ""
81
+ iterations = 0
82
+
83
+ while iterations < MAX_ITERATIONS:
84
+ iterations += 1
85
+ payload = _build_payload(message, session_id, ctx, tool_results or None)
86
+
87
+ try:
88
+ if stream:
89
+ final_reply = _stream_request(backend, payload)
90
+ else:
91
+ final_reply = _plain_request(backend, payload)
92
+
93
+ except NetworkError as e:
94
+ return f"[bold red]⚠ No network:[/bold red] {e}\nYour session is safe — retry when connected."
95
+ except Exception as e:
96
+ return f"[bold red]⚠ Error:[/bold red] {e}"
97
+
98
+ # Parse tool calls from response
99
+ calls = _extract_tool_calls(final_reply)
100
+ if not calls:
101
+ break # AI is done — no more tools needed
102
+
103
+ # Execute each tool locally
104
+ tool_results = []
105
+ for call in calls:
106
+ tool_name = call.get("tool")
107
+ args = call.get("args", {})
108
+
109
+ console.print(f"\n[dim]→ Tool: {tool_name}[/dim]")
110
+ result = execute_tool(tool_name, args)
111
+
112
+ tool_results.append({
113
+ "tool": tool_name,
114
+ "args": args,
115
+ "result": result,
116
+ })
117
+
118
+ # Show result summary
119
+ if result.get("status") == "success":
120
+ console.print(f"[green]✓ {tool_name}[/green]")
121
+ elif result.get("status") == "cancelled":
122
+ console.print(f"[yellow]↩ {tool_name} cancelled[/yellow]")
123
+ else:
124
+ console.print(f"[red]✗ {tool_name}: {result.get('reason', 'failed')}[/red]")
125
+
126
+ # Feed results back for next iteration
127
+ message = "" # subsequent turns are tool-result driven
128
+
129
+ return final_reply
130
+
131
+
132
+ def _stream_request(backend: str, payload: dict) -> str:
133
+ """
134
+ Streaming request — prints words as they arrive.
135
+ Returns full text when done.
136
+ """
137
+ url = f"{backend}/api/v2/chat/stream"
138
+ full_text = ""
139
+
140
+ try:
141
+ with httpx.Client(timeout=REQUEST_TIMEOUT) as client:
142
+ with client.stream("POST", url, json=payload, headers=_headers()) as resp:
143
+ if resp.status_code == 401:
144
+ raise NetworkError("Invalid token — run: oadson setup")
145
+ if resp.status_code == 404:
146
+ # Fallback to non-streaming
147
+ return _plain_request(backend, payload)
148
+ if resp.status_code != 200:
149
+ raise NetworkError(f"HTTP {resp.status_code}")
150
+
151
+ console.print() # newline before streaming
152
+ for chunk in resp.iter_text():
153
+ if chunk:
154
+ # Handle SSE format: "data: {...}\n"
155
+ for line in chunk.splitlines():
156
+ if line.startswith("data: "):
157
+ data_str = line[6:]
158
+ if data_str.strip() == "[DONE]":
159
+ break
160
+ try:
161
+ data = json.loads(data_str)
162
+ token = (
163
+ data.get("text") or
164
+ data.get("token") or
165
+ data.get("content") or
166
+ data.get("response") or ""
167
+ )
168
+ if token:
169
+ console.print(token, end="", highlight=False)
170
+ full_text += token
171
+ except json.JSONDecodeError:
172
+ # Plain text chunk
173
+ console.print(line, end="", highlight=False)
174
+ full_text += line
175
+ console.print() # newline after streaming
176
+
177
+ except httpx.ConnectError as e:
178
+ raise NetworkError(f"Cannot reach {backend} — {e}")
179
+ except httpx.TimeoutException:
180
+ raise NetworkError(f"Request timed out after {REQUEST_TIMEOUT}s")
181
+
182
+ return full_text
183
+
184
+
185
+ def _plain_request(backend: str, payload: dict) -> str:
186
+ """
187
+ Non-streaming fallback — shows spinner, returns full reply.
188
+ """
189
+ url = f"{backend}/api/v2/chat"
190
+
191
+ with Live(Spinner("dots", text="OADSON is thinking..."), console=console, refresh_per_second=10):
192
+ try:
193
+ with httpx.Client(timeout=REQUEST_TIMEOUT) as client:
194
+ resp = client.post(url, json=payload, headers=_headers())
195
+ except httpx.ConnectError as e:
196
+ raise NetworkError(f"Cannot reach {backend} — {e}")
197
+ except httpx.TimeoutException:
198
+ raise NetworkError(f"Request timed out after {REQUEST_TIMEOUT}s")
199
+
200
+ if resp.status_code == 401:
201
+ raise NetworkError("Invalid token — run: oadson setup")
202
+ if resp.status_code != 200:
203
+ raise NetworkError(f"HTTP {resp.status_code}: {resp.text[:200]}")
204
+
205
+ data = resp.json()
206
+ return (
207
+ data.get("response") or
208
+ data.get("text") or
209
+ data.get("message") or
210
+ str(data)
211
+ )
212
+
213
+
214
+ def _extract_tool_calls(text: str) -> list:
215
+ """
216
+ Parse tool calls from AI response.
217
+ Railway returns them as JSON blocks: ```tool_call\n{...}\n```
218
+ or as a JSON array in the response body.
219
+ """
220
+ calls = []
221
+
222
+ # Format 1: ```tool_call\n{"tool": ..., "args": ...}\n```
223
+ import re
224
+ blocks = re.findall(r"```tool_call\s*\n([\s\S]*?)\n```", text)
225
+ for block in blocks:
226
+ try:
227
+ call = json.loads(block.strip())
228
+ if isinstance(call, list):
229
+ calls.extend(call)
230
+ elif isinstance(call, dict) and "tool" in call:
231
+ calls.append(call)
232
+ except json.JSONDecodeError:
233
+ pass
234
+
235
+ # Format 2: JSON array anywhere in text
236
+ if not calls:
237
+ arr = re.findall(r"\[\s*\{[^]]*\"tool\"[^]]*\}\s*\]", text, re.DOTALL)
238
+ for match in arr:
239
+ try:
240
+ parsed = json.loads(match)
241
+ if isinstance(parsed, list):
242
+ calls.extend(parsed)
243
+ except json.JSONDecodeError:
244
+ pass
245
+
246
+ return calls
oadson/cli.py ADDED
@@ -0,0 +1,431 @@
1
+ """
2
+ cli.py — OADSON Terminal CLI
3
+
4
+ Modes:
5
+ oadson "fix the import error" → one-shot
6
+ oadson → interactive REPL
7
+ oadson setup → configure backend + token
8
+ oadson config → show current config
9
+ oadson session new → start fresh session
10
+ oadson session list → list past sessions
11
+ cat error.log | oadson → pipe mode
12
+ """
13
+ import json
14
+ import os
15
+ import sys
16
+ import uuid
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+ import typer
21
+ from rich.console import Console
22
+ from rich.panel import Panel
23
+ from rich.prompt import Prompt
24
+ from rich.table import Table
25
+ from prompt_toolkit import PromptSession
26
+ from prompt_toolkit.history import FileHistory
27
+ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
28
+ from prompt_toolkit.styles import Style
29
+
30
+ from oadson import agent
31
+ from oadson.config import (
32
+ get_config, save_config, get_backend_url,
33
+ get_token, is_configured,
34
+ )
35
+
36
+ app = typer.Typer(
37
+ name="oadson",
38
+ help="OADSON Terminal — AI coding agent for your local shell",
39
+ add_completion=False,
40
+ no_args_is_help=False,
41
+ )
42
+ session_app = typer.Typer(help="Manage OADSON sessions")
43
+ app.add_typer(session_app, name="session")
44
+
45
+ console = Console()
46
+
47
+ SESSIONS_FILE = Path.home() / ".oadson" / "sessions.json"
48
+ HISTORY_FILE = Path.home() / ".oadson" / "history"
49
+
50
+ PROMPT_STYLE = Style.from_dict({
51
+ "prompt": "bold #00ff88",
52
+ })
53
+
54
+
55
+ # ── SESSION HELPERS ──
56
+
57
+ def _load_sessions() -> dict:
58
+ if SESSIONS_FILE.exists():
59
+ try:
60
+ return json.loads(SESSIONS_FILE.read_text())
61
+ except Exception:
62
+ return {}
63
+ return {}
64
+
65
+
66
+ def _save_sessions(sessions: dict):
67
+ SESSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
68
+ SESSIONS_FILE.write_text(json.dumps(sessions, indent=2))
69
+
70
+
71
+ def _get_active_session() -> str:
72
+ sessions = _load_sessions()
73
+ active = sessions.get("active")
74
+ if active and active in sessions.get("history", {}):
75
+ return active
76
+ # Create default
77
+ return _create_session("default")
78
+
79
+
80
+ def _create_session(name: str = "") -> str:
81
+ sessions = _load_sessions()
82
+ sid = f"terminal_{uuid.uuid4().hex[:8]}"
83
+ history = sessions.get("history", {})
84
+ history[sid] = {
85
+ "id": sid,
86
+ "name": name or sid,
87
+ "created": _now(),
88
+ "updated": _now(),
89
+ }
90
+ sessions["history"] = history
91
+ sessions["active"] = sid
92
+ _save_sessions(sessions)
93
+ return sid
94
+
95
+
96
+ def _now() -> str:
97
+ from datetime import datetime
98
+ return datetime.now().strftime("%Y-%m-%d %H:%M")
99
+
100
+
101
+ # ── COMMANDS ──
102
+
103
+ @app.callback(invoke_without_command=True)
104
+ def main(
105
+ ctx: typer.Context,
106
+ message: Optional[str] = typer.Argument(None, help="Message to send (one-shot mode)"),
107
+ no_stream: bool = typer.Option(False, "--no-stream", help="Disable streaming"),
108
+ ):
109
+ """
110
+ OADSON Terminal — AI coding agent.
111
+
112
+ One-shot: oadson "explain this error"
113
+ Interactive: oadson
114
+ Pipe: cat file.py | oadson
115
+ Setup: oadson setup
116
+ """
117
+ if ctx.invoked_subcommand is not None:
118
+ return
119
+
120
+ if not is_configured():
121
+ console.print("[bold red]⚠ Not configured.[/bold red] Run: [bold]oadson setup[/bold]")
122
+ raise typer.Exit(1)
123
+
124
+ # Pipe mode: read from stdin
125
+ if not sys.stdin.isatty():
126
+ piped = sys.stdin.read().strip()
127
+ if message:
128
+ full = f"{piped}\n\n{message}"
129
+ else:
130
+ full = piped
131
+ _one_shot(full, stream=not no_stream)
132
+ return
133
+
134
+ # One-shot mode
135
+ if message:
136
+ _one_shot(message, stream=not no_stream)
137
+ return
138
+
139
+ # Interactive REPL
140
+ _repl(stream=not no_stream)
141
+
142
+
143
+ def _one_shot(message: str, stream: bool = True):
144
+ sid = _get_active_session()
145
+ reply = agent.run(message, session_id=sid, stream=stream)
146
+ if not stream:
147
+ console.print(reply)
148
+
149
+
150
+ def _repl(stream: bool = True):
151
+ """Interactive REPL with readline history and multiline support."""
152
+ console.print(Panel(
153
+ "[bold #00ff88]OADSON Terminal[/bold #00ff88]\n"
154
+ "[dim]AI coding agent — runs in your shell[/dim]\n\n"
155
+ "[dim]Commands: /new /sessions /exit /help[/dim]",
156
+ border_style="#00ff88",
157
+ ))
158
+
159
+ sid = _get_active_session()
160
+ sessions = _load_sessions()
161
+ session_name = sessions.get("history", {}).get(sid, {}).get("name", sid)
162
+ console.print(f"[dim]Session: {session_name}[/dim]\n")
163
+
164
+ HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
165
+ ps = PromptSession(
166
+ history=FileHistory(str(HISTORY_FILE)),
167
+ auto_suggest=AutoSuggestFromHistory(),
168
+ style=PROMPT_STYLE,
169
+ multiline=False,
170
+ )
171
+
172
+ last_exit = 0
173
+ last_cmd = ""
174
+
175
+ while True:
176
+ try:
177
+ user_input = ps.prompt("oadson> ").strip()
178
+ except (KeyboardInterrupt, EOFError):
179
+ console.print("\n[dim]Bye.[/dim]")
180
+ break
181
+
182
+ if not user_input:
183
+ continue
184
+
185
+ # ── REPL commands ──
186
+ if user_input.startswith("/"):
187
+ parts = user_input.split(None, 1)
188
+ cmd = parts[0].lower()
189
+ arg = parts[1] if len(parts) > 1 else ""
190
+
191
+ if cmd in ("/exit", "/quit", "/q"):
192
+ console.print("[dim]Bye.[/dim]")
193
+ break
194
+
195
+ elif cmd == "/new":
196
+ name = arg or ""
197
+ sid = _create_session(name)
198
+ console.print(f"[green]✓ New session started[/green]" + (f": {name}" if name else ""))
199
+
200
+ elif cmd in ("/sessions", "/history"):
201
+ _print_sessions()
202
+
203
+ elif cmd == "/delete":
204
+ _delete_session(arg)
205
+
206
+ elif cmd == "/help":
207
+ _print_help()
208
+
209
+ elif cmd == "/clear":
210
+ console.clear()
211
+
212
+ else:
213
+ console.print(f"[yellow]Unknown command: {cmd}[/yellow]")
214
+
215
+ continue
216
+
217
+ # ── Send to AI ──
218
+ reply = agent.run(
219
+ user_input,
220
+ session_id=sid,
221
+ last_exit_code=last_exit,
222
+ last_command=last_cmd,
223
+ stream=stream,
224
+ )
225
+
226
+ # Update session timestamp
227
+ _touch_session(sid)
228
+
229
+ last_cmd = user_input
230
+ last_exit = 0 # reset — we don't know actual exit code here
231
+
232
+ if not stream:
233
+ console.print(reply)
234
+ console.print()
235
+
236
+
237
+ def _touch_session(sid: str):
238
+ sessions = _load_sessions()
239
+ if sid in sessions.get("history", {}):
240
+ sessions["history"][sid]["updated"] = _now()
241
+ _save_sessions(sessions)
242
+
243
+
244
+ def _print_sessions():
245
+ sessions = _load_sessions()
246
+ history = sessions.get("history", {})
247
+ active = sessions.get("active", "")
248
+
249
+ if not history:
250
+ console.print("[dim]No sessions yet.[/dim]")
251
+ return
252
+
253
+ table = Table(title="OADSON Sessions", border_style="dim")
254
+ table.add_column("#", style="dim", width=3)
255
+ table.add_column("Name")
256
+ table.add_column("ID", style="dim")
257
+ table.add_column("Updated")
258
+ table.add_column("", width=2)
259
+
260
+ for i, (sid, info) in enumerate(
261
+ sorted(history.items(), key=lambda x: x[1].get("updated", ""), reverse=True), 1
262
+ ):
263
+ active_marker = "●" if sid == active else ""
264
+ table.add_row(
265
+ str(i),
266
+ info.get("name", sid),
267
+ sid,
268
+ info.get("updated", "—"),
269
+ f"[green]{active_marker}[/green]",
270
+ )
271
+
272
+ console.print(table)
273
+ console.print("[dim]/delete N to delete session #N[/dim]")
274
+
275
+
276
+ def _delete_session(arg: str):
277
+ sessions = _load_sessions()
278
+ history = sessions.get("history", {})
279
+ items = sorted(history.items(), key=lambda x: x[1].get("updated", ""), reverse=True)
280
+
281
+ try:
282
+ n = int(arg.strip())
283
+ sid, info = items[n - 1]
284
+ except (ValueError, IndexError):
285
+ console.print("[red]Usage: /delete N (e.g. /delete 1)[/red]")
286
+ return
287
+
288
+ from rich.prompt import Confirm
289
+ if not Confirm.ask(f"Delete session [bold]{info.get('name', sid)}[/bold]?"):
290
+ return
291
+
292
+ del history[sid]
293
+ if sessions.get("active") == sid:
294
+ # Switch to most recent remaining
295
+ if history:
296
+ sessions["active"] = sorted(
297
+ history.keys(),
298
+ key=lambda s: history[s].get("updated", ""), reverse=True
299
+ )[0]
300
+ else:
301
+ sessions.pop("active", None)
302
+
303
+ _save_sessions(sessions)
304
+ console.print(f"[green]✓ Deleted session: {info.get('name', sid)}[/green]")
305
+
306
+
307
+ def _print_help():
308
+ console.print(Panel(
309
+ "[bold]REPL Commands:[/bold]\n"
310
+ " /new [name] Start a fresh session\n"
311
+ " /sessions List all sessions\n"
312
+ " /delete N Delete session #N\n"
313
+ " /clear Clear screen\n"
314
+ " /help Show this\n"
315
+ " /exit Quit\n\n"
316
+ "[bold]Pipe mode:[/bold]\n"
317
+ " cat error.log | oadson\n"
318
+ " cat file.py | oadson 'refactor this'\n\n"
319
+ "[bold]One-shot:[/bold]\n"
320
+ ' oadson "fix the broken import"\n'
321
+ ' oadson "write tests for main.py"',
322
+ title="OADSON Help",
323
+ border_style="dim",
324
+ ))
325
+
326
+
327
+ # ── SETUP COMMAND ──
328
+
329
+ @app.command()
330
+ def setup():
331
+ """Configure OADSON with your Railway backend URL and token."""
332
+ console.print(Panel(
333
+ "[bold #00ff88]OADSON Setup[/bold #00ff88]",
334
+ border_style="#00ff88"
335
+ ))
336
+
337
+ cfg = get_config()
338
+
339
+ backend = Prompt.ask(
340
+ "Railway backend URL",
341
+ default=cfg.get("backend_url", "https://oadsonv2-production.up.railway.app")
342
+ ).strip().rstrip("/")
343
+
344
+ token = Prompt.ask(
345
+ "API token (from Railway env OADSON_SERVICE_TOKEN)",
346
+ password=True,
347
+ default=cfg.get("token", "")
348
+ ).strip()
349
+
350
+ cfg["backend_url"] = backend
351
+ cfg["token"] = token
352
+ save_config(cfg)
353
+
354
+ # Test connection
355
+ console.print("\n[dim]Testing connection...[/dim]")
356
+ try:
357
+ import httpx
358
+ r = httpx.get(
359
+ f"{backend}/health",
360
+ headers={"Authorization": f"Bearer {token}"},
361
+ timeout=10
362
+ )
363
+ if r.status_code in (200, 404):
364
+ console.print("[green]✓ Connected successfully[/green]")
365
+ else:
366
+ console.print(f"[yellow]⚠ HTTP {r.status_code} — check your URL and token[/yellow]")
367
+ except Exception as e:
368
+ console.print(f"[yellow]⚠ Could not connect: {e}[/yellow]")
369
+ console.print("[dim]Config saved anyway — retry when network is available[/dim]")
370
+
371
+ console.print(f"\n[bold]Config saved to:[/bold] ~/.oadson/config.json")
372
+ console.print("[bold green]✓ Ready. Run: oadson[/bold green]")
373
+
374
+
375
+ @app.command("config")
376
+ def show_config():
377
+ """Show current configuration."""
378
+ cfg = get_config()
379
+ if not cfg:
380
+ console.print("[yellow]Not configured. Run: oadson setup[/yellow]")
381
+ return
382
+
383
+ table = Table(border_style="dim")
384
+ table.add_column("Key")
385
+ table.add_column("Value")
386
+ table.add_row("backend_url", cfg.get("backend_url", "—"))
387
+ table.add_row("token", ("*" * 8 + cfg.get("token", "")[-4:]) if cfg.get("token") else "—")
388
+ console.print(table)
389
+
390
+
391
+ # ── SESSION SUBCOMMANDS ──
392
+
393
+ @session_app.command("new")
394
+ def session_new(name: str = typer.Argument("", help="Optional session name")):
395
+ """Start a new session."""
396
+ sid = _create_session(name)
397
+ console.print(f"[green]✓ New session:[/green] {name or sid}")
398
+
399
+
400
+ @session_app.command("list")
401
+ def session_list():
402
+ """List all sessions."""
403
+ _print_sessions()
404
+
405
+
406
+ @session_app.command("delete")
407
+ def session_delete(n: int = typer.Argument(..., help="Session number from list")):
408
+ """Delete a session by number."""
409
+ _delete_session(str(n))
410
+
411
+
412
+ @session_app.command("use")
413
+ def session_use(n: int = typer.Argument(..., help="Session number to switch to")):
414
+ """Switch to a session by number."""
415
+ sessions = _load_sessions()
416
+ history = sessions.get("history", {})
417
+ items = sorted(history.items(), key=lambda x: x[1].get("updated", ""), reverse=True)
418
+
419
+ try:
420
+ sid, info = items[n - 1]
421
+ except IndexError:
422
+ console.print(f"[red]No session #{n}[/red]")
423
+ return
424
+
425
+ sessions["active"] = sid
426
+ _save_sessions(sessions)
427
+ console.print(f"[green]✓ Switched to:[/green] {info.get('name', sid)}")
428
+
429
+
430
+ if __name__ == "__main__":
431
+ app()