closecode 0.1.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.
- closecode/__init__.py +4 -0
- closecode/__main__.py +6 -0
- closecode/agent/__init__.py +79 -0
- closecode/app.py +691 -0
- closecode/auth.py +22 -0
- closecode/config.py +97 -0
- closecode/llmesh/__init__.py +91 -0
- closecode/llmesh/client.py +221 -0
- closecode/llmesh/streaming.py +47 -0
- closecode/py.typed +0 -0
- closecode/sessions.py +176 -0
- closecode/ui/__init__.py +1 -0
- closecode/ui/screens/__init__.py +1 -0
- closecode/ui/screens/main.py +125 -0
- closecode/ui/screens/onboarding.py +101 -0
- closecode/ui/theme.tcss +374 -0
- closecode/ui/widgets/__init__.py +1 -0
- closecode/ui/widgets/chat.py +105 -0
- closecode/ui/widgets/composer.py +52 -0
- closecode/ui/widgets/header.py +53 -0
- closecode/ui/widgets/infopanel.py +101 -0
- closecode/ui/widgets/statusbar.py +60 -0
- closecode-0.1.0.dist-info/METADATA +404 -0
- closecode-0.1.0.dist-info/RECORD +28 -0
- closecode-0.1.0.dist-info/WHEEL +5 -0
- closecode-0.1.0.dist-info/entry_points.txt +2 -0
- closecode-0.1.0.dist-info/licenses/LICENSE +21 -0
- closecode-0.1.0.dist-info/top_level.txt +1 -0
closecode/auth.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Close Code authentication — API key management."""
|
|
2
|
+
|
|
3
|
+
from closecode.config import CloseCodeConfig, CLOSECODE_DIR
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def mask_key(key: str) -> str:
|
|
7
|
+
"""Mask an API key for display (show first 4 + last 4)."""
|
|
8
|
+
if len(key) <= 10:
|
|
9
|
+
return "*" * len(key)
|
|
10
|
+
return key[:4] + "*" * (len(key) - 8) + key[-4:]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def store_api_key(config: CloseCodeConfig, key: str):
|
|
14
|
+
"""Store the API key in config."""
|
|
15
|
+
config.api_key = key
|
|
16
|
+
config.save()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def clear_credentials(config: CloseCodeConfig):
|
|
20
|
+
"""Remove stored credentials (logout)."""
|
|
21
|
+
config.api_key = ""
|
|
22
|
+
config.save()
|
closecode/config.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Close Code configuration management.
|
|
2
|
+
|
|
3
|
+
Stores config at ~/.closecode/config.json.
|
|
4
|
+
Supports environment variable overrides.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ── Paths ───────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
CLOSECODE_DIR = Path.home() / ".closecode"
|
|
18
|
+
CONFIG_FILE = CLOSECODE_DIR / "config.json"
|
|
19
|
+
SESSIONS_DIR = CLOSECODE_DIR / "sessions"
|
|
20
|
+
LOGS_DIR = CLOSECODE_DIR / "logs"
|
|
21
|
+
|
|
22
|
+
# Pre-rename location; migrated once on first run.
|
|
23
|
+
_LEGACY_DIR = Path.home() / ".ovo"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _migrate_legacy_dir() -> None:
|
|
27
|
+
"""Move ~/.ovo to ~/.closecode once, so upgrades keep their sessions."""
|
|
28
|
+
if CLOSECODE_DIR.exists() or not _LEGACY_DIR.is_dir():
|
|
29
|
+
return
|
|
30
|
+
try:
|
|
31
|
+
_LEGACY_DIR.rename(CLOSECODE_DIR)
|
|
32
|
+
except OSError:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CloseCodeConfig(BaseModel):
|
|
37
|
+
"""Application configuration."""
|
|
38
|
+
|
|
39
|
+
# LLMesh connection
|
|
40
|
+
api_url: str = Field(default="http://localhost:8087")
|
|
41
|
+
api_key: str = Field(default="")
|
|
42
|
+
|
|
43
|
+
# Defaults
|
|
44
|
+
default_mode: str = Field(default="build")
|
|
45
|
+
default_model: str = Field(default="")
|
|
46
|
+
|
|
47
|
+
# Current state (persisted across restarts)
|
|
48
|
+
current_mode: str = Field(default="build")
|
|
49
|
+
current_model: str = Field(default="")
|
|
50
|
+
current_provider: str = Field(default="")
|
|
51
|
+
|
|
52
|
+
# Behaviour
|
|
53
|
+
stream: bool = Field(default=True)
|
|
54
|
+
auto_fallback: bool = Field(default=True)
|
|
55
|
+
auto_compact: bool = Field(default=True)
|
|
56
|
+
show_usage: bool = Field(default=True)
|
|
57
|
+
|
|
58
|
+
# Context budget
|
|
59
|
+
system_budget: int = Field(default=10_000)
|
|
60
|
+
output_budget: int = Field(default=10_000)
|
|
61
|
+
|
|
62
|
+
# Tool approval
|
|
63
|
+
tool_approval_policy: str = Field(default="ask") # ask | allow_safe | allow_all
|
|
64
|
+
|
|
65
|
+
model_config = {"protected_namespaces": ()}
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def load(cls) -> "CloseCodeConfig":
|
|
69
|
+
"""Load config from disk, with env var overrides."""
|
|
70
|
+
_migrate_legacy_dir()
|
|
71
|
+
config = cls()
|
|
72
|
+
|
|
73
|
+
if CONFIG_FILE.exists():
|
|
74
|
+
try:
|
|
75
|
+
with open(CONFIG_FILE) as f:
|
|
76
|
+
data = json.load(f)
|
|
77
|
+
config = cls(**data)
|
|
78
|
+
except (json.JSONDecodeError, Exception):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
# Environment variable overrides
|
|
82
|
+
if url := os.environ.get("CLOSECODE_API_URL") or os.environ.get("LLMESH_API_URL"):
|
|
83
|
+
config.api_url = url
|
|
84
|
+
if key := os.environ.get("CLOSECODE_API_KEY") or os.environ.get("LLMESH_API_KEY"):
|
|
85
|
+
config.api_key = key
|
|
86
|
+
|
|
87
|
+
return config
|
|
88
|
+
|
|
89
|
+
def save(self):
|
|
90
|
+
"""Persist config to disk."""
|
|
91
|
+
CLOSECODE_DIR.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
with open(CONFIG_FILE, "w") as f:
|
|
93
|
+
json.dump(self.model_dump(), f, indent=2)
|
|
94
|
+
|
|
95
|
+
def is_configured(self) -> bool:
|
|
96
|
+
"""Whether Close Code has a stored API key."""
|
|
97
|
+
return bool(self.api_key)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Pydantic domain models for Close Code."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ModelStatus(Enum):
|
|
9
|
+
"""Status of a model on the server."""
|
|
10
|
+
ACTIVE = "active"
|
|
11
|
+
INACTIVE = "inactive"
|
|
12
|
+
UNAVAILABLE = "unavailable"
|
|
13
|
+
RATE_LIMITED = "rate_limited"
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def icon(self) -> str:
|
|
17
|
+
return {
|
|
18
|
+
ModelStatus.ACTIVE: "●",
|
|
19
|
+
ModelStatus.INACTIVE: "○",
|
|
20
|
+
ModelStatus.UNAVAILABLE: "✗",
|
|
21
|
+
ModelStatus.RATE_LIMITED: "⚠",
|
|
22
|
+
}[self]
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def label(self) -> str:
|
|
26
|
+
return {
|
|
27
|
+
ModelStatus.ACTIVE: "Active",
|
|
28
|
+
ModelStatus.INACTIVE: "Inactive",
|
|
29
|
+
ModelStatus.UNAVAILABLE: "Unavailable",
|
|
30
|
+
ModelStatus.RATE_LIMITED: "Rate Limited",
|
|
31
|
+
}[self]
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def selectable(self) -> bool:
|
|
35
|
+
"""Whether this model can be selected by the user."""
|
|
36
|
+
return self == ModelStatus.ACTIVE
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ModelInfo(BaseModel):
|
|
40
|
+
"""Information about an available LLM model."""
|
|
41
|
+
name: str = Field(description="Frontend display name")
|
|
42
|
+
backend_name: str = Field(default="")
|
|
43
|
+
provider: str = Field(default="")
|
|
44
|
+
server_url: str = Field(default="")
|
|
45
|
+
modes: List[str] = Field(default_factory=list)
|
|
46
|
+
capabilities: List[str] = Field(default_factory=list)
|
|
47
|
+
context_window: int = Field(default=0)
|
|
48
|
+
tool_support: bool = Field(default=False)
|
|
49
|
+
vision_support: bool = Field(default=False)
|
|
50
|
+
reasoning_support: bool = Field(default=False)
|
|
51
|
+
streaming_support: bool = Field(default=True)
|
|
52
|
+
status: bool = Field(default=True)
|
|
53
|
+
priority: int = Field(default=0)
|
|
54
|
+
weight: float = Field(default=1.0)
|
|
55
|
+
|
|
56
|
+
model_config = {"protected_namespaces": ()}
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def model_status(self) -> ModelStatus:
|
|
60
|
+
"""Derive ModelStatus from the boolean status field."""
|
|
61
|
+
return ModelStatus.ACTIVE if self.status else ModelStatus.INACTIVE
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def display_name(self) -> str:
|
|
65
|
+
"""Short display name — strip provider prefix for readability."""
|
|
66
|
+
name = self.name
|
|
67
|
+
# e.g. "openai/gpt-oss-20b:free" → "gpt-oss-20b (free)"
|
|
68
|
+
if "/" in name:
|
|
69
|
+
name = name.split("/", 1)[1]
|
|
70
|
+
if ":free" in name:
|
|
71
|
+
name = name.replace(":free", " (free)")
|
|
72
|
+
return name
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ProviderInfo(BaseModel):
|
|
76
|
+
"""Provider status."""
|
|
77
|
+
name: str
|
|
78
|
+
server_url: str = ""
|
|
79
|
+
healthy: bool = True
|
|
80
|
+
models: List[str] = Field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class UsageInfo(BaseModel):
|
|
84
|
+
"""Token usage for a request."""
|
|
85
|
+
input_tokens: int = 0
|
|
86
|
+
output_tokens: int = 0
|
|
87
|
+
total_tokens: int = 0
|
|
88
|
+
model: str = ""
|
|
89
|
+
latency_ms: float = 0
|
|
90
|
+
|
|
91
|
+
model_config = {"protected_namespaces": ()}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""LLMesh API client for Close Code.
|
|
2
|
+
|
|
3
|
+
Talks only to the LLMesh gateway. Never contacts upstream providers directly.
|
|
4
|
+
All provider authentication is handled server-side.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from typing import Optional, Dict, List, Any, AsyncIterator
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from closecode.llmesh import ModelInfo, ProviderInfo, UsageInfo
|
|
14
|
+
from closecode.llmesh.streaming import parse_sse_stream, StreamError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class APIError(Exception):
|
|
18
|
+
"""Raised when the LLMesh API returns an error."""
|
|
19
|
+
def __init__(self, message: str, status_code: int = 0):
|
|
20
|
+
self.message = message
|
|
21
|
+
self.status_code = status_code
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LLMeshClient:
|
|
26
|
+
"""Async HTTP client for the LLMesh API."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, base_url: str = "http://localhost:8087", api_key: str = ""):
|
|
29
|
+
self.base_url = base_url.rstrip("/")
|
|
30
|
+
self.api_key = api_key
|
|
31
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
32
|
+
|
|
33
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
34
|
+
headers = {"Content-Type": "application/json"}
|
|
35
|
+
if self.api_key:
|
|
36
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
37
|
+
return headers
|
|
38
|
+
|
|
39
|
+
async def _ensure_client(self) -> httpx.AsyncClient:
|
|
40
|
+
if self._client is None or self._client.is_closed:
|
|
41
|
+
self._client = httpx.AsyncClient(
|
|
42
|
+
timeout=httpx.Timeout(connect=10.0, read=300.0, write=10.0, pool=10.0),
|
|
43
|
+
headers=self._get_headers(),
|
|
44
|
+
)
|
|
45
|
+
return self._client
|
|
46
|
+
|
|
47
|
+
async def close(self):
|
|
48
|
+
if self._client and not self._client.is_closed:
|
|
49
|
+
await self._client.aclose()
|
|
50
|
+
|
|
51
|
+
# ── Health ──────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
async def verify_connection(self) -> bool:
|
|
54
|
+
"""Verify the LLMesh gateway is reachable and the API key is valid."""
|
|
55
|
+
client = await self._ensure_client()
|
|
56
|
+
try:
|
|
57
|
+
resp = await client.get(f"{self.base_url}/v1/models", timeout=5.0)
|
|
58
|
+
return resp.status_code == 200
|
|
59
|
+
except httpx.HTTPError:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
async def health_check(self) -> bool:
|
|
63
|
+
"""Quick health check."""
|
|
64
|
+
return await self.verify_connection()
|
|
65
|
+
|
|
66
|
+
# ── Models ──────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
async def list_models(self) -> List[ModelInfo]:
|
|
69
|
+
"""Fetch models from LLMesh.
|
|
70
|
+
|
|
71
|
+
Prefers /api/cli/models, which returns every configured model with its
|
|
72
|
+
real admin-set status and full metadata — including models the admin has
|
|
73
|
+
disabled, so they can be shown as unavailable rather than vanishing.
|
|
74
|
+
|
|
75
|
+
Falls back to the OpenAI-compatible /v1/models, which by design lists
|
|
76
|
+
only active models, for gateways that predate the CLI endpoint.
|
|
77
|
+
"""
|
|
78
|
+
client = await self._ensure_client()
|
|
79
|
+
|
|
80
|
+
# Preferred: rich metadata, includes disabled models.
|
|
81
|
+
try:
|
|
82
|
+
resp = await client.get(f"{self.base_url}/api/cli/models")
|
|
83
|
+
if resp.status_code == 200:
|
|
84
|
+
data = resp.json()
|
|
85
|
+
models = [
|
|
86
|
+
ModelInfo(
|
|
87
|
+
name=m.get("name", ""),
|
|
88
|
+
backend_name=m.get("backend_name", "") or m.get("name", ""),
|
|
89
|
+
provider=m.get("provider", ""),
|
|
90
|
+
server_url=m.get("server_url", ""),
|
|
91
|
+
modes=m.get("modes") or [],
|
|
92
|
+
capabilities=m.get("capabilities") or [],
|
|
93
|
+
context_window=m.get("context_window") or 0,
|
|
94
|
+
tool_support=bool(m.get("tool_support", False)),
|
|
95
|
+
vision_support=bool(m.get("vision_support", False)),
|
|
96
|
+
reasoning_support=bool(m.get("reasoning_support", False)),
|
|
97
|
+
streaming_support=bool(m.get("streaming_support", True)),
|
|
98
|
+
status=bool(m.get("status", True)),
|
|
99
|
+
priority=m.get("priority") or 0,
|
|
100
|
+
weight=m.get("weight") or 1.0,
|
|
101
|
+
)
|
|
102
|
+
for m in data.get("models", [])
|
|
103
|
+
if m.get("name")
|
|
104
|
+
]
|
|
105
|
+
if models:
|
|
106
|
+
return models
|
|
107
|
+
except (httpx.HTTPError, json.JSONDecodeError, ValueError):
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
# Fallback: OpenAI-compatible endpoint (active models only).
|
|
111
|
+
try:
|
|
112
|
+
resp = await client.get(f"{self.base_url}/v1/models")
|
|
113
|
+
if resp.status_code == 200:
|
|
114
|
+
data = resp.json()
|
|
115
|
+
models = []
|
|
116
|
+
for m in data.get("data", []):
|
|
117
|
+
model_id = m.get("id", "")
|
|
118
|
+
provider = m.get("owned_by", "")
|
|
119
|
+
models.append(ModelInfo(
|
|
120
|
+
name=model_id,
|
|
121
|
+
backend_name=model_id,
|
|
122
|
+
provider=provider,
|
|
123
|
+
status=True, # This endpoint only returns active models
|
|
124
|
+
))
|
|
125
|
+
return models
|
|
126
|
+
except (httpx.HTTPError, json.JSONDecodeError):
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
async def list_providers(self) -> List[ProviderInfo]:
|
|
132
|
+
"""Fetch provider info."""
|
|
133
|
+
client = await self._ensure_client()
|
|
134
|
+
try:
|
|
135
|
+
resp = await client.get(f"{self.base_url}/api/cli/providers")
|
|
136
|
+
if resp.status_code == 200:
|
|
137
|
+
data = resp.json()
|
|
138
|
+
return [ProviderInfo(**p) for p in data.get("providers", [])]
|
|
139
|
+
except (httpx.HTTPError, json.JSONDecodeError):
|
|
140
|
+
pass
|
|
141
|
+
return []
|
|
142
|
+
|
|
143
|
+
# ── Chat ────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
async def chat_stream(
|
|
146
|
+
self,
|
|
147
|
+
messages: List[Dict[str, str]],
|
|
148
|
+
model: str,
|
|
149
|
+
temperature: float = 0.7,
|
|
150
|
+
max_tokens: Optional[int] = None,
|
|
151
|
+
) -> AsyncIterator[str]:
|
|
152
|
+
"""Send a streaming chat completion request. Yields content tokens."""
|
|
153
|
+
client = await self._ensure_client()
|
|
154
|
+
payload: Dict[str, Any] = {
|
|
155
|
+
"model": model,
|
|
156
|
+
"messages": messages,
|
|
157
|
+
"stream": True,
|
|
158
|
+
"stream_options": {"include_usage": True},
|
|
159
|
+
"temperature": temperature,
|
|
160
|
+
}
|
|
161
|
+
if max_tokens:
|
|
162
|
+
payload["max_tokens"] = max_tokens
|
|
163
|
+
|
|
164
|
+
async with client.stream(
|
|
165
|
+
"POST",
|
|
166
|
+
f"{self.base_url}/v1/chat/completions",
|
|
167
|
+
json=payload,
|
|
168
|
+
timeout=httpx.Timeout(connect=10.0, read=300.0, write=10.0, pool=10.0),
|
|
169
|
+
) as response:
|
|
170
|
+
if response.status_code != 200:
|
|
171
|
+
body = await response.aread()
|
|
172
|
+
raise APIError(
|
|
173
|
+
f"Server returned {response.status_code}: {body.decode()}",
|
|
174
|
+
response.status_code,
|
|
175
|
+
)
|
|
176
|
+
async for token in parse_sse_stream(response):
|
|
177
|
+
yield token
|
|
178
|
+
|
|
179
|
+
async def chat(
|
|
180
|
+
self,
|
|
181
|
+
messages: List[Dict[str, str]],
|
|
182
|
+
model: str,
|
|
183
|
+
temperature: float = 0.7,
|
|
184
|
+
max_tokens: Optional[int] = None,
|
|
185
|
+
) -> Dict[str, Any]:
|
|
186
|
+
"""Send a non-streaming chat completion. Returns full response."""
|
|
187
|
+
client = await self._ensure_client()
|
|
188
|
+
payload: Dict[str, Any] = {
|
|
189
|
+
"model": model,
|
|
190
|
+
"messages": messages,
|
|
191
|
+
"stream": False,
|
|
192
|
+
"temperature": temperature,
|
|
193
|
+
}
|
|
194
|
+
if max_tokens:
|
|
195
|
+
payload["max_tokens"] = max_tokens
|
|
196
|
+
|
|
197
|
+
start = time.time()
|
|
198
|
+
resp = await client.post(f"{self.base_url}/v1/chat/completions", json=payload)
|
|
199
|
+
|
|
200
|
+
if resp.status_code != 200:
|
|
201
|
+
raise APIError(f"Server returned {resp.status_code}: {resp.text}", resp.status_code)
|
|
202
|
+
|
|
203
|
+
data = resp.json()
|
|
204
|
+
latency_ms = (time.time() - start) * 1000
|
|
205
|
+
usage = data.get("usage", {})
|
|
206
|
+
|
|
207
|
+
content = ""
|
|
208
|
+
choices = data.get("choices", [])
|
|
209
|
+
if choices:
|
|
210
|
+
content = choices[0].get("message", {}).get("content", "")
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
"content": content,
|
|
214
|
+
"usage": UsageInfo(
|
|
215
|
+
input_tokens=usage.get("prompt_tokens", 0),
|
|
216
|
+
output_tokens=usage.get("completion_tokens", 0),
|
|
217
|
+
total_tokens=usage.get("total_tokens", 0),
|
|
218
|
+
model=model,
|
|
219
|
+
latency_ms=latency_ms,
|
|
220
|
+
),
|
|
221
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""SSE stream parser for OpenAI-compatible streaming responses."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import AsyncIterator
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def parse_sse_stream(response: httpx.Response) -> AsyncIterator[str]:
|
|
10
|
+
"""Parse an SSE stream and yield content tokens.
|
|
11
|
+
|
|
12
|
+
Handles the standard OpenAI streaming format:
|
|
13
|
+
data: {"choices": [{"delta": {"content": "token"}}]}
|
|
14
|
+
data: [DONE]
|
|
15
|
+
"""
|
|
16
|
+
async for line in response.aiter_lines():
|
|
17
|
+
if not line.startswith("data: "):
|
|
18
|
+
continue
|
|
19
|
+
|
|
20
|
+
payload = line[6:].strip()
|
|
21
|
+
|
|
22
|
+
if payload == "[DONE]":
|
|
23
|
+
break
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
chunk = json.loads(payload)
|
|
27
|
+
except json.JSONDecodeError:
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
# Check for error responses
|
|
31
|
+
if "error" in chunk:
|
|
32
|
+
error_msg = chunk["error"].get("message", "Unknown error")
|
|
33
|
+
raise StreamError(error_msg)
|
|
34
|
+
|
|
35
|
+
choices = chunk.get("choices", [])
|
|
36
|
+
if not choices:
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
delta = choices[0].get("delta", {})
|
|
40
|
+
content = delta.get("content", "")
|
|
41
|
+
if content:
|
|
42
|
+
yield content
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class StreamError(Exception):
|
|
46
|
+
"""Raised when the SSE stream contains an error."""
|
|
47
|
+
pass
|
closecode/py.typed
ADDED
|
File without changes
|
closecode/sessions.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Close Code Session Manager — local-only session persistence.
|
|
2
|
+
|
|
3
|
+
Sessions are stored as JSON files in ~/.closecode/sessions/.
|
|
4
|
+
Nothing is sent to the server. Only conversation content and model name are persisted.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List, Optional
|
|
14
|
+
from dataclasses import dataclass, field, asdict
|
|
15
|
+
|
|
16
|
+
from closecode.config import SESSIONS_DIR
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SessionMessage:
|
|
21
|
+
"""A single message in a session."""
|
|
22
|
+
role: str
|
|
23
|
+
content: str
|
|
24
|
+
timestamp: str = ""
|
|
25
|
+
model: str = ""
|
|
26
|
+
|
|
27
|
+
def __post_init__(self):
|
|
28
|
+
if not self.timestamp:
|
|
29
|
+
self.timestamp = datetime.now(timezone.utc).isoformat()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Session:
|
|
34
|
+
"""A saved conversation session."""
|
|
35
|
+
id: str = ""
|
|
36
|
+
title: str = "New session"
|
|
37
|
+
model: str = ""
|
|
38
|
+
created_at: str = ""
|
|
39
|
+
updated_at: str = ""
|
|
40
|
+
messages: List[SessionMessage] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
def __post_init__(self):
|
|
43
|
+
if not self.id:
|
|
44
|
+
self.id = uuid.uuid4().hex[:12]
|
|
45
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
46
|
+
if not self.created_at:
|
|
47
|
+
self.created_at = now
|
|
48
|
+
if not self.updated_at:
|
|
49
|
+
self.updated_at = now
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def message_count(self) -> int:
|
|
53
|
+
return len(self.messages)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def preview(self) -> str:
|
|
57
|
+
"""First user message as a preview, truncated."""
|
|
58
|
+
for msg in self.messages:
|
|
59
|
+
if msg.role == "user":
|
|
60
|
+
text = msg.content.strip().replace("\n", " ")
|
|
61
|
+
return text[:60] + ("…" if len(text) > 60 else "")
|
|
62
|
+
return "Empty session"
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def age_label(self) -> str:
|
|
66
|
+
"""Human-readable age like '2 hours ago'."""
|
|
67
|
+
try:
|
|
68
|
+
updated = datetime.fromisoformat(self.updated_at)
|
|
69
|
+
now = datetime.now(timezone.utc)
|
|
70
|
+
delta = now - updated
|
|
71
|
+
seconds = delta.total_seconds()
|
|
72
|
+
|
|
73
|
+
if seconds < 60:
|
|
74
|
+
return "just now"
|
|
75
|
+
elif seconds < 3600:
|
|
76
|
+
mins = int(seconds // 60)
|
|
77
|
+
return f"{mins}m ago"
|
|
78
|
+
elif seconds < 86400:
|
|
79
|
+
hours = int(seconds // 3600)
|
|
80
|
+
return f"{hours}h ago"
|
|
81
|
+
elif seconds < 604800:
|
|
82
|
+
days = int(seconds // 86400)
|
|
83
|
+
return f"{days}d ago"
|
|
84
|
+
else:
|
|
85
|
+
return updated.strftime("%b %d")
|
|
86
|
+
except Exception:
|
|
87
|
+
return ""
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict:
|
|
90
|
+
return {
|
|
91
|
+
"id": self.id,
|
|
92
|
+
"title": self.title,
|
|
93
|
+
"model": self.model,
|
|
94
|
+
"created_at": self.created_at,
|
|
95
|
+
"updated_at": self.updated_at,
|
|
96
|
+
"messages": [asdict(m) for m in self.messages],
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def from_dict(cls, data: dict) -> Session:
|
|
101
|
+
msgs = [SessionMessage(**m) for m in data.get("messages", [])]
|
|
102
|
+
return cls(
|
|
103
|
+
id=data.get("id", ""),
|
|
104
|
+
title=data.get("title", "New session"),
|
|
105
|
+
model=data.get("model", ""),
|
|
106
|
+
created_at=data.get("created_at", ""),
|
|
107
|
+
updated_at=data.get("updated_at", ""),
|
|
108
|
+
messages=msgs,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class SessionManager:
|
|
113
|
+
"""Manages local session files in ~/.closecode/sessions/."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, sessions_dir: Optional[Path] = None):
|
|
116
|
+
self.sessions_dir = sessions_dir or SESSIONS_DIR
|
|
117
|
+
self.sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
|
|
119
|
+
def _path(self, session_id: str) -> Path:
|
|
120
|
+
return self.sessions_dir / f"{session_id}.json"
|
|
121
|
+
|
|
122
|
+
def create(self, model: str = "", title: str = "") -> Session:
|
|
123
|
+
"""Create a new session."""
|
|
124
|
+
return Session(model=model, title=title or "New session")
|
|
125
|
+
|
|
126
|
+
def save(self, session: Session) -> None:
|
|
127
|
+
"""Save a session to disk."""
|
|
128
|
+
session.updated_at = datetime.now(timezone.utc).isoformat()
|
|
129
|
+
path = self._path(session.id)
|
|
130
|
+
with open(path, "w") as f:
|
|
131
|
+
json.dump(session.to_dict(), f, indent=2)
|
|
132
|
+
|
|
133
|
+
def load(self, session_id: str) -> Optional[Session]:
|
|
134
|
+
"""Load a session from disk."""
|
|
135
|
+
path = self._path(session_id)
|
|
136
|
+
if not path.exists():
|
|
137
|
+
return None
|
|
138
|
+
try:
|
|
139
|
+
with open(path) as f:
|
|
140
|
+
data = json.load(f)
|
|
141
|
+
return Session.from_dict(data)
|
|
142
|
+
except (json.JSONDecodeError, Exception):
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
def list_sessions(self, limit: int = 20) -> List[Session]:
|
|
146
|
+
"""List all sessions, sorted by most recently updated."""
|
|
147
|
+
sessions = []
|
|
148
|
+
for path in self.sessions_dir.glob("*.json"):
|
|
149
|
+
try:
|
|
150
|
+
with open(path) as f:
|
|
151
|
+
data = json.load(f)
|
|
152
|
+
sessions.append(Session.from_dict(data))
|
|
153
|
+
except (json.JSONDecodeError, Exception):
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
sessions.sort(key=lambda s: s.updated_at, reverse=True)
|
|
157
|
+
return sessions[:limit]
|
|
158
|
+
|
|
159
|
+
def delete(self, session_id: str) -> bool:
|
|
160
|
+
"""Delete a session file."""
|
|
161
|
+
path = self._path(session_id)
|
|
162
|
+
if path.exists():
|
|
163
|
+
path.unlink()
|
|
164
|
+
return True
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def auto_title(messages: List[SessionMessage]) -> str:
|
|
169
|
+
"""Generate a title from the first user message."""
|
|
170
|
+
for msg in messages:
|
|
171
|
+
if msg.role == "user":
|
|
172
|
+
text = msg.content.strip().replace("\n", " ")
|
|
173
|
+
if len(text) > 50:
|
|
174
|
+
return text[:47] + "…"
|
|
175
|
+
return text
|
|
176
|
+
return "New session"
|
closecode/ui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Close Code UI package."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Close Code UI screens package."""
|