comodor 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.
- comodor/__init__.py +18 -0
- comodor/__main__.py +6 -0
- comodor/agent/__init__.py +9 -0
- comodor/agent/context.py +149 -0
- comodor/agent/loop.py +409 -0
- comodor/agent/prompts.py +209 -0
- comodor/agent/tokens.py +131 -0
- comodor/cli.py +295 -0
- comodor/config.py +394 -0
- comodor/events.py +211 -0
- comodor/learning/__init__.py +8 -0
- comodor/learning/bm25.py +147 -0
- comodor/learning/hotindex.py +228 -0
- comodor/learning/memory.py +450 -0
- comodor/learning/progress.py +184 -0
- comodor/learning/reflect.py +152 -0
- comodor/learning/rules.py +418 -0
- comodor/learning/signals.py +310 -0
- comodor/learning/store.py +992 -0
- comodor/learning/writer.py +185 -0
- comodor/net/__init__.py +11 -0
- comodor/net/http.py +2036 -0
- comodor/net/sse.py +113 -0
- comodor/paths.py +120 -0
- comodor/providers/__init__.py +24 -0
- comodor/providers/anthropic.py +267 -0
- comodor/providers/base.py +266 -0
- comodor/providers/fake.py +136 -0
- comodor/providers/gateway.py +275 -0
- comodor/providers/openai_compat.py +281 -0
- comodor/providers/registry.py +178 -0
- comodor/safety/__init__.py +11 -0
- comodor/safety/checkpoints.py +232 -0
- comodor/safety/permissions.py +199 -0
- comodor/safety/redact.py +92 -0
- comodor/session/__init__.py +5 -0
- comodor/session/store.py +203 -0
- comodor/tools/__init__.py +7 -0
- comodor/tools/base.py +173 -0
- comodor/tools/fs.py +314 -0
- comodor/tools/registry.py +85 -0
- comodor/tools/search.py +252 -0
- comodor/tools/shell.py +234 -0
- comodor/tools/todo.py +93 -0
- comodor/tools/web.py +174 -0
- comodor/ui/__init__.py +8 -0
- comodor/ui/app.py +1159 -0
- comodor/ui/console.py +125 -0
- comodor/ui/input/__init__.py +15 -0
- comodor/ui/input/keys.py +322 -0
- comodor/ui/input/reader.py +288 -0
- comodor/ui/layout.py +189 -0
- comodor/ui/markdown.py +76 -0
- comodor/ui/screen.py +185 -0
- comodor/ui/theme.py +251 -0
- comodor/ui/widgets/__init__.py +21 -0
- comodor/ui/widgets/buttons.py +108 -0
- comodor/ui/widgets/chat.py +236 -0
- comodor/ui/widgets/history.py +131 -0
- comodor/ui/widgets/overlay.py +246 -0
- comodor/ui/widgets/panel.py +91 -0
- comodor/ui/widgets/progress.py +143 -0
- comodor/ui/widgets/prompt.py +310 -0
- comodor/ui/widgets/statusbar.py +193 -0
- comodor/ui/widgets/toast.py +67 -0
- comodor-0.1.0.dist-info/METADATA +313 -0
- comodor-0.1.0.dist-info/RECORD +70 -0
- comodor-0.1.0.dist-info/WHEEL +4 -0
- comodor-0.1.0.dist-info/entry_points.txt +2 -0
- comodor-0.1.0.dist-info/licenses/LICENSE +21 -0
comodor/config.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Layered configuration.
|
|
2
|
+
|
|
3
|
+
Resolution order, later layers winning:
|
|
4
|
+
|
|
5
|
+
1. built-in defaults (this module)
|
|
6
|
+
2. ~/.comodor/config.toml user preferences
|
|
7
|
+
3. ./.comodor/config.toml project overrides, safe to commit
|
|
8
|
+
4. .env files + process environment credentials
|
|
9
|
+
5. CLI flags one-off overrides
|
|
10
|
+
|
|
11
|
+
Credentials deliberately live only in layer 4. Nothing that resolves an API key
|
|
12
|
+
is ever written back to a TOML file, so a project config can be committed to a
|
|
13
|
+
repository without leaking anything.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import tomllib
|
|
20
|
+
from dataclasses import dataclass, field, fields, is_dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from .paths import Paths, resolve as resolve_paths
|
|
25
|
+
|
|
26
|
+
# --------------------------------------------------------------------------- #
|
|
27
|
+
# provider catalogue
|
|
28
|
+
# --------------------------------------------------------------------------- #
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ProviderConfig:
|
|
33
|
+
"""One reachable LLM backend.
|
|
34
|
+
|
|
35
|
+
``kind`` selects the wire protocol: ``openai`` covers OpenRouter, Xiaomi
|
|
36
|
+
MiMo, DeepSeek, Groq, Together, Ollama and LM Studio — they differ only by
|
|
37
|
+
base URL and headers — while ``anthropic`` speaks the native Messages API.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
kind: str = "openai" # "openai" | "anthropic" | "fake"
|
|
42
|
+
base_url: str = ""
|
|
43
|
+
api_key: str = ""
|
|
44
|
+
model: str = ""
|
|
45
|
+
label: str = "" # what the status bar shows
|
|
46
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
47
|
+
enabled: bool = True
|
|
48
|
+
timeout: float = 120.0
|
|
49
|
+
# True when the user actually said something about this provider — a key, an
|
|
50
|
+
# endpoint, or a config entry. Local runtimes are "ready" by default because
|
|
51
|
+
# they need no key, so without this signal Comodor would happily auto-select
|
|
52
|
+
# an Ollama that is not running and fail with a connection error.
|
|
53
|
+
explicit: bool = False
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def display(self) -> str:
|
|
57
|
+
return self.label or self.name.title()
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def local(self) -> bool:
|
|
61
|
+
return any(host in self.base_url for host in ("localhost", "127.0.0.1", "0.0.0.0"))
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def ready(self) -> bool:
|
|
65
|
+
"""Local runtimes need no key; hosted ones do."""
|
|
66
|
+
return bool(self.base_url) and (bool(self.api_key) or self.local)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def selectable(self) -> bool:
|
|
70
|
+
"""Whether Comodor may choose this provider on the user's behalf."""
|
|
71
|
+
return self.enabled and self.ready and (not self.local or self.explicit)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# name -> (label, kind, default base url, env var holding the key, default model)
|
|
75
|
+
KNOWN_PROVIDERS: dict[str, tuple[str, str, str, str, str]] = {
|
|
76
|
+
"openrouter": ("Openrouter", "openai", "https://openrouter.ai/api/v1",
|
|
77
|
+
"OPENROUTER_API_KEY", "anthropic/claude-sonnet-4.5"),
|
|
78
|
+
"anthropic": ("Anthropic", "anthropic", "https://api.anthropic.com/v1",
|
|
79
|
+
"ANTHROPIC_API_KEY", "claude-sonnet-4-5"),
|
|
80
|
+
"openai": ("OpenAI", "openai", "https://api.openai.com/v1",
|
|
81
|
+
"OPENAI_API_KEY", "gpt-4o"),
|
|
82
|
+
"xiaomi": ("Xiaomi MiMo", "openai", "https://token-plan-sgp.xiaomimimo.com/v1",
|
|
83
|
+
"XIAOMI_API_KEY", "mimo-v2.5-pro"),
|
|
84
|
+
"deepseek": ("DeepSeek", "openai", "https://api.deepseek.com/v1",
|
|
85
|
+
"DEEPSEEK_API_KEY", "deepseek-chat"),
|
|
86
|
+
"groq": ("Groq", "openai", "https://api.groq.com/openai/v1",
|
|
87
|
+
"GROQ_API_KEY", "llama-3.3-70b-versatile"),
|
|
88
|
+
"together": ("Together", "openai", "https://api.together.xyz/v1",
|
|
89
|
+
"TOGETHER_API_KEY", "meta-llama/Llama-3.3-70B-Instruct-Turbo"),
|
|
90
|
+
"ollama": ("Ollama", "openai", "http://localhost:11434/v1",
|
|
91
|
+
"OLLAMA_API_KEY", "llama3.2"),
|
|
92
|
+
"lmstudio": ("LM Studio", "openai", "http://localhost:1234/v1",
|
|
93
|
+
"LMSTUDIO_API_KEY", "local-model"),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# --------------------------------------------------------------------------- #
|
|
97
|
+
# sections
|
|
98
|
+
# --------------------------------------------------------------------------- #
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class UIConfig:
|
|
103
|
+
theme: str = "ember"
|
|
104
|
+
ascii_borders: bool = False # for terminals without box-drawing glyphs
|
|
105
|
+
mouse: bool = True
|
|
106
|
+
max_fps: int = 20 # streaming deltas are coalesced to this
|
|
107
|
+
show_timestamps: bool = False
|
|
108
|
+
sidebar: bool = True
|
|
109
|
+
syntax_theme: str = "monokai"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class AgentConfig:
|
|
114
|
+
mode: str = "act" # act | plan | chat
|
|
115
|
+
loop: bool = True # autonomous multi-step iteration
|
|
116
|
+
max_steps: int = 24 # hard stop for one task
|
|
117
|
+
max_seconds: float = 900.0
|
|
118
|
+
max_cost_usd: float = 2.0
|
|
119
|
+
context_limit: int = 1_000_000 # the "Context: 1M" gauge
|
|
120
|
+
compact_at: float = 0.75 # summarise history past this fraction
|
|
121
|
+
temperature: float = 0.3
|
|
122
|
+
max_output_tokens: int = 8192
|
|
123
|
+
system_prompt_extra: str = ""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class GatewayConfig:
|
|
128
|
+
"""The GW indicator: route across providers instead of pinning one."""
|
|
129
|
+
|
|
130
|
+
enabled: bool = False # matches the mockup's "GW: Disable"
|
|
131
|
+
policy: str = "quality" # cost | speed | quality
|
|
132
|
+
chain: list[str] = field(default_factory=list) # explicit failover order
|
|
133
|
+
failure_threshold: int = 3 # errors before a provider is tripped
|
|
134
|
+
cooldown_seconds: float = 60.0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class LearningConfig:
|
|
139
|
+
enabled: bool = True
|
|
140
|
+
top_k: int = 6 # lessons recalled per turn
|
|
141
|
+
max_playbook_tokens: int = 800 # hard cap on injected memory
|
|
142
|
+
reflect: bool = True # distil lessons after each task
|
|
143
|
+
reflect_model: str = "" # blank = the active model
|
|
144
|
+
min_confidence: float = 0.15 # below this a lesson is pruned
|
|
145
|
+
half_life_days: float = 45.0 # confidence decay
|
|
146
|
+
share_scope: str = "project" # project | global — where new lessons land
|
|
147
|
+
|
|
148
|
+
# Reflex — the fast lane: deterministic, model-free, always on. It costs no
|
|
149
|
+
# tokens, so it stays enabled even when reflection is switched off.
|
|
150
|
+
corrections: bool = True # learn from edits, undos and denials
|
|
151
|
+
rules: bool = True # keep a house-rules block in the prompt
|
|
152
|
+
announce: bool = True # say so in the transcript when a rule lands
|
|
153
|
+
prefetch: bool = True # warm recall while the user is typing
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class SafetyConfig:
|
|
158
|
+
auto_approve_safe: bool = True # read-only tools never prompt
|
|
159
|
+
auto_approve_writes: bool = False
|
|
160
|
+
auto_approve_shell: bool = False
|
|
161
|
+
checkpoints: bool = True # snapshot files before mutating them
|
|
162
|
+
allow_commands: list[str] = field(default_factory=list)
|
|
163
|
+
deny_commands: list[str] = field(default_factory=lambda: list(DEFAULT_DENY))
|
|
164
|
+
workspace_only: bool = True # refuse file writes outside the project
|
|
165
|
+
max_file_read_bytes: int = 512_000
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# Patterns that are never worth running from an agent, however it is prompted.
|
|
169
|
+
DEFAULT_DENY: tuple[str, ...] = (
|
|
170
|
+
"rm -rf /",
|
|
171
|
+
"rm -rf ~",
|
|
172
|
+
"mkfs",
|
|
173
|
+
"dd if=",
|
|
174
|
+
"shutdown",
|
|
175
|
+
"reboot",
|
|
176
|
+
"format c:",
|
|
177
|
+
"del /f /s /q c:",
|
|
178
|
+
":(){", # fork bomb
|
|
179
|
+
"> /dev/sda",
|
|
180
|
+
"chmod -R 777 /",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass
|
|
185
|
+
class Config:
|
|
186
|
+
provider: str = "openrouter"
|
|
187
|
+
model: str = ""
|
|
188
|
+
ui: UIConfig = field(default_factory=UIConfig)
|
|
189
|
+
agent: AgentConfig = field(default_factory=AgentConfig)
|
|
190
|
+
gateway: GatewayConfig = field(default_factory=GatewayConfig)
|
|
191
|
+
learning: LearningConfig = field(default_factory=LearningConfig)
|
|
192
|
+
safety: SafetyConfig = field(default_factory=SafetyConfig)
|
|
193
|
+
providers: dict[str, ProviderConfig] = field(default_factory=dict)
|
|
194
|
+
paths: Paths = field(default_factory=resolve_paths)
|
|
195
|
+
|
|
196
|
+
# -- helpers ---------------------------------------------------------- #
|
|
197
|
+
|
|
198
|
+
def active(self) -> ProviderConfig | None:
|
|
199
|
+
return self.providers.get(self.provider)
|
|
200
|
+
|
|
201
|
+
def available(self) -> list[ProviderConfig]:
|
|
202
|
+
"""Providers Comodor is willing to use without being told to."""
|
|
203
|
+
return [p for p in self.providers.values() if p.selectable]
|
|
204
|
+
|
|
205
|
+
def active_model(self) -> str:
|
|
206
|
+
if self.model:
|
|
207
|
+
return self.model
|
|
208
|
+
provider = self.active()
|
|
209
|
+
return provider.model if provider else ""
|
|
210
|
+
|
|
211
|
+
def to_public_dict(self) -> dict[str, Any]:
|
|
212
|
+
"""Config with every secret removed — safe to display or export."""
|
|
213
|
+
data = _as_dict(self)
|
|
214
|
+
for entry in data.get("providers", {}).values():
|
|
215
|
+
if entry.get("api_key"):
|
|
216
|
+
entry["api_key"] = "***"
|
|
217
|
+
entry.pop("headers", None)
|
|
218
|
+
data.pop("paths", None)
|
|
219
|
+
return data
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# --------------------------------------------------------------------------- #
|
|
223
|
+
# loading
|
|
224
|
+
# --------------------------------------------------------------------------- #
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _as_dict(obj: Any) -> Any:
|
|
228
|
+
if is_dataclass(obj) and not isinstance(obj, type):
|
|
229
|
+
return {f.name: _as_dict(getattr(obj, f.name)) for f in fields(obj)}
|
|
230
|
+
if isinstance(obj, dict):
|
|
231
|
+
return {k: _as_dict(v) for k, v in obj.items()}
|
|
232
|
+
if isinstance(obj, (list, tuple)):
|
|
233
|
+
return [_as_dict(v) for v in obj]
|
|
234
|
+
if isinstance(obj, Path):
|
|
235
|
+
return str(obj)
|
|
236
|
+
return obj
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _apply(section: Any, values: dict[str, Any]) -> None:
|
|
240
|
+
"""Copy known keys onto a dataclass, ignoring unknown ones.
|
|
241
|
+
|
|
242
|
+
Unknown keys are tolerated rather than fatal: a config written by a newer
|
|
243
|
+
Comodor should not stop an older one from starting.
|
|
244
|
+
"""
|
|
245
|
+
valid = {f.name for f in fields(section)}
|
|
246
|
+
for key, value in values.items():
|
|
247
|
+
if key not in valid:
|
|
248
|
+
continue
|
|
249
|
+
current = getattr(section, key)
|
|
250
|
+
if is_dataclass(current) and isinstance(value, dict):
|
|
251
|
+
_apply(current, value)
|
|
252
|
+
else:
|
|
253
|
+
setattr(section, key, value)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _load_toml(path: Path) -> dict[str, Any]:
|
|
257
|
+
try:
|
|
258
|
+
with path.open("rb") as handle:
|
|
259
|
+
return tomllib.load(handle)
|
|
260
|
+
except FileNotFoundError:
|
|
261
|
+
return {}
|
|
262
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
263
|
+
# A broken config must never prevent startup; defaults carry the run.
|
|
264
|
+
return {}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _load_env_files(paths: Paths) -> None:
|
|
268
|
+
"""Load .env files from every place a user might reasonably put one.
|
|
269
|
+
|
|
270
|
+
Earlier files win, matching the "closest to me" intuition: a key in the
|
|
271
|
+
project's .env should beat one in the home directory.
|
|
272
|
+
"""
|
|
273
|
+
try:
|
|
274
|
+
from dotenv import load_dotenv
|
|
275
|
+
except ImportError:
|
|
276
|
+
return
|
|
277
|
+
candidates = [
|
|
278
|
+
Path.cwd() / ".env",
|
|
279
|
+
Path.cwd() / "src" / ".env",
|
|
280
|
+
paths.project / ".env",
|
|
281
|
+
paths.project / "src" / ".env",
|
|
282
|
+
paths.user / ".env",
|
|
283
|
+
]
|
|
284
|
+
seen: set[Path] = set()
|
|
285
|
+
for candidate in candidates:
|
|
286
|
+
resolved = candidate.expanduser()
|
|
287
|
+
if resolved in seen or not resolved.is_file():
|
|
288
|
+
continue
|
|
289
|
+
seen.add(resolved)
|
|
290
|
+
load_dotenv(resolved, override=False)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _build_providers(overrides: dict[str, Any]) -> dict[str, ProviderConfig]:
|
|
294
|
+
"""Materialise the catalogue, folding in env vars and TOML overrides."""
|
|
295
|
+
providers: dict[str, ProviderConfig] = {}
|
|
296
|
+
for name, (label, kind, base_url, key_env, model) in KNOWN_PROVIDERS.items():
|
|
297
|
+
prefix = name.upper()
|
|
298
|
+
endpoint = (os.environ.get(f"{prefix}_ENDPOINT")
|
|
299
|
+
or os.environ.get(f"{prefix}_BASE_URL"))
|
|
300
|
+
api_key = os.environ.get(key_env, "").strip()
|
|
301
|
+
providers[name] = ProviderConfig(
|
|
302
|
+
name=name,
|
|
303
|
+
kind=kind,
|
|
304
|
+
label=label,
|
|
305
|
+
base_url=endpoint or base_url,
|
|
306
|
+
api_key=api_key,
|
|
307
|
+
model=os.environ.get(f"{prefix}_MODEL") or model,
|
|
308
|
+
explicit=bool(endpoint or api_key),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# TOML may add entirely new providers (any OpenAI-compatible endpoint).
|
|
312
|
+
for name, values in (overrides or {}).items():
|
|
313
|
+
if not isinstance(values, dict):
|
|
314
|
+
continue
|
|
315
|
+
entry = providers.get(name) or ProviderConfig(name=name)
|
|
316
|
+
_apply(entry, values)
|
|
317
|
+
if not entry.api_key and values.get("api_key_env"):
|
|
318
|
+
entry.api_key = os.environ.get(str(values["api_key_env"]), "").strip()
|
|
319
|
+
entry.explicit = True # naming it in a config file is intent
|
|
320
|
+
providers[name] = entry
|
|
321
|
+
return providers
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def load(cwd: Path | str | None = None, overrides: dict[str, Any] | None = None) -> Config:
|
|
325
|
+
"""Resolve the full configuration for one run."""
|
|
326
|
+
paths = resolve_paths(cwd).ensure()
|
|
327
|
+
_load_env_files(paths)
|
|
328
|
+
|
|
329
|
+
merged: dict[str, Any] = {}
|
|
330
|
+
for source in (_load_toml(paths.config_file), _load_toml(paths.project_config_file)):
|
|
331
|
+
for key, value in source.items():
|
|
332
|
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
333
|
+
merged[key].update(value)
|
|
334
|
+
else:
|
|
335
|
+
merged[key] = value
|
|
336
|
+
|
|
337
|
+
config = Config(paths=paths)
|
|
338
|
+
config.providers = _build_providers(merged.get("providers", {}))
|
|
339
|
+
_apply(config, {k: v for k, v in merged.items() if k != "providers"})
|
|
340
|
+
|
|
341
|
+
if overrides:
|
|
342
|
+
_apply(config, overrides)
|
|
343
|
+
|
|
344
|
+
# Pick a sensible default provider: honour the configured choice when it is
|
|
345
|
+
# usable, otherwise fall back to whichever provider the user has actually
|
|
346
|
+
# set up. Local runtimes only qualify if they were named explicitly —
|
|
347
|
+
# otherwise a machine with no keys would silently select an Ollama that is
|
|
348
|
+
# not running and fail with a connection error instead of a useful one.
|
|
349
|
+
active = config.providers.get(config.provider)
|
|
350
|
+
if active is None or not active.ready:
|
|
351
|
+
for candidate in ("openrouter", "anthropic", "openai", "xiaomi",
|
|
352
|
+
"deepseek", "groq", "together", "ollama", "lmstudio"):
|
|
353
|
+
entry = config.providers.get(candidate)
|
|
354
|
+
if entry and entry.selectable:
|
|
355
|
+
config.provider = candidate
|
|
356
|
+
config.model = "" # the old model belonged to the old provider
|
|
357
|
+
break
|
|
358
|
+
if not config.model:
|
|
359
|
+
active = config.active()
|
|
360
|
+
config.model = active.model if active else ""
|
|
361
|
+
return config
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def save_user_config(config: Config) -> Path:
|
|
365
|
+
"""Persist the non-secret parts of the config to the user TOML file."""
|
|
366
|
+
data = config.to_public_dict()
|
|
367
|
+
data.pop("providers", None) # provider keys stay in the environment
|
|
368
|
+
target = config.paths.config_file
|
|
369
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
370
|
+
target.write_text(_dump_toml(data), encoding="utf-8")
|
|
371
|
+
return target
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _dump_toml(data: dict[str, Any], prefix: str = "") -> str:
|
|
375
|
+
"""Minimal TOML writer — enough for our flat, typed config tree."""
|
|
376
|
+
scalars: list[str] = []
|
|
377
|
+
tables: list[str] = []
|
|
378
|
+
for key, value in data.items():
|
|
379
|
+
if isinstance(value, dict):
|
|
380
|
+
tables.append(f"\n[{prefix}{key}]\n" + _dump_toml(value, f"{prefix}{key}."))
|
|
381
|
+
else:
|
|
382
|
+
scalars.append(f"{key} = {_toml_value(value)}")
|
|
383
|
+
return "\n".join(scalars) + ("\n" if scalars else "") + "".join(tables)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _toml_value(value: Any) -> str:
|
|
387
|
+
if isinstance(value, bool):
|
|
388
|
+
return "true" if value else "false"
|
|
389
|
+
if isinstance(value, (int, float)):
|
|
390
|
+
return str(value)
|
|
391
|
+
if isinstance(value, (list, tuple)):
|
|
392
|
+
return "[" + ", ".join(_toml_value(v) for v in value) + "]"
|
|
393
|
+
escaped = str(value).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
|
394
|
+
return f'"{escaped}"'
|
comodor/events.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""The one channel between the agent worker and the UI thread.
|
|
2
|
+
|
|
3
|
+
The agent runs in a background thread; Rich renders from the main thread. They
|
|
4
|
+
share no mutable state — the worker only *emits* events and the UI only *reads*
|
|
5
|
+
them. That rule is what keeps the interface responsive while a tool is running
|
|
6
|
+
and makes the whole agent reusable headlessly: swap the subscriber and the same
|
|
7
|
+
loop drives a JSON stream instead of a terminal.
|
|
8
|
+
|
|
9
|
+
One flow needs to go the other way — asking the user to approve a tool call —
|
|
10
|
+
so :class:`Request` carries its own reply slot. The worker blocks on it while
|
|
11
|
+
the UI keeps painting.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import queue
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from typing import Any, Callable, Iterator
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Kind(str, Enum):
|
|
25
|
+
"""Every event the agent can emit."""
|
|
26
|
+
|
|
27
|
+
# conversation
|
|
28
|
+
USER_MESSAGE = "user_message"
|
|
29
|
+
ASSISTANT_START = "assistant_start"
|
|
30
|
+
ASSISTANT_DELTA = "assistant_delta" # streamed token(s)
|
|
31
|
+
ASSISTANT_END = "assistant_end"
|
|
32
|
+
REASONING_DELTA = "reasoning_delta" # extended-thinking stream
|
|
33
|
+
|
|
34
|
+
# tools
|
|
35
|
+
TOOL_START = "tool_start"
|
|
36
|
+
TOOL_OUTPUT = "tool_output" # incremental output (shell)
|
|
37
|
+
TOOL_END = "tool_end"
|
|
38
|
+
|
|
39
|
+
# lifecycle
|
|
40
|
+
TURN_START = "turn_start"
|
|
41
|
+
TURN_END = "turn_end"
|
|
42
|
+
STEP = "step" # one loop iteration completed
|
|
43
|
+
CANCELLED = "cancelled"
|
|
44
|
+
|
|
45
|
+
# side channels
|
|
46
|
+
STATUS = "status" # provider/model/connection
|
|
47
|
+
USAGE = "usage" # tokens + cost + context fill
|
|
48
|
+
MEMORY = "memory" # lessons recalled or learned
|
|
49
|
+
TODO = "todo" # task list changed
|
|
50
|
+
NOTICE = "notice" # transient toast
|
|
51
|
+
ERROR = "error"
|
|
52
|
+
REQUEST = "request" # needs an answer from the user
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(slots=True)
|
|
56
|
+
class Event:
|
|
57
|
+
kind: Kind
|
|
58
|
+
payload: dict[str, Any] = field(default_factory=dict)
|
|
59
|
+
at: float = field(default_factory=time.time)
|
|
60
|
+
|
|
61
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
62
|
+
return self.payload.get(key, default)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def text(self) -> str:
|
|
66
|
+
return str(self.payload.get("text", ""))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Request:
|
|
71
|
+
"""A question the worker needs answered before it can continue.
|
|
72
|
+
|
|
73
|
+
Used for permission prompts. The worker calls :meth:`wait`; the UI renders
|
|
74
|
+
the dialog and calls :meth:`answer`. A timeout means "denied", so a
|
|
75
|
+
forgotten prompt can never wedge the agent forever.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
id: str
|
|
79
|
+
prompt: str
|
|
80
|
+
options: list[str]
|
|
81
|
+
detail: str = ""
|
|
82
|
+
kind: str = "permission"
|
|
83
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
84
|
+
_done: threading.Event = field(default_factory=threading.Event, repr=False)
|
|
85
|
+
_answer: str | None = field(default=None, repr=False)
|
|
86
|
+
|
|
87
|
+
def answer(self, choice: str) -> None:
|
|
88
|
+
self._answer = choice
|
|
89
|
+
self._done.set()
|
|
90
|
+
|
|
91
|
+
def wait(self, timeout: float | None = None) -> str:
|
|
92
|
+
if self._done.wait(timeout):
|
|
93
|
+
return self._answer or (self.options[-1] if self.options else "no")
|
|
94
|
+
return self.options[-1] if self.options else "no"
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def answered(self) -> bool:
|
|
98
|
+
return self._done.is_set()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
Subscriber = Callable[[Event], None]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class EventBus:
|
|
105
|
+
"""Fan-out queue: every subscriber sees every event, in order.
|
|
106
|
+
|
|
107
|
+
Subscribers are called on the emitting thread, so they must be cheap — the
|
|
108
|
+
UI subscriber just appends to its own queue and returns.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self) -> None:
|
|
112
|
+
self._subscribers: list[Subscriber] = []
|
|
113
|
+
self._lock = threading.Lock()
|
|
114
|
+
self._closed = False
|
|
115
|
+
|
|
116
|
+
def subscribe(self, subscriber: Subscriber) -> Callable[[], None]:
|
|
117
|
+
with self._lock:
|
|
118
|
+
self._subscribers.append(subscriber)
|
|
119
|
+
|
|
120
|
+
def unsubscribe() -> None:
|
|
121
|
+
with self._lock:
|
|
122
|
+
if subscriber in self._subscribers:
|
|
123
|
+
self._subscribers.remove(subscriber)
|
|
124
|
+
|
|
125
|
+
return unsubscribe
|
|
126
|
+
|
|
127
|
+
def emit(self, kind: Kind, **payload: Any) -> Event:
|
|
128
|
+
event = Event(kind=kind, payload=payload)
|
|
129
|
+
self.publish(event)
|
|
130
|
+
return event
|
|
131
|
+
|
|
132
|
+
def publish(self, event: Event) -> None:
|
|
133
|
+
with self._lock:
|
|
134
|
+
if self._closed:
|
|
135
|
+
return
|
|
136
|
+
targets = list(self._subscribers)
|
|
137
|
+
for subscriber in targets:
|
|
138
|
+
try:
|
|
139
|
+
subscriber(event)
|
|
140
|
+
except Exception:
|
|
141
|
+
# A broken subscriber must never take down the agent mid-task.
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
def ask(self, request: Request) -> Request:
|
|
145
|
+
"""Emit a question and hand the caller the object to wait on."""
|
|
146
|
+
self.publish(Event(kind=Kind.REQUEST, payload={"request": request}))
|
|
147
|
+
return request
|
|
148
|
+
|
|
149
|
+
def close(self) -> None:
|
|
150
|
+
with self._lock:
|
|
151
|
+
self._closed = True
|
|
152
|
+
self._subscribers.clear()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class EventQueue:
|
|
156
|
+
"""A subscriber that buffers events for a consumer to drain at its own pace.
|
|
157
|
+
|
|
158
|
+
The UI drains this once per frame: rendering never blocks the agent, and a
|
|
159
|
+
burst of stream deltas collapses into a single repaint.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def __init__(self, bus: EventBus, maxsize: int = 0) -> None:
|
|
163
|
+
self._queue: queue.Queue[Event] = queue.Queue(maxsize)
|
|
164
|
+
self._unsubscribe = bus.subscribe(self._queue.put)
|
|
165
|
+
|
|
166
|
+
def drain(self, limit: int = 512) -> list[Event]:
|
|
167
|
+
events: list[Event] = []
|
|
168
|
+
for _ in range(limit):
|
|
169
|
+
try:
|
|
170
|
+
events.append(self._queue.get_nowait())
|
|
171
|
+
except queue.Empty:
|
|
172
|
+
break
|
|
173
|
+
return events
|
|
174
|
+
|
|
175
|
+
def wait(self, timeout: float) -> Event | None:
|
|
176
|
+
try:
|
|
177
|
+
return self._queue.get(timeout=timeout)
|
|
178
|
+
except queue.Empty:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
def __iter__(self) -> Iterator[Event]:
|
|
182
|
+
while True:
|
|
183
|
+
yield self._queue.get()
|
|
184
|
+
|
|
185
|
+
def close(self) -> None:
|
|
186
|
+
self._unsubscribe()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class Cancellation:
|
|
190
|
+
"""Cooperative cancellation, checked between agent steps and stream chunks."""
|
|
191
|
+
|
|
192
|
+
def __init__(self) -> None:
|
|
193
|
+
self._flag = threading.Event()
|
|
194
|
+
|
|
195
|
+
def cancel(self) -> None:
|
|
196
|
+
self._flag.set()
|
|
197
|
+
|
|
198
|
+
def reset(self) -> None:
|
|
199
|
+
self._flag.clear()
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def cancelled(self) -> bool:
|
|
203
|
+
return self._flag.is_set()
|
|
204
|
+
|
|
205
|
+
def raise_if_cancelled(self) -> None:
|
|
206
|
+
if self._flag.is_set():
|
|
207
|
+
raise Cancelled()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class Cancelled(Exception):
|
|
211
|
+
"""Raised inside the worker when the user pressed Esc."""
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""The persistent brain: recall, reinforcement, reflection, consolidation."""
|
|
2
|
+
|
|
3
|
+
from .bm25 import BM25Index, similarity, tokenize
|
|
4
|
+
from .memory import LearningEngine
|
|
5
|
+
from .store import BrainStore, Episode, Lesson, Skill
|
|
6
|
+
|
|
7
|
+
__all__ = ["LearningEngine", "BrainStore", "Lesson", "Skill", "Episode",
|
|
8
|
+
"BM25Index", "tokenize", "similarity"]
|