clob 0.2.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.
- clob/__init__.py +5 -0
- clob/agents/__init__.py +20 -0
- clob/agents/coder.py +38 -0
- clob/analytics/__init__.py +150 -0
- clob/attachments/__init__.py +123 -0
- clob/config/__init__.py +3 -0
- clob/config/settings.py +162 -0
- clob/core/__init__.py +3 -0
- clob/core/runtime.py +173 -0
- clob/indexing/__init__.py +158 -0
- clob/main.py +404 -0
- clob/memory/__init__.py +5 -0
- clob/memory/manager.py +50 -0
- clob/memory/models.py +50 -0
- clob/memory/persistence.py +176 -0
- clob/plugins/__init__.py +3 -0
- clob/plugins/loader.py +68 -0
- clob/providers/__init__.py +21 -0
- clob/providers/base.py +80 -0
- clob/providers/capabilities.py +91 -0
- clob/providers/groq.py +16 -0
- clob/providers/nvidia_build.py +16 -0
- clob/providers/ollama.py +133 -0
- clob/providers/openai_compatible.py +143 -0
- clob/providers/openrouter.py +18 -0
- clob/providers/registry.py +65 -0
- clob/rendering/__init__.py +150 -0
- clob/rendering/images.py +125 -0
- clob/sandbox/__init__.py +192 -0
- clob/themes/__init__.py +117 -0
- clob/tools/__init__.py +3 -0
- clob/tools/shell.py +61 -0
- clob/tui/__init__.py +3 -0
- clob/tui/app.py +328 -0
- clob/tui/bindings.py +13 -0
- clob/tui/screens/__init__.py +6 -0
- clob/tui/screens/memory_search.py +91 -0
- clob/tui/screens/palette.py +140 -0
- clob/tui/screens/settings.py +117 -0
- clob/tui/screens/usage.py +53 -0
- clob/tui/themes/dark.tcss +265 -0
- clob/tui/widgets/__init__.py +141 -0
- clob/utils/__init__.py +3 -0
- clob/utils/logging.py +31 -0
- clob/workspace/__init__.py +223 -0
- clob-0.2.0.dist-info/METADATA +365 -0
- clob-0.2.0.dist-info/RECORD +50 -0
- clob-0.2.0.dist-info/WHEEL +4 -0
- clob-0.2.0.dist-info/entry_points.txt +2 -0
- clob-0.2.0.dist-info/licenses/LICENSE +21 -0
clob/__init__.py
ADDED
clob/agents/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Agent foundations for clob."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..providers.base import ChatMessage as ChatMessage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseAgent:
|
|
12
|
+
"""Base class for all agents."""
|
|
13
|
+
|
|
14
|
+
name: str = "base"
|
|
15
|
+
|
|
16
|
+
def __init__(self, runtime) -> None:
|
|
17
|
+
self.runtime = runtime
|
|
18
|
+
|
|
19
|
+
async def run(self, task: str, **kwargs: Any) -> AsyncIterator[str]:
|
|
20
|
+
raise NotImplementedError
|
clob/agents/coder.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Coding agent — specialized for code generation and editing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from . import BaseAgent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CoderAgent(BaseAgent):
|
|
12
|
+
"""Agent specialized for code generation and editing tasks."""
|
|
13
|
+
|
|
14
|
+
name = "coder"
|
|
15
|
+
|
|
16
|
+
SYSTEM_PROMPT = """You are an expert software engineer.
|
|
17
|
+
When asked to write code:
|
|
18
|
+
1. Always wrap code in markdown fences with the language tag
|
|
19
|
+
2. Explain what the code does
|
|
20
|
+
3. Mention any dependencies
|
|
21
|
+
4. Follow best practices for the language
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
async def run(self, task: str, **kwargs: Any) -> AsyncIterator[str]:
|
|
25
|
+
from ..providers.base import ChatMessage
|
|
26
|
+
|
|
27
|
+
provider = self.runtime.registry.get(self.runtime.provider)
|
|
28
|
+
if not provider:
|
|
29
|
+
yield "No provider configured."
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
messages = [
|
|
33
|
+
ChatMessage(role="system", content=self.SYSTEM_PROMPT),
|
|
34
|
+
ChatMessage(role="user", content=task),
|
|
35
|
+
]
|
|
36
|
+
async for chunk in provider.stream_chat(messages, model=self.runtime.model, **kwargs):
|
|
37
|
+
if chunk.delta:
|
|
38
|
+
yield chunk.delta
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Token and cost analytics for clob.
|
|
2
|
+
|
|
3
|
+
Tracks:
|
|
4
|
+
- Input/output tokens per session
|
|
5
|
+
- Estimated cost based on provider pricing
|
|
6
|
+
- Daily/session usage summaries
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
# ── Provider pricing (per 1M tokens, USD) ─────────────────────
|
|
15
|
+
|
|
16
|
+
PRICING: dict[str, dict[str, float]] = {
|
|
17
|
+
# provider -> model_prefix -> (input, output) per 1M tokens
|
|
18
|
+
"openrouter": {
|
|
19
|
+
"default": (0.50, 1.50),
|
|
20
|
+
"openai/gpt-4o": (2.50, 10.00),
|
|
21
|
+
"openai/gpt-4o-mini": (0.15, 0.60),
|
|
22
|
+
"anthropic/claude": (3.00, 15.00),
|
|
23
|
+
"meta-llama": (0.05, 0.05),
|
|
24
|
+
},
|
|
25
|
+
"groq": {
|
|
26
|
+
"default": (0.05, 0.10),
|
|
27
|
+
"llama3-70b": (0.59, 0.79),
|
|
28
|
+
"llama3-8b": (0.05, 0.08),
|
|
29
|
+
"mixtral": (0.24, 0.24),
|
|
30
|
+
},
|
|
31
|
+
"ollama": {
|
|
32
|
+
"default": (0.0, 0.0), # local, free
|
|
33
|
+
},
|
|
34
|
+
"nvidia": {
|
|
35
|
+
"default": (0.20, 0.20),
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _estimate_cost(provider: str, model: str, in_tokens: int, out_tokens: int) -> float:
|
|
41
|
+
"""Estimate USD cost for a completion."""
|
|
42
|
+
provider_prices = PRICING.get(provider, PRICING.get("openrouter", {}))
|
|
43
|
+
|
|
44
|
+
# Find best matching model prefix
|
|
45
|
+
in_price, out_price = provider_prices.get("default", (0.50, 1.50))
|
|
46
|
+
for prefix, prices in provider_prices.items():
|
|
47
|
+
if prefix != "default" and model.startswith(prefix):
|
|
48
|
+
in_price, out_price = prices
|
|
49
|
+
break
|
|
50
|
+
|
|
51
|
+
return (in_tokens * in_price + out_tokens * out_price) / 1_000_000
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class TurnStats:
|
|
56
|
+
"""Stats for a single request/response turn."""
|
|
57
|
+
|
|
58
|
+
provider: str
|
|
59
|
+
model: str
|
|
60
|
+
input_tokens: int
|
|
61
|
+
output_tokens: int
|
|
62
|
+
latency_ms: float
|
|
63
|
+
timestamp: datetime = field(default_factory=lambda: __import__("datetime").datetime.now())
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def total_tokens(self) -> int:
|
|
67
|
+
return self.input_tokens + self.output_tokens
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def estimated_cost_usd(self) -> float:
|
|
71
|
+
return _estimate_cost(self.provider, self.model, self.input_tokens, self.output_tokens)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class SessionStats:
|
|
76
|
+
"""Aggregated stats for a session."""
|
|
77
|
+
|
|
78
|
+
session_id: int
|
|
79
|
+
turns: list[TurnStats] = field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
def record(self, turn: TurnStats) -> None:
|
|
82
|
+
self.turns.append(turn)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def total_input_tokens(self) -> int:
|
|
86
|
+
return sum(t.input_tokens for t in self.turns)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def total_output_tokens(self) -> int:
|
|
90
|
+
return sum(t.output_tokens for t in self.turns)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def total_tokens(self) -> int:
|
|
94
|
+
return self.total_input_tokens + self.total_output_tokens
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def total_cost_usd(self) -> float:
|
|
98
|
+
return sum(t.estimated_cost_usd for t in self.turns)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def turn_count(self) -> int:
|
|
102
|
+
return len(self.turns)
|
|
103
|
+
|
|
104
|
+
def summary_line(self) -> str:
|
|
105
|
+
"""One-line summary for status bar."""
|
|
106
|
+
cost = self.total_cost_usd
|
|
107
|
+
cost_str = f"${cost:.4f}" if cost > 0 else "free"
|
|
108
|
+
return f"↑{self.total_input_tokens} ↓{self.total_output_tokens} {cost_str}"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class AnalyticsTracker:
|
|
112
|
+
"""Runtime analytics tracker — in-memory for current session."""
|
|
113
|
+
|
|
114
|
+
def __init__(self) -> None:
|
|
115
|
+
self._sessions: dict[int, SessionStats] = {}
|
|
116
|
+
self._global_turns: list[TurnStats] = []
|
|
117
|
+
|
|
118
|
+
def record(self, session_id: int, turn: TurnStats) -> None:
|
|
119
|
+
if session_id not in self._sessions:
|
|
120
|
+
self._sessions[session_id] = SessionStats(session_id=session_id)
|
|
121
|
+
self._sessions[session_id].record(turn)
|
|
122
|
+
self._global_turns.append(turn)
|
|
123
|
+
|
|
124
|
+
def get_session(self, session_id: int) -> SessionStats | None:
|
|
125
|
+
return self._sessions.get(session_id)
|
|
126
|
+
|
|
127
|
+
def global_summary(self) -> dict:
|
|
128
|
+
total_in = sum(t.input_tokens for t in self._global_turns)
|
|
129
|
+
total_out = sum(t.output_tokens for t in self._global_turns)
|
|
130
|
+
total_cost = sum(t.estimated_cost_usd for t in self._global_turns)
|
|
131
|
+
return {
|
|
132
|
+
"total_input_tokens": total_in,
|
|
133
|
+
"total_output_tokens": total_out,
|
|
134
|
+
"total_tokens": total_in + total_out,
|
|
135
|
+
"total_cost_usd": total_cost,
|
|
136
|
+
"turn_count": len(self._global_turns),
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
def format_usage_report(self) -> str:
|
|
140
|
+
g = self.global_summary()
|
|
141
|
+
lines = [
|
|
142
|
+
"── clob Usage Report ────────────────────────",
|
|
143
|
+
f" Total turns: {g['turn_count']}",
|
|
144
|
+
f" Input tokens: {g['total_input_tokens']:,}",
|
|
145
|
+
f" Output tokens: {g['total_output_tokens']:,}",
|
|
146
|
+
f" Total tokens: {g['total_tokens']:,}",
|
|
147
|
+
f" Est. cost: ${g['total_cost_usd']:.4f} USD",
|
|
148
|
+
"─────────────────────────────────────────────",
|
|
149
|
+
]
|
|
150
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Multimodal attachment handling for clob.
|
|
2
|
+
|
|
3
|
+
Supports: images, PDFs, text files, code files.
|
|
4
|
+
Converts to provider-compatible message formats.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import mimetypes
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
SUPPORTED_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
|
|
15
|
+
SUPPORTED_TEXT_EXTS = {
|
|
16
|
+
".txt",
|
|
17
|
+
".md",
|
|
18
|
+
".py",
|
|
19
|
+
".js",
|
|
20
|
+
".ts",
|
|
21
|
+
".go",
|
|
22
|
+
".rs",
|
|
23
|
+
".java",
|
|
24
|
+
".c",
|
|
25
|
+
".cpp",
|
|
26
|
+
".h",
|
|
27
|
+
".css",
|
|
28
|
+
".html",
|
|
29
|
+
".json",
|
|
30
|
+
".yaml",
|
|
31
|
+
".yml",
|
|
32
|
+
".toml",
|
|
33
|
+
".sh",
|
|
34
|
+
".sql",
|
|
35
|
+
".xml",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Attachment:
|
|
41
|
+
path: Path
|
|
42
|
+
mime_type: str
|
|
43
|
+
data: bytes
|
|
44
|
+
filename: str
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_image(self) -> bool:
|
|
48
|
+
return self.mime_type.startswith("image/")
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def is_text(self) -> bool:
|
|
52
|
+
return self.mime_type.startswith("text/") or self.path.suffix in SUPPORTED_TEXT_EXTS
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def is_pdf(self) -> bool:
|
|
56
|
+
return self.mime_type == "application/pdf"
|
|
57
|
+
|
|
58
|
+
def to_base64(self) -> str:
|
|
59
|
+
return base64.standard_b64encode(self.data).decode()
|
|
60
|
+
|
|
61
|
+
def to_openai_content_part(self) -> dict:
|
|
62
|
+
"""Convert to OpenAI-compatible vision content part."""
|
|
63
|
+
if self.is_image:
|
|
64
|
+
return {
|
|
65
|
+
"type": "image_url",
|
|
66
|
+
"image_url": {"url": f"data:{self.mime_type};base64,{self.to_base64()}"},
|
|
67
|
+
}
|
|
68
|
+
elif self.is_text:
|
|
69
|
+
try:
|
|
70
|
+
text = self.data.decode(errors="replace")
|
|
71
|
+
except Exception:
|
|
72
|
+
text = f"[Binary file: {self.filename}]"
|
|
73
|
+
return {"type": "text", "text": f"```\n# {self.filename}\n{text}\n```"}
|
|
74
|
+
else:
|
|
75
|
+
return {"type": "text", "text": f"[Attached file: {self.filename} ({self.mime_type})]"}
|
|
76
|
+
|
|
77
|
+
def preview(self, max_chars: int = 200) -> str:
|
|
78
|
+
if self.is_image:
|
|
79
|
+
return f"🖼 {self.filename} ({len(self.data) // 1024}KB image)"
|
|
80
|
+
if self.is_text:
|
|
81
|
+
try:
|
|
82
|
+
text = self.data.decode(errors="replace")[:max_chars]
|
|
83
|
+
return f"📄 {self.filename}\n{text}..."
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
return f"📎 {self.filename} ({self.mime_type})"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_attachment(path: Path) -> Attachment:
|
|
90
|
+
"""Load a file as an Attachment."""
|
|
91
|
+
mime, _ = mimetypes.guess_type(str(path))
|
|
92
|
+
if mime is None:
|
|
93
|
+
# Fallback detection
|
|
94
|
+
if path.suffix in SUPPORTED_IMAGE_EXTS:
|
|
95
|
+
mime = f"image/{path.suffix.lstrip('.')}"
|
|
96
|
+
elif path.suffix in SUPPORTED_TEXT_EXTS:
|
|
97
|
+
mime = "text/plain"
|
|
98
|
+
else:
|
|
99
|
+
mime = "application/octet-stream"
|
|
100
|
+
|
|
101
|
+
return Attachment(
|
|
102
|
+
path=path,
|
|
103
|
+
mime_type=mime,
|
|
104
|
+
data=path.read_bytes(),
|
|
105
|
+
filename=path.name,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def attachments_to_messages(
|
|
110
|
+
text: str,
|
|
111
|
+
attachments: list[Attachment],
|
|
112
|
+
) -> list[dict]:
|
|
113
|
+
"""Build OpenAI-compatible content list from text + attachments."""
|
|
114
|
+
if not attachments:
|
|
115
|
+
return [{"role": "user", "content": text}]
|
|
116
|
+
|
|
117
|
+
content = []
|
|
118
|
+
# Add attachments first
|
|
119
|
+
for att in attachments:
|
|
120
|
+
content.append(att.to_openai_content_part())
|
|
121
|
+
# Then the text
|
|
122
|
+
content.append({"type": "text", "text": text})
|
|
123
|
+
return [{"role": "user", "content": content}]
|
clob/config/__init__.py
ADDED
clob/config/settings.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Configuration models and settings for clob."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field, field_validator
|
|
11
|
+
|
|
12
|
+
CONFIG_DIR = Path.home() / ".config" / "clob"
|
|
13
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
14
|
+
DB_FILE = CONFIG_DIR / "clob.db"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProviderConfig(BaseModel):
|
|
18
|
+
"""Configuration for a single provider."""
|
|
19
|
+
|
|
20
|
+
name: str = ""
|
|
21
|
+
base_url: str = ""
|
|
22
|
+
api_key: str = ""
|
|
23
|
+
chat_endpoint: str = "/chat/completions"
|
|
24
|
+
models_endpoint: str = "/models"
|
|
25
|
+
images_endpoint: str = "/images/generations"
|
|
26
|
+
embeddings_endpoint: str = "/embeddings"
|
|
27
|
+
extra_headers: dict[str, str] = Field(default_factory=dict)
|
|
28
|
+
timeout: float = 60.0
|
|
29
|
+
enabled: bool = True
|
|
30
|
+
|
|
31
|
+
@field_validator("api_key", mode="before")
|
|
32
|
+
@classmethod
|
|
33
|
+
def resolve_env(cls, v: str) -> str:
|
|
34
|
+
"""Resolve env:VAR_NAME references."""
|
|
35
|
+
if isinstance(v, str) and v.startswith("env:"):
|
|
36
|
+
env_var = v[4:]
|
|
37
|
+
return os.environ.get(env_var, "")
|
|
38
|
+
return v or ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DefaultConfig(BaseModel):
|
|
42
|
+
"""Default provider/model selection."""
|
|
43
|
+
|
|
44
|
+
provider: str = "openrouter"
|
|
45
|
+
model: str = "openai/gpt-4o-mini"
|
|
46
|
+
stream: bool = True
|
|
47
|
+
max_tokens: int = 4096
|
|
48
|
+
temperature: float = 0.7
|
|
49
|
+
system_prompt: str = "You are a helpful AI assistant."
|
|
50
|
+
theme: str = "dark"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AppConfig(BaseModel):
|
|
54
|
+
"""Full application configuration."""
|
|
55
|
+
|
|
56
|
+
default: DefaultConfig = Field(default_factory=DefaultConfig)
|
|
57
|
+
providers: dict[str, ProviderConfig] = Field(default_factory=dict)
|
|
58
|
+
profiles: dict[str, Any] = Field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def load(cls) -> AppConfig:
|
|
62
|
+
if not CONFIG_FILE.exists():
|
|
63
|
+
return cls._create_defaults()
|
|
64
|
+
with open(CONFIG_FILE, "rb") as f:
|
|
65
|
+
data = tomllib.load(f)
|
|
66
|
+
return cls._from_dict(data)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def _from_dict(cls, data: dict[str, Any]) -> AppConfig:
|
|
70
|
+
default = DefaultConfig(**data.get("default", {}))
|
|
71
|
+
providers: dict[str, ProviderConfig] = {}
|
|
72
|
+
for name, pdata in data.get("providers", {}).items():
|
|
73
|
+
providers[name] = ProviderConfig(name=name, **pdata)
|
|
74
|
+
profiles = data.get("profiles", {})
|
|
75
|
+
return cls(default=default, providers=providers, profiles=profiles)
|
|
76
|
+
|
|
77
|
+
def apply_profile(self, name: str) -> bool:
|
|
78
|
+
p = self.profiles.get(name)
|
|
79
|
+
if not p:
|
|
80
|
+
return False
|
|
81
|
+
if p.get("provider"):
|
|
82
|
+
self.default.provider = p["provider"]
|
|
83
|
+
if p.get("model"):
|
|
84
|
+
self.default.model = p["model"]
|
|
85
|
+
if p.get("system_prompt"):
|
|
86
|
+
self.default.system_prompt = p["system_prompt"]
|
|
87
|
+
if p.get("temperature") is not None:
|
|
88
|
+
self.default.temperature = p["temperature"]
|
|
89
|
+
if p.get("max_tokens") is not None:
|
|
90
|
+
self.default.max_tokens = p["max_tokens"]
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def _create_defaults(cls) -> AppConfig:
|
|
95
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
config = cls()
|
|
97
|
+
config.providers = {
|
|
98
|
+
"openrouter": ProviderConfig(
|
|
99
|
+
name="openrouter",
|
|
100
|
+
base_url="https://openrouter.ai/api/v1",
|
|
101
|
+
api_key="env:OPENROUTER_API_KEY",
|
|
102
|
+
),
|
|
103
|
+
"groq": ProviderConfig(
|
|
104
|
+
name="groq",
|
|
105
|
+
base_url="https://api.groq.com/openai/v1",
|
|
106
|
+
api_key="env:GROQ_API_KEY",
|
|
107
|
+
),
|
|
108
|
+
"ollama": ProviderConfig(
|
|
109
|
+
name="ollama",
|
|
110
|
+
base_url="http://localhost:11434",
|
|
111
|
+
api_key="",
|
|
112
|
+
chat_endpoint="/api/chat",
|
|
113
|
+
models_endpoint="/api/tags",
|
|
114
|
+
),
|
|
115
|
+
"nvidia": ProviderConfig(
|
|
116
|
+
name="nvidia",
|
|
117
|
+
base_url="https://integrate.api.nvidia.com/v1",
|
|
118
|
+
api_key="env:NVIDIA_API_KEY",
|
|
119
|
+
),
|
|
120
|
+
}
|
|
121
|
+
_write_default_config(config)
|
|
122
|
+
return config
|
|
123
|
+
|
|
124
|
+
def get_provider(self, name: str) -> ProviderConfig | None:
|
|
125
|
+
return self.providers.get(name)
|
|
126
|
+
|
|
127
|
+
def save(self) -> None:
|
|
128
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
_write_default_config(self)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _write_default_config(config: AppConfig) -> None:
|
|
133
|
+
"""Write a default config.toml."""
|
|
134
|
+
lines = [
|
|
135
|
+
"# clob configuration\n",
|
|
136
|
+
"# https://github.com/crishacks/clob\n\n",
|
|
137
|
+
"[default]\n",
|
|
138
|
+
f'provider = "{config.default.provider}"\n',
|
|
139
|
+
f'model = "{config.default.model}"\n',
|
|
140
|
+
f"stream = {str(config.default.stream).lower()}\n",
|
|
141
|
+
f"max_tokens = {config.default.max_tokens}\n",
|
|
142
|
+
f"temperature = {config.default.temperature}\n",
|
|
143
|
+
f'theme = "{config.default.theme}"\n\n',
|
|
144
|
+
]
|
|
145
|
+
for pname, pconf in config.providers.items():
|
|
146
|
+
lines.append(f"[providers.{pname}]\n")
|
|
147
|
+
lines.append(f'base_url = "{pconf.base_url}"\n')
|
|
148
|
+
# Don't write resolved keys back — keep env: refs
|
|
149
|
+
lines.append(f'api_key = "env:{pname.upper()}_API_KEY"\n\n')
|
|
150
|
+
|
|
151
|
+
with open(CONFIG_FILE, "w") as f:
|
|
152
|
+
f.writelines(lines)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class ProfileConfig(BaseModel):
|
|
156
|
+
"""A named configuration profile (work, local, etc.)."""
|
|
157
|
+
|
|
158
|
+
provider: str = ""
|
|
159
|
+
model: str = ""
|
|
160
|
+
system_prompt: str = ""
|
|
161
|
+
temperature: float | None = None
|
|
162
|
+
max_tokens: int | None = None
|
clob/core/__init__.py
ADDED
clob/core/runtime.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Core runtime v0.2.0 — wires providers, memory, analytics, workspace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..analytics import AnalyticsTracker, TurnStats
|
|
11
|
+
from ..config.settings import AppConfig
|
|
12
|
+
from ..memory.manager import MemoryManager
|
|
13
|
+
from ..providers.base import ChatChunk, ChatMessage
|
|
14
|
+
from ..providers.capabilities import ProviderCapabilities, get_capabilities
|
|
15
|
+
from ..providers.registry import ProviderRegistry
|
|
16
|
+
from ..workspace import resolve_context_refs
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Runtime:
|
|
20
|
+
"""
|
|
21
|
+
Central runtime v0.2.0:
|
|
22
|
+
- Provider selection + capability awareness
|
|
23
|
+
- Streaming with token counting
|
|
24
|
+
- Analytics tracking
|
|
25
|
+
- Workspace context injection (@file, @dir, @workspace)
|
|
26
|
+
- Session management
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, config: AppConfig) -> None:
|
|
30
|
+
self.config = config
|
|
31
|
+
self.registry = ProviderRegistry()
|
|
32
|
+
self.memory = MemoryManager()
|
|
33
|
+
self.analytics = AnalyticsTracker()
|
|
34
|
+
self._current_session_id: int | None = None
|
|
35
|
+
self._current_provider: str = config.default.provider
|
|
36
|
+
self._current_model: str = config.default.model
|
|
37
|
+
self._workspace_root: Path = Path.cwd()
|
|
38
|
+
|
|
39
|
+
async def start(self) -> None:
|
|
40
|
+
self.registry.load_from_config(self.config)
|
|
41
|
+
await self.memory.start()
|
|
42
|
+
|
|
43
|
+
async def stop(self) -> None:
|
|
44
|
+
await self.registry.close_all()
|
|
45
|
+
await self.memory.stop()
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def provider(self) -> str:
|
|
49
|
+
return self._current_provider
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def model(self) -> str:
|
|
53
|
+
return self._current_model
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def capabilities(self) -> ProviderCapabilities:
|
|
57
|
+
return get_capabilities(self._current_provider)
|
|
58
|
+
|
|
59
|
+
def set_provider(self, name: str) -> bool:
|
|
60
|
+
if self.registry.get(name):
|
|
61
|
+
self._current_provider = name
|
|
62
|
+
return True
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
def set_model(self, model: str) -> None:
|
|
66
|
+
self._current_model = model
|
|
67
|
+
|
|
68
|
+
def set_workspace(self, path: Path) -> None:
|
|
69
|
+
self._workspace_root = path.resolve()
|
|
70
|
+
|
|
71
|
+
async def ensure_session(self) -> int:
|
|
72
|
+
if self._current_session_id is None:
|
|
73
|
+
session = await self.memory.new_session(
|
|
74
|
+
provider=self._current_provider,
|
|
75
|
+
model=self._current_model,
|
|
76
|
+
)
|
|
77
|
+
self._current_session_id = session.id
|
|
78
|
+
return self._current_session_id
|
|
79
|
+
|
|
80
|
+
async def new_session(self) -> int:
|
|
81
|
+
session = await self.memory.new_session(
|
|
82
|
+
provider=self._current_provider,
|
|
83
|
+
model=self._current_model,
|
|
84
|
+
)
|
|
85
|
+
self._current_session_id = session.id
|
|
86
|
+
return session.id
|
|
87
|
+
|
|
88
|
+
async def load_session(self, session_id: int) -> bool:
|
|
89
|
+
session = await self.memory.get_session(session_id)
|
|
90
|
+
if session:
|
|
91
|
+
self._current_session_id = session_id
|
|
92
|
+
if session.provider:
|
|
93
|
+
self._current_provider = session.provider
|
|
94
|
+
if session.model:
|
|
95
|
+
self._current_model = session.model
|
|
96
|
+
return True
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
async def _resolve_input(self, user_input: str) -> str:
|
|
100
|
+
if any(ref in user_input for ref in ("@file", "@dir", "@workspace")):
|
|
101
|
+
return resolve_context_refs(user_input, self._workspace_root)
|
|
102
|
+
return user_input
|
|
103
|
+
|
|
104
|
+
async def _build_messages(self, session_id: int) -> list[ChatMessage]:
|
|
105
|
+
messages: list[ChatMessage] = []
|
|
106
|
+
system = self.config.default.system_prompt
|
|
107
|
+
if system:
|
|
108
|
+
messages.append(ChatMessage(role="system", content=system))
|
|
109
|
+
history = await self.memory.get_history(session_id)
|
|
110
|
+
for msg in history:
|
|
111
|
+
messages.append(ChatMessage(role=msg.role, content=msg.content))
|
|
112
|
+
return messages
|
|
113
|
+
|
|
114
|
+
async def stream_response(self, user_input: str, **kwargs: Any) -> AsyncIterator[ChatChunk]:
|
|
115
|
+
provider = self.registry.get(self._current_provider)
|
|
116
|
+
if not provider:
|
|
117
|
+
raise RuntimeError(
|
|
118
|
+
f"Provider '{self._current_provider}' not configured. "
|
|
119
|
+
"Run 'clob doctor' to check setup."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
await self._resolve_input(user_input)
|
|
123
|
+
session_id = await self.ensure_session()
|
|
124
|
+
await self.memory.add_message(session_id, "user", user_input)
|
|
125
|
+
|
|
126
|
+
messages = await self._build_messages(session_id)
|
|
127
|
+
|
|
128
|
+
t0 = time.monotonic()
|
|
129
|
+
full_response = ""
|
|
130
|
+
input_tokens = sum(len(m.content.split()) for m in messages)
|
|
131
|
+
|
|
132
|
+
async for chunk in provider.stream_chat(
|
|
133
|
+
messages,
|
|
134
|
+
model=self._current_model,
|
|
135
|
+
max_tokens=self.config.default.max_tokens,
|
|
136
|
+
temperature=self.config.default.temperature,
|
|
137
|
+
**kwargs,
|
|
138
|
+
):
|
|
139
|
+
full_response += chunk.delta
|
|
140
|
+
yield chunk
|
|
141
|
+
|
|
142
|
+
latency_ms = (time.monotonic() - t0) * 1000
|
|
143
|
+
output_tokens = len(full_response.split())
|
|
144
|
+
|
|
145
|
+
await self.memory.add_message(session_id, "assistant", full_response)
|
|
146
|
+
|
|
147
|
+
self.analytics.record(
|
|
148
|
+
session_id,
|
|
149
|
+
TurnStats(
|
|
150
|
+
provider=self._current_provider,
|
|
151
|
+
model=self._current_model,
|
|
152
|
+
input_tokens=input_tokens,
|
|
153
|
+
output_tokens=output_tokens,
|
|
154
|
+
latency_ms=latency_ms,
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
history = await self.memory.get_history(session_id)
|
|
159
|
+
if len(history) == 2:
|
|
160
|
+
title = user_input[:50].strip()
|
|
161
|
+
await self.memory.rename_session(session_id, title)
|
|
162
|
+
|
|
163
|
+
async def chat(self, user_input: str, **kwargs: Any) -> str:
|
|
164
|
+
result = ""
|
|
165
|
+
async for chunk in self.stream_response(user_input, **kwargs):
|
|
166
|
+
result += chunk.delta
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
def session_stats_line(self) -> str:
|
|
170
|
+
if self._current_session_id is None:
|
|
171
|
+
return ""
|
|
172
|
+
stats = self.analytics.get_session(self._current_session_id)
|
|
173
|
+
return stats.summary_line() if stats else ""
|