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.
- devobin/__init__.py +4 -0
- devobin/app.py +42 -0
- devobin/cli/__init__.py +1 -0
- devobin/cli/chat.py +831 -0
- devobin/cli/commands.py +362 -0
- devobin/cli/slash_menu.py +74 -0
- devobin/cli/terminal.py +155 -0
- devobin/cli/ui.py +129 -0
- devobin/config/__init__.py +1 -0
- devobin/config/settings.py +124 -0
- devobin/core/__init__.py +1 -0
- devobin/core/analyzer.py +166 -0
- devobin/core/executor.py +244 -0
- devobin/core/memory.py +78 -0
- devobin/core/optimizer.py +106 -0
- devobin/core/prompt_builder.py +513 -0
- devobin/core/researcher.py +283 -0
- devobin/core/rtl.py +93 -0
- devobin/core/scanner.py +206 -0
- devobin/core/tools.py +283 -0
- devobin/core/validator.py +235 -0
- devobin/main.py +33 -0
- devobin/output/__init__.py +1 -0
- devobin/providers/__init__.py +85 -0
- devobin/providers/anthropic_provider.py +72 -0
- devobin/providers/google_provider.py +90 -0
- devobin/providers/ollama_provider.py +81 -0
- devobin/providers/openai_provider.py +102 -0
- devobin/storage/__init__.py +1 -0
- devobin/storage/database.py +207 -0
- devobin/ui/__init__.py +1 -0
- devobin/ui/ask_modal.py +96 -0
- devobin/ui/chat_screen.py +1029 -0
- devobin/ui/command_palette.py +131 -0
- devobin/ui/connect_modal.py +166 -0
- devobin/ui/export_modal.py +159 -0
- devobin/ui/history_modal.py +137 -0
- devobin/ui/memory_modal.py +88 -0
- devobin/ui/model_modal.py +143 -0
- devobin/ui/permission_modal.py +92 -0
- devobin/ui/project_modal.py +133 -0
- devobin/ui/settings_modal.py +89 -0
- devobin-1.0.0.dist-info/METADATA +364 -0
- devobin-1.0.0.dist-info/RECORD +47 -0
- devobin-1.0.0.dist-info/WHEEL +4 -0
- devobin-1.0.0.dist-info/entry_points.txt +2 -0
- 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)
|