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.
Files changed (70) hide show
  1. comodor/__init__.py +18 -0
  2. comodor/__main__.py +6 -0
  3. comodor/agent/__init__.py +9 -0
  4. comodor/agent/context.py +149 -0
  5. comodor/agent/loop.py +409 -0
  6. comodor/agent/prompts.py +209 -0
  7. comodor/agent/tokens.py +131 -0
  8. comodor/cli.py +295 -0
  9. comodor/config.py +394 -0
  10. comodor/events.py +211 -0
  11. comodor/learning/__init__.py +8 -0
  12. comodor/learning/bm25.py +147 -0
  13. comodor/learning/hotindex.py +228 -0
  14. comodor/learning/memory.py +450 -0
  15. comodor/learning/progress.py +184 -0
  16. comodor/learning/reflect.py +152 -0
  17. comodor/learning/rules.py +418 -0
  18. comodor/learning/signals.py +310 -0
  19. comodor/learning/store.py +992 -0
  20. comodor/learning/writer.py +185 -0
  21. comodor/net/__init__.py +11 -0
  22. comodor/net/http.py +2036 -0
  23. comodor/net/sse.py +113 -0
  24. comodor/paths.py +120 -0
  25. comodor/providers/__init__.py +24 -0
  26. comodor/providers/anthropic.py +267 -0
  27. comodor/providers/base.py +266 -0
  28. comodor/providers/fake.py +136 -0
  29. comodor/providers/gateway.py +275 -0
  30. comodor/providers/openai_compat.py +281 -0
  31. comodor/providers/registry.py +178 -0
  32. comodor/safety/__init__.py +11 -0
  33. comodor/safety/checkpoints.py +232 -0
  34. comodor/safety/permissions.py +199 -0
  35. comodor/safety/redact.py +92 -0
  36. comodor/session/__init__.py +5 -0
  37. comodor/session/store.py +203 -0
  38. comodor/tools/__init__.py +7 -0
  39. comodor/tools/base.py +173 -0
  40. comodor/tools/fs.py +314 -0
  41. comodor/tools/registry.py +85 -0
  42. comodor/tools/search.py +252 -0
  43. comodor/tools/shell.py +234 -0
  44. comodor/tools/todo.py +93 -0
  45. comodor/tools/web.py +174 -0
  46. comodor/ui/__init__.py +8 -0
  47. comodor/ui/app.py +1159 -0
  48. comodor/ui/console.py +125 -0
  49. comodor/ui/input/__init__.py +15 -0
  50. comodor/ui/input/keys.py +322 -0
  51. comodor/ui/input/reader.py +288 -0
  52. comodor/ui/layout.py +189 -0
  53. comodor/ui/markdown.py +76 -0
  54. comodor/ui/screen.py +185 -0
  55. comodor/ui/theme.py +251 -0
  56. comodor/ui/widgets/__init__.py +21 -0
  57. comodor/ui/widgets/buttons.py +108 -0
  58. comodor/ui/widgets/chat.py +236 -0
  59. comodor/ui/widgets/history.py +131 -0
  60. comodor/ui/widgets/overlay.py +246 -0
  61. comodor/ui/widgets/panel.py +91 -0
  62. comodor/ui/widgets/progress.py +143 -0
  63. comodor/ui/widgets/prompt.py +310 -0
  64. comodor/ui/widgets/statusbar.py +193 -0
  65. comodor/ui/widgets/toast.py +67 -0
  66. comodor-0.1.0.dist-info/METADATA +313 -0
  67. comodor-0.1.0.dist-info/RECORD +70 -0
  68. comodor-0.1.0.dist-info/WHEEL +4 -0
  69. comodor-0.1.0.dist-info/entry_points.txt +2 -0
  70. 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"]