devobin 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.
Files changed (47) hide show
  1. devobin/__init__.py +4 -0
  2. devobin/app.py +42 -0
  3. devobin/cli/__init__.py +1 -0
  4. devobin/cli/chat.py +831 -0
  5. devobin/cli/commands.py +362 -0
  6. devobin/cli/slash_menu.py +74 -0
  7. devobin/cli/terminal.py +155 -0
  8. devobin/cli/ui.py +129 -0
  9. devobin/config/__init__.py +1 -0
  10. devobin/config/settings.py +124 -0
  11. devobin/core/__init__.py +1 -0
  12. devobin/core/analyzer.py +166 -0
  13. devobin/core/executor.py +244 -0
  14. devobin/core/memory.py +78 -0
  15. devobin/core/optimizer.py +106 -0
  16. devobin/core/prompt_builder.py +513 -0
  17. devobin/core/researcher.py +283 -0
  18. devobin/core/rtl.py +93 -0
  19. devobin/core/scanner.py +206 -0
  20. devobin/core/tools.py +283 -0
  21. devobin/core/validator.py +235 -0
  22. devobin/main.py +33 -0
  23. devobin/output/__init__.py +1 -0
  24. devobin/providers/__init__.py +85 -0
  25. devobin/providers/anthropic_provider.py +72 -0
  26. devobin/providers/google_provider.py +90 -0
  27. devobin/providers/ollama_provider.py +81 -0
  28. devobin/providers/openai_provider.py +102 -0
  29. devobin/storage/__init__.py +1 -0
  30. devobin/storage/database.py +207 -0
  31. devobin/ui/__init__.py +1 -0
  32. devobin/ui/ask_modal.py +96 -0
  33. devobin/ui/chat_screen.py +1029 -0
  34. devobin/ui/command_palette.py +131 -0
  35. devobin/ui/connect_modal.py +166 -0
  36. devobin/ui/export_modal.py +159 -0
  37. devobin/ui/history_modal.py +137 -0
  38. devobin/ui/memory_modal.py +88 -0
  39. devobin/ui/model_modal.py +143 -0
  40. devobin/ui/permission_modal.py +92 -0
  41. devobin/ui/project_modal.py +133 -0
  42. devobin/ui/settings_modal.py +89 -0
  43. devobin-1.0.0.dist-info/METADATA +364 -0
  44. devobin-1.0.0.dist-info/RECORD +47 -0
  45. devobin-1.0.0.dist-info/WHEEL +4 -0
  46. devobin-1.0.0.dist-info/entry_points.txt +2 -0
  47. devobin-1.0.0.dist-info/licenses/LICENSE +21 -0
