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.
Files changed (50) hide show
  1. clob/__init__.py +5 -0
  2. clob/agents/__init__.py +20 -0
  3. clob/agents/coder.py +38 -0
  4. clob/analytics/__init__.py +150 -0
  5. clob/attachments/__init__.py +123 -0
  6. clob/config/__init__.py +3 -0
  7. clob/config/settings.py +162 -0
  8. clob/core/__init__.py +3 -0
  9. clob/core/runtime.py +173 -0
  10. clob/indexing/__init__.py +158 -0
  11. clob/main.py +404 -0
  12. clob/memory/__init__.py +5 -0
  13. clob/memory/manager.py +50 -0
  14. clob/memory/models.py +50 -0
  15. clob/memory/persistence.py +176 -0
  16. clob/plugins/__init__.py +3 -0
  17. clob/plugins/loader.py +68 -0
  18. clob/providers/__init__.py +21 -0
  19. clob/providers/base.py +80 -0
  20. clob/providers/capabilities.py +91 -0
  21. clob/providers/groq.py +16 -0
  22. clob/providers/nvidia_build.py +16 -0
  23. clob/providers/ollama.py +133 -0
  24. clob/providers/openai_compatible.py +143 -0
  25. clob/providers/openrouter.py +18 -0
  26. clob/providers/registry.py +65 -0
  27. clob/rendering/__init__.py +150 -0
  28. clob/rendering/images.py +125 -0
  29. clob/sandbox/__init__.py +192 -0
  30. clob/themes/__init__.py +117 -0
  31. clob/tools/__init__.py +3 -0
  32. clob/tools/shell.py +61 -0
  33. clob/tui/__init__.py +3 -0
  34. clob/tui/app.py +328 -0
  35. clob/tui/bindings.py +13 -0
  36. clob/tui/screens/__init__.py +6 -0
  37. clob/tui/screens/memory_search.py +91 -0
  38. clob/tui/screens/palette.py +140 -0
  39. clob/tui/screens/settings.py +117 -0
  40. clob/tui/screens/usage.py +53 -0
  41. clob/tui/themes/dark.tcss +265 -0
  42. clob/tui/widgets/__init__.py +141 -0
  43. clob/utils/__init__.py +3 -0
  44. clob/utils/logging.py +31 -0
  45. clob/workspace/__init__.py +223 -0
  46. clob-0.2.0.dist-info/METADATA +365 -0
  47. clob-0.2.0.dist-info/RECORD +50 -0
  48. clob-0.2.0.dist-info/WHEEL +4 -0
  49. clob-0.2.0.dist-info/entry_points.txt +2 -0
  50. clob-0.2.0.dist-info/licenses/LICENSE +21 -0
clob/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """clob — Universal AI in your terminal."""
2
+
3
+ __version__ = "0.2.0"
4
+ __author__ = "clob contributors"
5
+ __license__ = "MIT"
@@ -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}]
@@ -0,0 +1,3 @@
1
+ from .settings import CONFIG_DIR, CONFIG_FILE, DB_FILE, AppConfig, DefaultConfig, ProviderConfig
2
+
3
+ __all__ = ["AppConfig", "ProviderConfig", "DefaultConfig", "CONFIG_DIR", "CONFIG_FILE", "DB_FILE"]
@@ -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
@@ -0,0 +1,3 @@
1
+ from .runtime import Runtime
2
+
3
+ __all__ = ["Runtime"]
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 ""