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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Anthropic Claude provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import AsyncIterator
|
|
6
|
+
|
|
7
|
+
from anthropic import AsyncAnthropic
|
|
8
|
+
|
|
9
|
+
from devobin.providers import AIProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AnthropicProvider(AIProvider):
|
|
13
|
+
name = "anthropic"
|
|
14
|
+
|
|
15
|
+
def __init__(self, config: dict) -> None:
|
|
16
|
+
super().__init__(config)
|
|
17
|
+
self._client = AsyncAnthropic(api_key=self.api_key)
|
|
18
|
+
|
|
19
|
+
async def chat(
|
|
20
|
+
self,
|
|
21
|
+
messages: list[dict[str, str]],
|
|
22
|
+
system: str | None = None,
|
|
23
|
+
temperature: float = 0.7,
|
|
24
|
+
max_tokens: int = 4096,
|
|
25
|
+
) -> str:
|
|
26
|
+
kwargs: dict = {
|
|
27
|
+
"model": self.model,
|
|
28
|
+
"messages": messages,
|
|
29
|
+
"temperature": temperature,
|
|
30
|
+
"max_tokens": max_tokens,
|
|
31
|
+
}
|
|
32
|
+
if system:
|
|
33
|
+
kwargs["system"] = system
|
|
34
|
+
|
|
35
|
+
response = await self._client.messages.create(**kwargs)
|
|
36
|
+
return response.content[0].text if response.content else ""
|
|
37
|
+
|
|
38
|
+
async def chat_stream(
|
|
39
|
+
self,
|
|
40
|
+
messages: list[dict[str, str]],
|
|
41
|
+
system: str | None = None,
|
|
42
|
+
temperature: float = 0.7,
|
|
43
|
+
max_tokens: int = 4096,
|
|
44
|
+
) -> AsyncIterator[str]:
|
|
45
|
+
kwargs: dict = {
|
|
46
|
+
"model": self.model,
|
|
47
|
+
"messages": messages,
|
|
48
|
+
"temperature": temperature,
|
|
49
|
+
"max_tokens": max_tokens,
|
|
50
|
+
}
|
|
51
|
+
if system:
|
|
52
|
+
kwargs["system"] = system
|
|
53
|
+
|
|
54
|
+
async with self._client.messages.stream(**kwargs) as stream:
|
|
55
|
+
async for text in stream.text_stream:
|
|
56
|
+
yield text
|
|
57
|
+
|
|
58
|
+
async def validate_connection(self) -> bool:
|
|
59
|
+
try:
|
|
60
|
+
await self.chat([{"role": "user", "content": "hi"}], max_tokens=5)
|
|
61
|
+
return True
|
|
62
|
+
except Exception:
|
|
63
|
+
return self.api_key != ""
|
|
64
|
+
|
|
65
|
+
def available_models(self) -> list[str]:
|
|
66
|
+
return [
|
|
67
|
+
"claude-opus-4-20250514",
|
|
68
|
+
"claude-sonnet-4-20250514",
|
|
69
|
+
"claude-3-5-haiku-20241022",
|
|
70
|
+
"claude-3-5-sonnet-20241022",
|
|
71
|
+
"claude-3-opus-20240229",
|
|
72
|
+
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Google Gemini provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import AsyncIterator
|
|
6
|
+
|
|
7
|
+
from google import genai
|
|
8
|
+
from google.genai import types
|
|
9
|
+
|
|
10
|
+
from devobin.providers import AIProvider
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GoogleProvider(AIProvider):
|
|
14
|
+
name = "google"
|
|
15
|
+
|
|
16
|
+
def __init__(self, config: dict) -> None:
|
|
17
|
+
super().__init__(config)
|
|
18
|
+
self._client = genai.Client(api_key=self.api_key)
|
|
19
|
+
|
|
20
|
+
async def chat(
|
|
21
|
+
self,
|
|
22
|
+
messages: list[dict[str, str]],
|
|
23
|
+
system: str | None = None,
|
|
24
|
+
temperature: float = 0.7,
|
|
25
|
+
max_tokens: int = 4096,
|
|
26
|
+
) -> str:
|
|
27
|
+
contents = []
|
|
28
|
+
if system:
|
|
29
|
+
contents.append(types.Content(role="user", parts=[types.Part(text=system)]))
|
|
30
|
+
contents.append(types.Content(role="model", parts=[types.Part(text="Understood.")]))
|
|
31
|
+
|
|
32
|
+
for m in messages:
|
|
33
|
+
role = "user" if m["role"] == "user" else "model"
|
|
34
|
+
contents.append(types.Content(role=role, parts=[types.Part(text=m["content"])]))
|
|
35
|
+
|
|
36
|
+
response = await self._client.aio.models.generate_content(
|
|
37
|
+
model=self.model,
|
|
38
|
+
contents=contents,
|
|
39
|
+
config=types.GenerateContentConfig(
|
|
40
|
+
temperature=temperature,
|
|
41
|
+
max_output_tokens=max_tokens,
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
return response.text or ""
|
|
45
|
+
|
|
46
|
+
async def chat_stream(
|
|
47
|
+
self,
|
|
48
|
+
messages: list[dict[str, str]],
|
|
49
|
+
system: str | None = None,
|
|
50
|
+
temperature: float = 0.7,
|
|
51
|
+
max_tokens: int = 4096,
|
|
52
|
+
) -> AsyncIterator[str]:
|
|
53
|
+
contents = []
|
|
54
|
+
if system:
|
|
55
|
+
contents.append(types.Content(role="user", parts=[types.Part(text=system)]))
|
|
56
|
+
contents.append(types.Content(role="model", parts=[types.Part(text="Understood.")]))
|
|
57
|
+
|
|
58
|
+
for m in messages:
|
|
59
|
+
role = "user" if m["role"] == "user" else "model"
|
|
60
|
+
contents.append(types.Content(role=role, parts=[types.Part(text=m["content"])]))
|
|
61
|
+
|
|
62
|
+
async for chunk in self._client.aio.models.generate_content_stream(
|
|
63
|
+
model=self.model,
|
|
64
|
+
contents=contents,
|
|
65
|
+
config=types.GenerateContentConfig(
|
|
66
|
+
temperature=temperature,
|
|
67
|
+
max_output_tokens=max_tokens,
|
|
68
|
+
),
|
|
69
|
+
):
|
|
70
|
+
if chunk.text:
|
|
71
|
+
yield chunk.text
|
|
72
|
+
|
|
73
|
+
async def validate_connection(self) -> bool:
|
|
74
|
+
try:
|
|
75
|
+
await self._client.aio.models.generate_content(
|
|
76
|
+
model=self.model,
|
|
77
|
+
contents="hi",
|
|
78
|
+
config=types.GenerateContentConfig(max_output_tokens=5),
|
|
79
|
+
)
|
|
80
|
+
return True
|
|
81
|
+
except Exception:
|
|
82
|
+
return self.api_key != ""
|
|
83
|
+
|
|
84
|
+
def available_models(self) -> list[str]:
|
|
85
|
+
return [
|
|
86
|
+
"gemini-2.0-flash",
|
|
87
|
+
"gemini-1.5-pro",
|
|
88
|
+
"gemini-1.5-flash",
|
|
89
|
+
"gemini-1.0-pro",
|
|
90
|
+
]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Ollama local model provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import AsyncIterator
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from devobin.providers import AIProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OllamaProvider(AIProvider):
|
|
13
|
+
name = "ollama"
|
|
14
|
+
|
|
15
|
+
def __init__(self, config: dict) -> None:
|
|
16
|
+
super().__init__(config)
|
|
17
|
+
self.base_url = config.get("base_url", "http://localhost:11434")
|
|
18
|
+
self._client = httpx.AsyncClient(base_url=self.base_url, timeout=120.0)
|
|
19
|
+
|
|
20
|
+
async def chat(
|
|
21
|
+
self,
|
|
22
|
+
messages: list[dict[str, str]],
|
|
23
|
+
system: str | None = None,
|
|
24
|
+
temperature: float = 0.7,
|
|
25
|
+
max_tokens: int = 4096,
|
|
26
|
+
) -> str:
|
|
27
|
+
payload: dict = {
|
|
28
|
+
"model": self.model,
|
|
29
|
+
"messages": messages,
|
|
30
|
+
"stream": False,
|
|
31
|
+
"options": {"temperature": temperature, "num_predict": max_tokens},
|
|
32
|
+
}
|
|
33
|
+
if system:
|
|
34
|
+
payload["system"] = system
|
|
35
|
+
|
|
36
|
+
response = await self._client.post("/api/chat", json=payload)
|
|
37
|
+
response.raise_for_status()
|
|
38
|
+
data = response.json()
|
|
39
|
+
return data.get("message", {}).get("content", "")
|
|
40
|
+
|
|
41
|
+
async def chat_stream(
|
|
42
|
+
self,
|
|
43
|
+
messages: list[dict[str, str]],
|
|
44
|
+
system: str | None = None,
|
|
45
|
+
temperature: float = 0.7,
|
|
46
|
+
max_tokens: int = 4096,
|
|
47
|
+
) -> AsyncIterator[str]:
|
|
48
|
+
payload: dict = {
|
|
49
|
+
"model": self.model,
|
|
50
|
+
"messages": messages,
|
|
51
|
+
"stream": True,
|
|
52
|
+
"options": {"temperature": temperature, "num_predict": max_tokens},
|
|
53
|
+
}
|
|
54
|
+
if system:
|
|
55
|
+
payload["system"] = system
|
|
56
|
+
|
|
57
|
+
async with self._client.stream("POST", "/api/chat", json=payload) as response:
|
|
58
|
+
response.raise_for_status()
|
|
59
|
+
async for line in response.aiter_lines():
|
|
60
|
+
if line:
|
|
61
|
+
import json
|
|
62
|
+
data = json.loads(line)
|
|
63
|
+
content = data.get("message", {}).get("content", "")
|
|
64
|
+
if content:
|
|
65
|
+
yield content
|
|
66
|
+
|
|
67
|
+
async def validate_connection(self) -> bool:
|
|
68
|
+
try:
|
|
69
|
+
resp = await self._client.get("/api/tags")
|
|
70
|
+
return resp.status_code == 200
|
|
71
|
+
except Exception:
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
def available_models(self) -> list[str]:
|
|
75
|
+
try:
|
|
76
|
+
import json
|
|
77
|
+
resp = httpx.get(f"{self.base_url}/api/tags", timeout=5.0)
|
|
78
|
+
data = resp.json()
|
|
79
|
+
return [m["name"] for m in data.get("models", [])]
|
|
80
|
+
except Exception:
|
|
81
|
+
return ["llama3", "codellama", "mistral", "gemma"]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""OpenAI and OpenAI-compatible API provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import AsyncIterator
|
|
6
|
+
|
|
7
|
+
from openai import AsyncOpenAI
|
|
8
|
+
|
|
9
|
+
from devobin.providers import AIProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OpenAIProvider(AIProvider):
|
|
13
|
+
name = "openai"
|
|
14
|
+
|
|
15
|
+
def __init__(self, config: dict) -> None:
|
|
16
|
+
super().__init__(config)
|
|
17
|
+
base = config.get("base_url") or "https://api.openai.com/v1"
|
|
18
|
+
self._client = AsyncOpenAI(api_key=self.api_key, base_url=base)
|
|
19
|
+
|
|
20
|
+
async def chat(
|
|
21
|
+
self,
|
|
22
|
+
messages: list[dict[str, str]],
|
|
23
|
+
system: str | None = None,
|
|
24
|
+
temperature: float = 0.7,
|
|
25
|
+
max_tokens: int = 4096,
|
|
26
|
+
) -> str:
|
|
27
|
+
full_messages = []
|
|
28
|
+
if system:
|
|
29
|
+
full_messages.append({"role": "system", "content": system})
|
|
30
|
+
full_messages.extend(messages)
|
|
31
|
+
|
|
32
|
+
response = await self._client.chat.completions.create(
|
|
33
|
+
model=self.model,
|
|
34
|
+
messages=full_messages, # type: ignore[arg-type]
|
|
35
|
+
temperature=temperature,
|
|
36
|
+
max_tokens=max_tokens,
|
|
37
|
+
)
|
|
38
|
+
if not response.choices:
|
|
39
|
+
return ""
|
|
40
|
+
return response.choices[0].message.content or ""
|
|
41
|
+
|
|
42
|
+
async def chat_stream(
|
|
43
|
+
self,
|
|
44
|
+
messages: list[dict[str, str]],
|
|
45
|
+
system: str | None = None,
|
|
46
|
+
temperature: float = 0.7,
|
|
47
|
+
max_tokens: int = 4096,
|
|
48
|
+
) -> AsyncIterator[str]:
|
|
49
|
+
full_messages = []
|
|
50
|
+
if system:
|
|
51
|
+
full_messages.append({"role": "system", "content": system})
|
|
52
|
+
full_messages.extend(messages)
|
|
53
|
+
|
|
54
|
+
stream = await self._client.chat.completions.create(
|
|
55
|
+
model=self.model,
|
|
56
|
+
messages=full_messages, # type: ignore[arg-type]
|
|
57
|
+
temperature=temperature,
|
|
58
|
+
max_tokens=max_tokens,
|
|
59
|
+
stream=True,
|
|
60
|
+
)
|
|
61
|
+
async for chunk in stream:
|
|
62
|
+
if not chunk.choices:
|
|
63
|
+
continue
|
|
64
|
+
delta = chunk.choices[0].delta
|
|
65
|
+
if delta and delta.content:
|
|
66
|
+
yield delta.content
|
|
67
|
+
|
|
68
|
+
async def validate_connection(self) -> bool:
|
|
69
|
+
try:
|
|
70
|
+
await self._client.models.list()
|
|
71
|
+
return True
|
|
72
|
+
except Exception:
|
|
73
|
+
return self.api_key != ""
|
|
74
|
+
|
|
75
|
+
def available_models(self) -> list[str]:
|
|
76
|
+
import httpx
|
|
77
|
+
|
|
78
|
+
base = self.config.get("base_url") or "https://api.openai.com/v1"
|
|
79
|
+
|
|
80
|
+
# For OpenAI official API, return known models
|
|
81
|
+
if "api.openai.com" in base:
|
|
82
|
+
return [
|
|
83
|
+
"gpt-4o",
|
|
84
|
+
"gpt-4o-mini",
|
|
85
|
+
"gpt-4-turbo",
|
|
86
|
+
"gpt-4",
|
|
87
|
+
"gpt-3.5-turbo",
|
|
88
|
+
"o1",
|
|
89
|
+
"o1-mini",
|
|
90
|
+
"o1-pro",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
# For compatible APIs, fetch models from the endpoint
|
|
94
|
+
try:
|
|
95
|
+
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
|
|
96
|
+
resp = httpx.get(f"{base}/models", headers=headers, timeout=10.0)
|
|
97
|
+
resp.raise_for_status()
|
|
98
|
+
data = resp.json()
|
|
99
|
+
models = [m["id"] for m in data.get("data", [])]
|
|
100
|
+
return models if models else ["default"]
|
|
101
|
+
except Exception:
|
|
102
|
+
return ["default"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""DevObin storage package."""
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Local storage for projects, history, and memories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from devobin.config.settings import get_config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _now_iso() -> str:
|
|
15
|
+
return datetime.now(timezone.utc).isoformat()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ProjectStorage:
|
|
19
|
+
"""Per-project persistent storage (memory, sessions, prompts).
|
|
20
|
+
|
|
21
|
+
Data lives in .devobin/ inside the current working directory so the user
|
|
22
|
+
can find everything where they launched the CLI.
|
|
23
|
+
|
|
24
|
+
Sessions: each chat conversation is a separate JSON file in sessions/.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, project_name: str) -> None:
|
|
28
|
+
from devobin.config.settings import local_data_dir
|
|
29
|
+
self.name = project_name
|
|
30
|
+
self._dir = local_data_dir() / "projects" / project_name
|
|
31
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
self._memory_path = self._dir / "memory.json"
|
|
33
|
+
self._meta_path = self._dir / "meta.json"
|
|
34
|
+
self._sessions_dir = self._dir / "sessions"
|
|
35
|
+
self._sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
self._memory: list[dict[str, Any]] = self._load_list(self._memory_path)
|
|
37
|
+
self._meta: dict[str, Any] = self._load_dict(self._meta_path)
|
|
38
|
+
self._current_session_id: str | None = None
|
|
39
|
+
self._current_session: list[dict[str, Any]] = []
|
|
40
|
+
|
|
41
|
+
# ── I/O helpers ───────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
def _load_list(self, path: Path) -> list[dict[str, Any]]:
|
|
44
|
+
if path.exists():
|
|
45
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
def _load_dict(self, path: Path) -> dict[str, Any]:
|
|
49
|
+
if path.exists():
|
|
50
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
51
|
+
return {}
|
|
52
|
+
|
|
53
|
+
def _save_list(self, path: Path, data: list[dict[str, Any]]) -> None:
|
|
54
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
55
|
+
|
|
56
|
+
def _save_dict(self, path: Path, data: dict[str, Any]) -> None:
|
|
57
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
58
|
+
|
|
59
|
+
# ── Memory ────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
def add_memory(self, content: str, category: str = "general") -> dict[str, Any]:
|
|
62
|
+
entry = {
|
|
63
|
+
"id": uuid.uuid4().hex[:8],
|
|
64
|
+
"content": content,
|
|
65
|
+
"category": category,
|
|
66
|
+
"created_at": _now_iso(),
|
|
67
|
+
}
|
|
68
|
+
self._memory.append(entry)
|
|
69
|
+
self._save_list(self._memory_path, self._memory)
|
|
70
|
+
return entry
|
|
71
|
+
|
|
72
|
+
def get_memories(self, category: str | None = None) -> list[dict[str, Any]]:
|
|
73
|
+
if category:
|
|
74
|
+
return [m for m in self._memory if m.get("category") == category]
|
|
75
|
+
return list(self._memory)
|
|
76
|
+
|
|
77
|
+
def search_memories(self, query: str) -> list[dict[str, Any]]:
|
|
78
|
+
q = query.lower()
|
|
79
|
+
return [m for m in self._memory if q in m.get("content", "").lower()]
|
|
80
|
+
|
|
81
|
+
def clear_memories(self) -> None:
|
|
82
|
+
self._memory.clear()
|
|
83
|
+
self._save_list(self._memory_path, self._memory)
|
|
84
|
+
|
|
85
|
+
# ── Sessions (each chat = separate file) ──────────────────────
|
|
86
|
+
|
|
87
|
+
def new_session(self) -> str:
|
|
88
|
+
"""Create a new session and return its ID."""
|
|
89
|
+
sid = _now_iso().replace(":", "-").replace(".", "-")[:19]
|
|
90
|
+
self._current_session_id = sid
|
|
91
|
+
self._current_session = []
|
|
92
|
+
self._save_session()
|
|
93
|
+
return sid
|
|
94
|
+
|
|
95
|
+
def _session_path(self, sid: str) -> Path:
|
|
96
|
+
return self._sessions_dir / f"{sid}.json"
|
|
97
|
+
|
|
98
|
+
def _save_session(self) -> None:
|
|
99
|
+
if self._current_session_id:
|
|
100
|
+
path = self._session_path(self._current_session_id)
|
|
101
|
+
data = {
|
|
102
|
+
"id": self._current_session_id,
|
|
103
|
+
"messages": self._current_session,
|
|
104
|
+
}
|
|
105
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
106
|
+
|
|
107
|
+
def add_message(self, role: str, content: str) -> dict[str, Any]:
|
|
108
|
+
entry = {
|
|
109
|
+
"id": uuid.uuid4().hex[:8],
|
|
110
|
+
"role": role,
|
|
111
|
+
"content": content,
|
|
112
|
+
"timestamp": _now_iso(),
|
|
113
|
+
}
|
|
114
|
+
if self._current_session_id is None:
|
|
115
|
+
self.new_session()
|
|
116
|
+
self._current_session.append(entry)
|
|
117
|
+
self._save_session()
|
|
118
|
+
return entry
|
|
119
|
+
|
|
120
|
+
def get_history(self, limit: int = 50) -> list[dict[str, Any]]:
|
|
121
|
+
return self._current_session[-limit:]
|
|
122
|
+
|
|
123
|
+
def clear_history(self) -> None:
|
|
124
|
+
self._current_session.clear()
|
|
125
|
+
self._save_session()
|
|
126
|
+
|
|
127
|
+
def get_context_messages(self, max_messages: int = 20) -> list[dict[str, str]]:
|
|
128
|
+
recent = self._current_session[-max_messages:]
|
|
129
|
+
return [{"role": m["role"], "content": m["content"]} for m in recent]
|
|
130
|
+
|
|
131
|
+
def list_sessions(self) -> list[dict[str, Any]]:
|
|
132
|
+
"""List all sessions for this project."""
|
|
133
|
+
sessions = []
|
|
134
|
+
for f in sorted(self._sessions_dir.glob("*.json"), reverse=True):
|
|
135
|
+
try:
|
|
136
|
+
data = json.loads(f.read_text(encoding="utf-8"))
|
|
137
|
+
msgs = data.get("messages", [])
|
|
138
|
+
if msgs:
|
|
139
|
+
first_user = next(
|
|
140
|
+
(m["content"][:60] for m in msgs if m.get("role") == "user"),
|
|
141
|
+
"(empty)"
|
|
142
|
+
)
|
|
143
|
+
sessions.append({
|
|
144
|
+
"id": data.get("id", f.stem),
|
|
145
|
+
"count": len(msgs),
|
|
146
|
+
"preview": first_user,
|
|
147
|
+
"messages": msgs,
|
|
148
|
+
})
|
|
149
|
+
except Exception:
|
|
150
|
+
continue
|
|
151
|
+
return sessions
|
|
152
|
+
|
|
153
|
+
# ── Meta ──────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
def set_meta(self, key: str, value: Any) -> None:
|
|
156
|
+
self._meta[key] = value
|
|
157
|
+
self._save_dict(self._meta_path, self._meta)
|
|
158
|
+
|
|
159
|
+
def get_meta(self, key: str, default: Any = None) -> Any:
|
|
160
|
+
return self._meta.get(key, default)
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def created_at(self) -> str:
|
|
164
|
+
return self._meta.get("created_at", _now_iso())
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class GlobalStorage:
|
|
168
|
+
"""Global storage for user-level memory and preferences."""
|
|
169
|
+
|
|
170
|
+
def __init__(self) -> None:
|
|
171
|
+
self._dir = get_config().projects_dir().parent
|
|
172
|
+
self._memory_path = get_config()._settings_path.parent / "global_memory.json"
|
|
173
|
+
self._memory: list[dict[str, Any]] = self._load()
|
|
174
|
+
|
|
175
|
+
def _load(self) -> list[dict[str, Any]]:
|
|
176
|
+
if self._memory_path.exists():
|
|
177
|
+
return json.loads(self._memory_path.read_text(encoding="utf-8"))
|
|
178
|
+
return []
|
|
179
|
+
|
|
180
|
+
def _save(self) -> None:
|
|
181
|
+
self._memory_path.write_text(
|
|
182
|
+
json.dumps(self._memory, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def add_memory(self, content: str, category: str = "preference") -> dict[str, Any]:
|
|
186
|
+
entry = {
|
|
187
|
+
"id": uuid.uuid4().hex[:8],
|
|
188
|
+
"content": content,
|
|
189
|
+
"category": category,
|
|
190
|
+
"created_at": _now_iso(),
|
|
191
|
+
}
|
|
192
|
+
self._memory.append(entry)
|
|
193
|
+
self._save()
|
|
194
|
+
return entry
|
|
195
|
+
|
|
196
|
+
def get_memories(self, category: str | None = None) -> list[dict[str, Any]]:
|
|
197
|
+
if category:
|
|
198
|
+
return [m for m in self._memory if m.get("category") == category]
|
|
199
|
+
return list(self._memory)
|
|
200
|
+
|
|
201
|
+
def search_memories(self, query: str) -> list[dict[str, Any]]:
|
|
202
|
+
q = query.lower()
|
|
203
|
+
return [m for m in self._memory if q in m.get("content", "").lower()]
|
|
204
|
+
|
|
205
|
+
def clear_memories(self) -> None:
|
|
206
|
+
self._memory.clear()
|
|
207
|
+
self._save()
|
devobin/ui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""DevObin UI package."""
|
devobin/ui/ask_modal.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Interactive question modal — lets the AI ask the user for clarification or permission."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.screen import ModalScreen
|
|
7
|
+
from textual.widgets import Static, Input, Button
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual import on
|
|
11
|
+
from rich.markup import escape
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AskModal(ModalScreen[str | None]):
|
|
15
|
+
"""Modal that shows an AI question and returns the user's answer.
|
|
16
|
+
|
|
17
|
+
Used when the model needs clarification or permission to read a file.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
CSS = """
|
|
21
|
+
AskModal {
|
|
22
|
+
align: center middle;
|
|
23
|
+
}
|
|
24
|
+
#ask-container {
|
|
25
|
+
width: 60;
|
|
26
|
+
max-height: 30;
|
|
27
|
+
background: $surface;
|
|
28
|
+
border: tall $primary;
|
|
29
|
+
padding: 2;
|
|
30
|
+
}
|
|
31
|
+
#ask-title {
|
|
32
|
+
text-align: center;
|
|
33
|
+
text-style: bold;
|
|
34
|
+
color: $primary;
|
|
35
|
+
margin-bottom: 1;
|
|
36
|
+
}
|
|
37
|
+
#ask-question {
|
|
38
|
+
height: auto;
|
|
39
|
+
max-height: 15;
|
|
40
|
+
overflow-y: auto;
|
|
41
|
+
margin-bottom: 1;
|
|
42
|
+
color: $text;
|
|
43
|
+
}
|
|
44
|
+
#ask-input {
|
|
45
|
+
width: 100%;
|
|
46
|
+
background: #161b22;
|
|
47
|
+
color: #e6edf3;
|
|
48
|
+
border: round #2b3440;
|
|
49
|
+
padding: 0 1;
|
|
50
|
+
}
|
|
51
|
+
#ask-input:focus { border: round #00d4aa; }
|
|
52
|
+
#ask-buttons {
|
|
53
|
+
height: auto;
|
|
54
|
+
margin-top: 1;
|
|
55
|
+
}
|
|
56
|
+
#ask-buttons Button {
|
|
57
|
+
margin: 0 1;
|
|
58
|
+
}
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
BINDINGS = [
|
|
62
|
+
Binding("escape", "cancel", "Cancel", show=True),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
def __init__(self, question: str, title: str = "DevObin asks") -> None:
|
|
66
|
+
super().__init__()
|
|
67
|
+
self._question = question
|
|
68
|
+
self._title = title
|
|
69
|
+
|
|
70
|
+
def compose(self) -> ComposeResult:
|
|
71
|
+
with Vertical(id="ask-container"):
|
|
72
|
+
yield Static(self._title, id="ask-title")
|
|
73
|
+
yield Static(escape(self._question), id="ask-question")
|
|
74
|
+
yield Input(placeholder="Your answer…", id="ask-input")
|
|
75
|
+
with Vertical(id="ask-buttons"):
|
|
76
|
+
yield Button("Send", id="ask-send", variant="primary")
|
|
77
|
+
yield Button("Skip", id="ask-skip", variant="default")
|
|
78
|
+
|
|
79
|
+
def on_mount(self) -> None:
|
|
80
|
+
self.query_one("#ask-input", Input).focus()
|
|
81
|
+
|
|
82
|
+
@on(Input.Submitted, "#ask-input")
|
|
83
|
+
def on_submitted(self, event: Input.Submitted) -> None:
|
|
84
|
+
self.dismiss(event.value.strip() or None)
|
|
85
|
+
|
|
86
|
+
@on(Button.Pressed, "#ask-send")
|
|
87
|
+
def on_send_pressed(self) -> None:
|
|
88
|
+
value = self.query_one("#ask-input", Input).value.strip()
|
|
89
|
+
self.dismiss(value or None)
|
|
90
|
+
|
|
91
|
+
@on(Button.Pressed, "#ask-skip")
|
|
92
|
+
def on_skip_pressed(self) -> None:
|
|
93
|
+
self.dismiss(None)
|
|
94
|
+
|
|
95
|
+
def action_cancel(self) -> None:
|
|
96
|
+
self.dismiss(None)
|