devobin/core/tools.py ADDED
@@ -0,0 +1,283 @@
1
+ """Agent tools — autonomous file discovery, search, and reading over a workspace.
2
+
3
+ These are the primitives an agent (or DevObin's own scanner) uses to explore a
4
+ project the way Claude Code / MimoCode do: find files by pattern, search their
5
+ contents, read them without artificial line caps, and list directories. Every
6
+ operation is READ-ONLY — DevObin never edits the user's project.
7
+
8
+ The model outputs tool calls as [TOOL:name:argument] tags in its response.
9
+ The chat loop detects them, executes the tools, feeds results back, and the
10
+ model continues until it produces a final response without tool calls.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import fnmatch
16
+ import re
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+
20
+ from devobin.core.scanner import BINARY_EXTS, IGNORE_DIRS
21
+
22
+ # ── Tool call tag format ──────────────────────────────────────────
23
+ # [TOOL:glob:**/*.py]
24
+ # [TOOL:grep:class\s+\w+Provider]
25
+ # [TOOL:read:devobin/main.py]
26
+ # [TOOL:ls:devobin/core/]
27
+
28
+ TOOL_TAG_RE = re.compile(r"\[TOOL:(\w+):([^\]]+)\]")
29
+
30
+
31
+ @dataclass
32
+ class ToolCall:
33
+ """A parsed tool call from the model's output."""
34
+
35
+ tool: str
36
+ arg: str
37
+
38
+
39
+ @dataclass
40
+ class ToolResult:
41
+ """Uniform result for every tool call."""
42
+
43
+ tool: str
44
+ ok: bool = True
45
+ summary: str = ""
46
+ matches: list[str] = field(default_factory=list)
47
+ content: str = ""
48
+ error: str = ""
49
+
50
+ def render(self) -> str:
51
+ if not self.ok:
52
+ return f"[{self.tool}] error: {self.error}"
53
+ if self.content:
54
+ return self.content
55
+ if self.matches:
56
+ head = f"[{self.tool}] {len(self.matches)} match(es):\n"
57
+ return head + "\n".join(self.matches)
58
+ return self.summary or f"[{self.tool}] no results"
59
+
60
+
61
+ def parse_tool_calls(text: str) -> list[ToolCall]:
62
+ """Extract all [TOOL:name:arg] tags from the model's output."""
63
+ return [ToolCall(tool=m.group(1), arg=m.group(2)) for m in TOOL_TAG_RE.finditer(text)]
64
+
65
+
66
+ def strip_tool_calls(text: str) -> str:
67
+ """Remove all [TOOL:...] tags from the output, leaving clean text."""
68
+ return TOOL_TAG_RE.sub("", text).strip()
69
+
70
+
71
+ def execute_tool_calls(calls: list[ToolCall]) -> str:
72
+ """Execute a list of tool calls and return formatted results."""
73
+ if not calls:
74
+ return ""
75
+ parts: list[str] = []
76
+ for call in calls:
77
+ fn = TOOLS.get(call.tool)
78
+ if fn is None:
79
+ parts.append(f"[{call.tool}] unknown tool. Available: {', '.join(TOOLS)}")
80
+ continue
81
+ try:
82
+ result: ToolResult = fn(call.arg)
83
+ parts.append(result.render())
84
+ except Exception as e:
85
+ parts.append(f"[{call.tool}] error: {e}")
86
+ return "\n\n---\n\n".join(parts)
87
+
88
+
89
+ def _iter_files(root: Path):
90
+ """Yield text-ish files under root, skipping ignored dirs and binaries."""
91
+ for path in root.rglob("*"):
92
+ if not path.is_file():
93
+ continue
94
+ rel_parts = path.relative_to(root).parts
95
+ if any(p in IGNORE_DIRS for p in rel_parts):
96
+ continue
97
+ if path.suffix.lower() in BINARY_EXTS:
98
+ continue
99
+ yield path
100
+
101
+
102
+ def glob_files(pattern: str, root: str | Path | None = None, limit: int = 500) -> ToolResult:
103
+ """Find files whose relative path matches a glob pattern (e.g. ``**/*.py``)."""
104
+ root_path = Path(root).resolve() if root else Path.cwd()
105
+ matches: list[str] = []
106
+ for path in _iter_files(root_path):
107
+ rel = path.relative_to(root_path).as_posix()
108
+ if fnmatch.fnmatch(rel, pattern) or fnmatch.fnmatch(path.name, pattern):
109
+ matches.append(rel)
110
+ if len(matches) >= limit:
111
+ break
112
+ return ToolResult(tool="glob", matches=sorted(matches),
113
+ summary=f"{len(matches)} file(s) matching '{pattern}'")
114
+
115
+
116
+ def grep_files(
117
+ pattern: str,
118
+ root: str | Path | None = None,
119
+ glob: str = "*",
120
+ limit: int = 300,
121
+ ignore_case: bool = True,
122
+ ) -> ToolResult:
123
+ """Search file contents with a regex, returning ``path:line: text`` hits."""
124
+ root_path = Path(root).resolve() if root else Path.cwd()
125
+ try:
126
+ rx = re.compile(pattern, re.IGNORECASE if ignore_case else 0)
127
+ except re.error as e:
128
+ return ToolResult(tool="grep", ok=False, error=f"bad regex: {e}")
129
+
130
+ hits: list[str] = []
131
+ for path in _iter_files(root_path):
132
+ rel = path.relative_to(root_path).as_posix()
133
+ if not (fnmatch.fnmatch(rel, glob) or fnmatch.fnmatch(path.name, glob)):
134
+ continue
135
+ try:
136
+ for i, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
137
+ if rx.search(line):
138
+ hits.append(f"{rel}:{i}: {line.strip()[:200]}")
139
+ if len(hits) >= limit:
140
+ return ToolResult(tool="grep", matches=hits,
141
+ summary=f"{len(hits)}+ hits (truncated)")
142
+ except Exception:
143
+ continue
144
+ return ToolResult(tool="grep", matches=hits, summary=f"{len(hits)} hit(s) for '{pattern}'")
145
+
146
+
147
+ def read_file(path: str, root: str | Path | None = None, max_bytes: int = 2_000_000) -> ToolResult:
148
+ """Read a file with a generous cap (no 500-line truncation)."""
149
+ root_path = Path(root).resolve() if root else Path.cwd()
150
+ target = (root_path / path).resolve() if not Path(path).is_absolute() else Path(path).resolve()
151
+
152
+ if not target.exists() or not target.is_file():
153
+ return ToolResult(tool="read", ok=False, error=f"not a file: {path}")
154
+ if target.suffix.lower() in BINARY_EXTS:
155
+ return ToolResult(tool="read", ok=False, error=f"binary file skipped: {path}")
156
+
157
+ try:
158
+ data = target.read_bytes()[:max_bytes]
159
+ text = data.decode("utf-8", errors="replace")
160
+ except Exception as e:
161
+ return ToolResult(tool="read", ok=False, error=str(e))
162
+
163
+ lines = text.count("\n") + 1
164
+ return ToolResult(tool="read", content=text,
165
+ summary=f"{path} ({lines} lines, {len(data)} bytes)")
166
+
167
+
168
+ def list_dir(path: str = ".", root: str | Path | None = None) -> ToolResult:
169
+ """List a directory's entries (dirs marked with a trailing slash)."""
170
+ root_path = Path(root).resolve() if root else Path.cwd()
171
+ target = (root_path / path).resolve()
172
+ if not target.exists() or not target.is_dir():
173
+ return ToolResult(tool="ls", ok=False, error=f"not a directory: {path}")
174
+ try:
175
+ entries = sorted(
176
+ (e.name + "/" if e.is_dir() else e.name)
177
+ for e in target.iterdir()
178
+ if e.name not in IGNORE_DIRS
179
+ )
180
+ except Exception as e:
181
+ return ToolResult(tool="ls", ok=False, error=str(e))
182
+ return ToolResult(tool="ls", matches=entries, summary=f"{len(entries)} entr(ies) in {path}")
183
+
184
+
185
+ def summarize_results(calls: list[ToolCall], raw_results: str) -> str:
186
+ """Create a user-friendly summary of tool results (no full code shown).
187
+
188
+ The full content is available to the model via the conversation history,
189
+ but the user only sees file names, line counts, and key identifiers.
190
+ """
191
+ parts: list[str] = []
192
+ for call in calls:
193
+ if call.tool == "read":
194
+ # Extract just the filename and stats from the result
195
+ lines = raw_results.split("\n")
196
+ # Find the relevant section for this tool call
197
+ fname = call.arg
198
+ line_count = 0
199
+ classes = []
200
+ functions = []
201
+ for line in lines:
202
+ stripped = line.strip()
203
+ if stripped.startswith("class ") and "(" in stripped:
204
+ name = stripped.split("(")[0].replace("class ", "").strip()
205
+ classes.append(name)
206
+ elif stripped.startswith("def ") and "(" in stripped:
207
+ name = stripped.split("(")[0].replace("def ", "").strip()
208
+ if not name.startswith("_"):
209
+ functions.append(name)
210
+ elif stripped and not stripped.startswith("["):
211
+ line_count += 1
212
+ summary = f" {fname} — {line_count} lines"
213
+ if classes:
214
+ summary += f" | classes: {', '.join(classes[:8])}"
215
+ if functions:
216
+ summary += f" | functions: {', '.join(functions[:8])}"
217
+ parts.append(summary)
218
+ elif call.tool == "glob":
219
+ # Show file count and first few matches
220
+ match_lines = [l for l in raw_results.split("\n") if l.strip() and not l.startswith("[")]
221
+ parts.append(f" Found {len(match_lines)} files matching '{call.arg}'")
222
+ elif call.tool == "grep":
223
+ match_lines = [l for l in raw_results.split("\n") if l.strip() and ":" in l and not l.startswith("[")]
224
+ parts.append(f" {len(match_lines)} matches for '{call.arg}'")
225
+ elif call.tool == "ls":
226
+ parts.append(f" Directory '{call.arg}':")
227
+ for line in raw_results.split("\n"):
228
+ if line.strip() and not line.startswith("["):
229
+ parts.append(f" {line.strip()}")
230
+ return "\n".join(parts) if parts else raw_results[:500]
231
+
232
+
233
+ def write_file(path: str, content: str, root: str | Path | None = None) -> ToolResult:
234
+ """Write a .md prompt file to the working directory.
235
+
236
+ RESTRICTIONS:
237
+ - Only .md files are allowed (this is a prompt tool, not a code editor)
238
+ - Path must be relative (no absolute paths)
239
+ - No directory traversal (no ..)
240
+ - Only writes to the current working directory
241
+ """
242
+ root_path = Path(root).resolve() if root else Path.cwd()
243
+
244
+ # Security: only .md files
245
+ if not path.endswith(".md"):
246
+ return ToolResult(tool="write", ok=False,
247
+ error="Only .md files are allowed. This is a prompt tool.")
248
+
249
+ # Security: no absolute paths
250
+ if Path(path).is_absolute():
251
+ return ToolResult(tool="write", ok=False,
252
+ error="Only relative paths allowed. Use a filename like 'my_prompt.md'.")
253
+
254
+ # Security: no directory traversal
255
+ if ".." in path:
256
+ return ToolResult(tool="write", ok=False,
257
+ error="Directory traversal not allowed.")
258
+
259
+ target = (root_path / path).resolve()
260
+
261
+ # Security: must be within the working directory
262
+ if not str(target).startswith(str(root_path)):
263
+ return ToolResult(tool="write", ok=False,
264
+ error="Cannot write outside the working directory.")
265
+
266
+ try:
267
+ target.parent.mkdir(parents=True, exist_ok=True)
268
+ target.write_text(content, encoding="utf-8")
269
+ lines = content.count("\n") + 1
270
+ return ToolResult(tool="write", ok=True,
271
+ summary=f"Created: {path} ({lines} lines, {len(content)} bytes)")
272
+ except Exception as e:
273
+ return ToolResult(tool="write", ok=False, error=str(e))
274
+
275
+
276
+ # Registry so a model/agent loop can dispatch by name.
277
+ TOOLS = {
278
+ "glob": glob_files,
279
+ "grep": grep_files,
280
+ "read": read_file,
281
+ "ls": list_dir,
282
+ "write": write_file,
283
+ }
@@ -0,0 +1,235 @@
1
+ """Output validator — checks code length AND grounding in the real project.
2
+
3
+ DevObin is a Prompt Architect: it may show SHORT snippets to sharpen a prompt,
4
+ but must never deliver complete files or working implementations. It must also
5
+ never hallucinate technologies that don't exist in the scanned workspace.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+
13
+
14
+ @dataclass
15
+ class ValidationResult:
16
+ """Result of validating AI output."""
17
+
18
+ is_clean: bool
19
+ code_score: int
20
+ detected_patterns: list[str]
21
+ reason: str = ""
22
+
23
+ @property
24
+ def summary(self) -> str:
25
+ if self.is_clean:
26
+ return "OK"
27
+ return f"Full code detected (score={self.code_score}): {', '.join(self.detected_patterns[:5])}"
28
+
29
+
30
+ @dataclass
31
+ class GroundingResult:
32
+ """Result of checking if the prompt is grounded in the real project."""
33
+
34
+ is_grounded: bool
35
+ hallucinations: list[str] = field(default_factory=list)
36
+ warnings: list[str] = field(default_factory=list)
37
+
38
+ @property
39
+ def summary(self) -> str:
40
+ if self.is_grounded:
41
+ return "OK"
42
+ return f"Hallucinated: {', '.join(self.hallucinations[:5])}"
43
+
44
+
45
+ # ── Code length policy ────────────────────────────────────────────
46
+ MAX_SNIPPET_LINES = 12
47
+ MAX_SNIPPET_BLOCKS = 4
48
+ MAX_STRUCTURAL_DEFS = 3
49
+ THRESHOLD = 10
50
+
51
+ # ── Full-document markers (never in an illustrative snippet) ──────
52
+ FULL_DOC_PATTERNS: list[tuple[str, str]] = [
53
+ (r"<!DOCTYPE\s+html", "HTML document"),
54
+ (r"<html[\s>]", "full <html> tree"),
55
+ (r"<head\s*>", "<head> section"),
56
+ (r"<body[\s>]", "<body> section"),
57
+ ]
58
+
59
+ STRUCTURAL_PATTERNS: list[tuple[str, str]] = [
60
+ (r"^\s*def\s+\w+\s*\(", "python def"),
61
+ (r"^\s*class\s+\w+\s*[\(:]", "python class"),
62
+ (r"^\s*(export\s+)?(async\s+)?function\s+\w+\s*\(", "js function"),
63
+ ]
64
+
65
+ _FENCE_RE = re.compile(r"```[^\n]*\n(.*?)```", re.DOTALL)
66
+
67
+ # ── Technologies that models commonly hallucinate ──────────────────
68
+ # These are only "hallucinated" if the workspace has NO evidence of them.
69
+ TECH_KEYWORDS: dict[str, list[str]] = {
70
+ "PostgreSQL": ["postgresql", "postgres", "psql", "asyncpg", "sqlalchemy", r"models\.py", r"migrations/"],
71
+ "MySQL": ["mysql", "mariadb", "pymysql"],
72
+ "Docker": ["dockerfile", "docker-compose", "docker"],
73
+ "React": ["react", "jsx", "tsx", r"next\.js", "nextjs"],
74
+ "Vue": ["vue", "vuejs", "nuxt"],
75
+ "Angular": ["angular"],
76
+ "Bootstrap": ["bootstrap"],
77
+ "Tailwind": ["tailwind"],
78
+ "Redis": ["redis"],
79
+ "Kubernetes": ["kubernetes", "k8s"],
80
+ "GraphQL": ["graphql"],
81
+ "Celery": ["celery"],
82
+ "RabbitMQ": ["rabbitmq"],
83
+ "Kafka": ["kafka"],
84
+ }
85
+
86
+
87
+ def _code_blocks(text: str) -> list[str]:
88
+ return _FENCE_RE.findall(text)
89
+
90
+
91
+ def _code_line_count(block: str) -> int:
92
+ return sum(1 for line in block.splitlines() if line.strip())
93
+
94
+
95
+ def validate_output(text: str) -> ValidationResult:
96
+ """Validate AI output for code length (short snippets OK, full files blocked)."""
97
+ if not text or not text.strip():
98
+ return ValidationResult(is_clean=True, code_score=0, detected_patterns=[])
99
+
100
+ violations: list[str] = []
101
+ score = 0
102
+
103
+ for pattern, label in FULL_DOC_PATTERNS:
104
+ if re.search(pattern, text, re.MULTILINE | re.IGNORECASE):
105
+ violations.append(label)
106
+ score += 15
107
+
108
+ blocks = _code_blocks(text)
109
+ for block in blocks:
110
+ n = _code_line_count(block)
111
+ if n > MAX_SNIPPET_LINES:
112
+ violations.append(f"{n}-line code block (max {MAX_SNIPPET_LINES})")
113
+ score += 15
114
+
115
+ if len(blocks) > MAX_SNIPPET_BLOCKS:
116
+ violations.append(f"{len(blocks)} code blocks (max {MAX_SNIPPET_BLOCKS})")
117
+ score += 10
118
+
119
+ struct_hits = 0
120
+ for pattern, _label in STRUCTURAL_PATTERNS:
121
+ struct_hits += len(re.findall(pattern, text, re.MULTILINE))
122
+ if struct_hits > MAX_STRUCTURAL_DEFS:
123
+ violations.append(f"{struct_hits} code definitions (max {MAX_STRUCTURAL_DEFS})")
124
+ score += 10
125
+
126
+ is_clean = score < THRESHOLD
127
+ reason = "" if is_clean else f"Full code detected (score={score})"
128
+
129
+ return ValidationResult(
130
+ is_clean=is_clean,
131
+ code_score=score,
132
+ detected_patterns=violations,
133
+ reason=reason,
134
+ )
135
+
136
+
137
+ def validate_grounding(text: str, project_map=None) -> GroundingResult:
138
+ """Check if the generated prompt references technologies actually in the project.
139
+
140
+ This catches the common failure mode where the model hallucinates PostgreSQL,
141
+ Docker, React, etc. despite the project having none of those.
142
+ """
143
+ if not text or not text.strip():
144
+ return GroundingResult(is_grounded=True)
145
+
146
+ text_lower = text.lower()
147
+ hallucinations: list[str] = []
148
+ warnings: list[str] = []
149
+
150
+ # Build a set of evidence strings from the workspace scan
151
+ # Only count source code and config files, NOT documentation (README etc.)
152
+ evidence = set()
153
+ if project_map is not None:
154
+ # Add file extensions and names (but skip docs)
155
+ for f in getattr(project_map, "files", []):
156
+ fname_lower = f.name.lower()
157
+ if fname_lower.startswith("readme") or fname_lower.endswith(".md"):
158
+ continue # Skip documentation files
159
+ evidence.add(f.ext.lower())
160
+ evidence.add(fname_lower)
161
+ evidence.add(f.path.lower())
162
+ # Add directory names
163
+ for d in getattr(project_map, "dirs", []):
164
+ evidence.add(d.lower())
165
+ # Add manifest content — only config files, not docs
166
+ for rel, content in getattr(project_map, "manifests", {}).items():
167
+ rel_lower = rel.lower()
168
+ if "readme" in rel_lower or rel_lower.endswith(".md"):
169
+ continue # Skip documentation
170
+ evidence.add(content.lower())
171
+ # Add languages
172
+ for lang in getattr(project_map, "languages", {}).keys():
173
+ evidence.add(lang.lower())
174
+
175
+ evidence_text = " ".join(evidence)
176
+
177
+ for tech, keywords in TECH_KEYWORDS.items():
178
+ # Check if the prompt mentions this technology
179
+ mentioned = any(re.search(kw, text_lower) for kw in keywords)
180
+ if not mentioned:
181
+ continue
182
+
183
+ # Check if there's evidence of this technology in the workspace
184
+ has_evidence = any(re.search(kw, evidence_text) for kw in keywords)
185
+
186
+ if not has_evidence:
187
+ hallucinations.append(tech)
188
+ warnings.append(
189
+ f"'{tech}' is mentioned in the prompt but NOT found in the "
190
+ f"scanned workspace. The model may have hallucinated it."
191
+ )
192
+
193
+ is_grounded = len(hallucinations) == 0
194
+ return GroundingResult(
195
+ is_grounded=is_grounded,
196
+ hallucinations=hallucinations,
197
+ warnings=warnings,
198
+ )
199
+
200
+
201
+ def build_rewrite_prompt(original_text: str) -> str:
202
+ """Build a prompt to rewrite a full implementation back into instructions."""
203
+ return """Your previous response included a full implementation, which breaks DevObin's rules.
204
+
205
+ You are a PROMPT ARCHITECT, not a CODER:
206
+ - You create instructions FOR other AI coding agents.
207
+ - You do NOT deliver complete files or working applications yourself.
208
+
209
+ Rewrite your previous response as an engineering prompt with:
210
+ 1. Technical specification
211
+ 2. Architecture requirements
212
+ 3. Implementation instructions
213
+ 4. Optimization rules
214
+ 5. Security rules
215
+
216
+ You MAY keep at most a few SHORT illustrative snippets (max 12 lines each) purely to
217
+ clarify an instruction — a function signature, a config key, a data schema.
218
+ You may NOT include:
219
+ - Complete files or full applications
220
+ - Long or copy-paste-ready implementations
221
+ - More than a few tiny snippets
222
+
223
+ The result must be a prompt another AI coding agent can implement from."""
224
+
225
+
226
+ def get_mode_message() -> str:
227
+ """Get the mode indicator for UI display."""
228
+ return (
229
+ "DevObin AI\n"
230
+ "Prompt Architect Mode\n"
231
+ "\n"
232
+ "Full code: Disabled\n"
233
+ "Short illustrative snippets: OK\n"
234
+ "Engineering context only"
235
+ )
devobin/main.py ADDED
@@ -0,0 +1,33 @@
1
+ """DevObin CLI application entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from devobin import __version__, __app_name__
9
+
10
+ console = Console()
11
+
12
+ app = typer.Typer(
13
+ name="devobin",
14
+ help=f"{__app_name__} — AI Engineering Context Compiler & Prompt Architect",
15
+ add_completion=False,
16
+ no_args_is_help=False,
17
+ invoke_without_command=True,
18
+ )
19
+
20
+
21
+ @app.callback(invoke_without_command=True)
22
+ def main(
23
+ ctx: typer.Context,
24
+ version: bool = typer.Option(False, "--version", "-v", help="Show version"),
25
+ ) -> None:
26
+ """DevObin AI — Interactive Engineering Context Compiler."""
27
+ if version:
28
+ console.print(f"{__app_name__} v{__version__}")
29
+ raise typer.Exit()
30
+
31
+ if ctx.invoked_subcommand is None:
32
+ from devobin.app import run_app
33
+ run_app()
@@ -0,0 +1 @@
1
+ """DevObin output package."""
@@ -0,0 +1,85 @@
1
+ """AI provider abstraction layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import AsyncIterator
7
+
8
+ from devobin.config.settings import get_config
9
+
10
+
11
+ class AIProvider(ABC):
12
+ """Base class for all AI providers."""
13
+
14
+ name: str = "base"
15
+
16
+ def __init__(self, config: dict) -> None:
17
+ self.config = config
18
+ self.api_key = config.get("api_key", "")
19
+ self.model = config.get("model", "")
20
+ self.base_url = config.get("base_url", "")
21
+
22
+ @abstractmethod
23
+ async def chat(
24
+ self,
25
+ messages: list[dict[str, str]],
26
+ system: str | None = None,
27
+ temperature: float = 0.7,
28
+ max_tokens: int = 4096,
29
+ ) -> str:
30
+ """Send a chat completion request and return the response text."""
31
+ ...
32
+
33
+ @abstractmethod
34
+ async def chat_stream(
35
+ self,
36
+ messages: list[dict[str, str]],
37
+ system: str | None = None,
38
+ temperature: float = 0.7,
39
+ max_tokens: int = 4096,
40
+ ) -> AsyncIterator[str]:
41
+ """Stream chat completion tokens."""
42
+ ...
43
+ yield # type: ignore[misc]
44
+
45
+ @abstractmethod
46
+ async def validate_connection(self) -> bool:
47
+ """Validate that the provider is properly configured and reachable."""
48
+ ...
49
+
50
+ @abstractmethod
51
+ def available_models(self) -> list[str]:
52
+ """Return list of available model IDs for this provider."""
53
+ ...
54
+
55
+
56
+ def get_provider(name: str | None = None) -> AIProvider:
57
+ """Get an AI provider instance by name (or the active one)."""
58
+ from devobin.providers.openai_provider import OpenAIProvider
59
+ from devobin.providers.anthropic_provider import AnthropicProvider
60
+ from devobin.providers.google_provider import GoogleProvider
61
+ from devobin.providers.ollama_provider import OllamaProvider
62
+
63
+ config = get_config()
64
+ provider_name = name or config.active_provider
65
+ if not provider_name:
66
+ raise ValueError("No AI provider configured. Use /connect to set one up.")
67
+
68
+ provider_cfg = config.get_provider(provider_name)
69
+ if not provider_cfg:
70
+ raise ValueError(f"Provider '{provider_name}' not found. Use /connect to configure.")
71
+
72
+ registry: dict[str, type[AIProvider]] = {
73
+ "openai": OpenAIProvider,
74
+ "anthropic": AnthropicProvider,
75
+ "google": GoogleProvider,
76
+ "ollama": OllamaProvider,
77
+ "openai-compatible": OpenAIProvider,
78
+ }
79
+
80
+ provider_type = provider_cfg.get("type", provider_name)
81
+ cls = registry.get(provider_type)
82
+ if cls is None:
83
+ raise ValueError(f"Unknown provider type: {provider_type}")
84
+
85
+ return cls(provider_cfg)