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
@@ -0,0 +1,209 @@
1
+ """Assembling the system prompt.
2
+
3
+ The prompt is built in fixed order — identity, environment, mode, playbook —
4
+ because that order is also a caching order: the stable parts come first, so a
5
+ provider's prefix cache keeps hitting even as the recalled playbook changes from
6
+ turn to turn.
7
+
8
+ Everything the model is told about *how to behave* lives here. Everything it is
9
+ told about *what it has learned* is injected as a separate playbook block, kept
10
+ visibly distinct so a bad lesson can be traced and deleted rather than quietly
11
+ shaping every answer.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import platform
18
+ import sys
19
+ from datetime import date
20
+ from pathlib import Path
21
+
22
+ from ..config import Config
23
+
24
+ IDENTITY = """\
25
+ You are Comodor, a terminal coding agent. You work inside the user's project, \
26
+ with real tools, on a real filesystem.
27
+
28
+ How you work:
29
+ - Act on what was asked. Do not widen the scope, and do not narrow it either.
30
+ - Investigate before you change anything. Read the file before you edit it; \
31
+ search for existing helpers before you write new ones.
32
+ - Prefer small, verifiable steps. After a change that can be checked — a test, \
33
+ a build, a script — run it.
34
+ - When something fails, read the actual error and fix the cause. Do not guess \
35
+ twice at the same thing.
36
+ - Say what you did, plainly. If part of a task is unfinished or blocked, say \
37
+ which part and why, rather than implying it is done.
38
+ - Never invent file contents, command output, or API behaviour. If you have not \
39
+ looked, say you have not looked.
40
+
41
+ How you write:
42
+ - Answer in English. Be concise; the user is reading in a terminal panel.
43
+ - Use Markdown sparingly — code blocks for code, short bullet lists for \
44
+ several parallel points, plain sentences otherwise.
45
+ - Reference files as `path/to/file.py:42` so they can be found quickly.
46
+ - Do not narrate routine tool use ("Now I will read the file"). Just use the \
47
+ tool; the interface already shows the user what is running."""
48
+
49
+ TOOL_GUIDANCE = """\
50
+ Using tools:
51
+ - Call tools in parallel when the calls are independent — several reads or \
52
+ searches at once is normal and much faster.
53
+ - `todo_write` is for any task with more than about three steps. Write the plan \
54
+ once, then keep it current: one item `active`, finished items `done`.
55
+ - `edit_file` needs an `old_string` that appears exactly once. Include \
56
+ surrounding lines to make it unique rather than guessing.
57
+ - Use `run_shell` for builds, tests and version control; use the file tools for \
58
+ reading and editing — they are safer and produce better diffs.
59
+ - A tool that returns an error is information, not a dead end. Read it, then \
60
+ adjust."""
61
+
62
+ MODE_ACT = """\
63
+ Mode: ACT. You have the full tool set and may modify the project. Work until \
64
+ the task is genuinely done."""
65
+
66
+ MODE_PLAN = """\
67
+ Mode: PLAN. You have read-only tools only — you cannot modify anything, and \
68
+ write tools are not available to you. Investigate the codebase, then produce a \
69
+ concrete plan: which files change, what changes in each, and how to verify the \
70
+ result. Name real files you have actually looked at."""
71
+
72
+ MODE_CHAT = """\
73
+ Mode: CHAT. Tools are switched off. Answer from the conversation and your own \
74
+ knowledge. If answering properly needs to look at files, say so and suggest \
75
+ switching to Act mode."""
76
+
77
+ _MODES = {"act": MODE_ACT, "plan": MODE_PLAN, "chat": MODE_CHAT}
78
+
79
+
80
+ def environment_block(config: Config) -> str:
81
+ """Facts about the machine — cheap to include, and wrong guesses are costly."""
82
+ root = config.paths.project
83
+ shell = "PowerShell / cmd" if os.name == "nt" else os.environ.get("SHELL", "sh")
84
+ lines = [
85
+ "Environment:",
86
+ f"- Operating system: {platform.system()} {platform.release()}",
87
+ f"- Shell: {shell}",
88
+ f"- Python: {sys.version.split()[0]}",
89
+ f"- Workspace: {root}",
90
+ f"- Today: {date.today().isoformat()}",
91
+ ]
92
+ markers = _project_markers(root)
93
+ if markers:
94
+ lines.append(f"- Project markers: {', '.join(markers)}")
95
+ return "\n".join(lines)
96
+
97
+
98
+ def _project_markers(root: Path) -> list[str]:
99
+ candidates = ("pyproject.toml", "package.json", "Cargo.toml", "go.mod",
100
+ "pom.xml", "build.gradle", "Makefile", "docker-compose.yml",
101
+ ".git", "requirements.txt")
102
+ found = []
103
+ for name in candidates:
104
+ try:
105
+ if (root / name).exists():
106
+ found.append(name)
107
+ except OSError:
108
+ continue
109
+ return found
110
+
111
+
112
+ def project_instructions(config: Config) -> str:
113
+ """Per-project rules from ``COMODOR.md`` — the project's own house style.
114
+
115
+ Reading a checked-in instructions file is how a team makes the agent follow
116
+ their conventions without every member configuring it by hand.
117
+ """
118
+ for name in ("COMODOR.md", ".comodor/COMODOR.md", "AGENTS.md"):
119
+ path = config.paths.project / name
120
+ try:
121
+ if path.is_file():
122
+ text = path.read_text(encoding="utf-8", errors="replace").strip()
123
+ if text:
124
+ return (f"Project instructions (from {name}) — these take "
125
+ f"precedence over your defaults:\n\n{text[:8000]}")
126
+ except OSError:
127
+ continue
128
+ return ""
129
+
130
+
131
+ def build_system_prompt(config: Config, playbook: str = "") -> str:
132
+ """The complete system prompt for one turn."""
133
+ mode = (config.agent.mode or "act").lower()
134
+ sections = [
135
+ IDENTITY,
136
+ environment_block(config),
137
+ _MODES.get(mode, MODE_ACT),
138
+ ]
139
+ if mode != "chat":
140
+ sections.append(TOOL_GUIDANCE)
141
+
142
+ instructions = project_instructions(config)
143
+ if instructions:
144
+ sections.append(instructions)
145
+
146
+ if playbook:
147
+ sections.append(playbook)
148
+
149
+ extra = (config.agent.system_prompt_extra or "").strip()
150
+ if extra:
151
+ sections.append(extra)
152
+
153
+ return "\n\n".join(section for section in sections if section)
154
+
155
+
156
+ # --------------------------------------------------------------------------- #
157
+ # prompts for the agent's own bookkeeping
158
+ # --------------------------------------------------------------------------- #
159
+
160
+ TITLE_PROMPT = """\
161
+ Summarise this conversation as a title of at most six words. Reply with the \
162
+ title only — no quotes, no punctuation at the end."""
163
+
164
+ COMPACT_PROMPT = """\
165
+ You are compacting a long agent session so that work can continue in a smaller \
166
+ context window.
167
+
168
+ Write a factual brief covering:
169
+ 1. What the user asked for, including any constraints they stated.
170
+ 2. What has been done so far — files created or changed, commands run, results.
171
+ 3. What is known about the codebase that was expensive to discover.
172
+ 4. What remains to be done, and any decision still open.
173
+
174
+ Be specific: real file paths, real function names, real error messages. Omit \
175
+ pleasantries and anything already superseded. This brief replaces the messages \
176
+ it summarises, so anything you leave out is lost."""
177
+
178
+ REFLECT_PROMPT = """\
179
+ You are reviewing a finished agent task in order to learn from it.
180
+
181
+ Produce JSON only, matching this shape:
182
+
183
+ {
184
+ "lessons": [
185
+ {
186
+ "kind": "preference | fact | heuristic | pitfall | env",
187
+ "trigger": "when this applies — the situation, in a few words",
188
+ "guidance": "what to do about it, imperative and specific",
189
+ "confidence": 0.0-1.0
190
+ }
191
+ ],
192
+ "skill": {
193
+ "name": "short_snake_case_name",
194
+ "description": "what this procedure accomplishes",
195
+ "steps": ["ordered, reusable steps"],
196
+ "tags": ["keyword", "keyword"]
197
+ } | null
198
+ }
199
+
200
+ Rules:
201
+ - Record only what would change behaviour *next time*. Facts about this \
202
+ codebase, a user preference they stated, a tool that failed in a predictable \
203
+ way, an environment quirk.
204
+ - Never record the specific content of this task ("fixed the typo in line 12"). \
205
+ Record the transferable part.
206
+ - If the task was routine and taught you nothing, return {"lessons": [], \
207
+ "skill": null}. An empty answer is a good answer.
208
+ - Propose a skill only for a multi-step procedure that clearly recurs.
209
+ - At most four lessons."""
@@ -0,0 +1,131 @@
1
+ """Counting tokens without a tokenizer.
2
+
3
+ Shipping ``tiktoken`` would add a compiled dependency and still be wrong for
4
+ every non-OpenAI model, so Comodor estimates instead — and then *calibrates*.
5
+ Every provider reply carries a real ``usage.input_tokens``; comparing that to
6
+ what we predicted for the same messages yields a correction factor, so after a
7
+ couple of turns the ``Context: 1M`` gauge tracks reality closely for whatever
8
+ model is actually in use.
9
+
10
+ The base heuristic accounts for the fact that code tokenises worse than prose
11
+ and that CJK text tokenises worse still.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ import threading
18
+ from dataclasses import dataclass
19
+
20
+ from ..providers.base import Message, ToolSpec
21
+
22
+ # Rough characters-per-token for Latin prose. Code, with its punctuation and
23
+ # indentation, lands nearer 3.
24
+ _PROSE_CHARS_PER_TOKEN = 4.0
25
+ _CODE_CHARS_PER_TOKEN = 3.0
26
+ # CJK, Thai and similar scripts are close to one token per character.
27
+ _WIDE = re.compile(r"[ᄀ-ᇿ⺀-꓏ꥠ-꥿가-퟿"
28
+ r"豈-﫿︰-﹏＀-￯]")
29
+ _CODEY = re.compile(r"[{}()\[\];=<>+\-*/\\|&^%$#@~`_]")
30
+ # Per-message framing the API adds (role markers, separators).
31
+ _MESSAGE_OVERHEAD = 4
32
+ _TOOL_OVERHEAD = 12
33
+
34
+
35
+ def estimate_text(text: str) -> int:
36
+ """Estimated token count for one string."""
37
+ if not text:
38
+ return 0
39
+ wide = len(_WIDE.findall(text))
40
+ rest = len(text) - wide
41
+ if rest <= 0:
42
+ return wide
43
+
44
+ density = len(_CODEY.findall(text)) / max(1, rest)
45
+ chars_per_token = (_CODE_CHARS_PER_TOKEN if density > 0.06
46
+ else _PROSE_CHARS_PER_TOKEN)
47
+ return int(wide + rest / chars_per_token) + 1
48
+
49
+
50
+ def estimate_message(message: Message) -> int:
51
+ total = _MESSAGE_OVERHEAD + estimate_text(message.content)
52
+ for call in message.tool_calls:
53
+ total += _TOOL_OVERHEAD + estimate_text(call.name) + estimate_text(call.arguments_json())
54
+ # An image costs far more than its base64 length suggests; this is the
55
+ # commonly used ~1.1k tokens for a typical screenshot.
56
+ total += 1100 * len(message.images)
57
+ return total
58
+
59
+
60
+ def estimate_messages(messages: list[Message]) -> int:
61
+ return sum(estimate_message(message) for message in messages)
62
+
63
+
64
+ def estimate_tools(tools: list[ToolSpec]) -> int:
65
+ total = 0
66
+ for tool in tools:
67
+ total += _TOOL_OVERHEAD + estimate_text(tool.name) + estimate_text(tool.description)
68
+ total += estimate_text(str(tool.parameters))
69
+ return total
70
+
71
+
72
+ @dataclass
73
+ class Calibration:
74
+ """Learns the ratio between our estimate and the provider's real count."""
75
+
76
+ factor: float = 1.0
77
+ samples: int = 0
78
+ _lock: threading.Lock = None # type: ignore[assignment]
79
+
80
+ def __post_init__(self) -> None:
81
+ self._lock = threading.Lock()
82
+
83
+ def observe(self, estimated: int, actual: int) -> None:
84
+ """Fold one real measurement into the correction factor."""
85
+ if estimated <= 0 or actual <= 0:
86
+ return
87
+ ratio = actual / estimated
88
+ # Ignore wild outliers: a cached-prefix turn reports far fewer input
89
+ # tokens than were sent and would otherwise poison the estimate.
90
+ if not 0.3 <= ratio <= 3.0:
91
+ return
92
+ with self._lock:
93
+ self.samples += 1
94
+ weight = min(0.3, 1.0 / self.samples)
95
+ self.factor = (1 - weight) * self.factor + weight * ratio
96
+
97
+ def apply(self, estimated: int) -> int:
98
+ with self._lock:
99
+ return int(estimated * self.factor)
100
+
101
+ @property
102
+ def confident(self) -> bool:
103
+ return self.samples >= 2
104
+
105
+
106
+ class TokenCounter:
107
+ """Estimation plus calibration, for one session."""
108
+
109
+ def __init__(self) -> None:
110
+ self.calibration = Calibration()
111
+
112
+ def count(self, messages: list[Message], tools: list[ToolSpec] | None = None) -> int:
113
+ raw = estimate_messages(messages) + estimate_tools(tools or [])
114
+ return self.calibration.apply(raw)
115
+
116
+ def count_text(self, text: str) -> int:
117
+ return self.calibration.apply(estimate_text(text))
118
+
119
+ def observe_usage(self, messages: list[Message], tools: list[ToolSpec] | None,
120
+ actual_input_tokens: int) -> None:
121
+ raw = estimate_messages(messages) + estimate_tools(tools or [])
122
+ self.calibration.observe(raw, actual_input_tokens)
123
+
124
+
125
+ def humanise(count: int) -> str:
126
+ """Compact display: 1.2M, 340K, 812."""
127
+ if count >= 1_000_000:
128
+ return f"{count / 1_000_000:.1f}M".replace(".0M", "M")
129
+ if count >= 1_000:
130
+ return f"{count / 1_000:.0f}K"
131
+ return str(count)
comodor/cli.py ADDED
@@ -0,0 +1,295 @@
1
+ """Command line entry point.
2
+
3
+ Three ways in:
4
+
5
+ * ``comodor`` — the full interface.
6
+ * ``comodor run "<task>"`` — one task, no interface, output to stdout or JSON.
7
+ This is the mode CI and scripts use; it needs explicit ``--yes`` before it
8
+ will change anything, because there is nobody there to approve a prompt.
9
+ * ``comodor doctor`` — what is configured, what is reachable, what the terminal
10
+ can do. The first thing to run when something is not working.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ from typing import Any
19
+
20
+ from . import __version__
21
+ from .config import Config, load as load_config
22
+
23
+
24
+ def build_parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(
26
+ prog="comodor",
27
+ description="Comodor — a terminal coding agent that learns as it works.",
28
+ )
29
+ parser.add_argument("--version", action="version", version=f"comodor {__version__}")
30
+
31
+ parser.add_argument("--provider", help="provider to use (openrouter, anthropic, …)")
32
+ parser.add_argument("--model", help="model id")
33
+ parser.add_argument("--mode", choices=("act", "plan", "chat"), help="starting mode")
34
+ parser.add_argument("--no-loop", action="store_true",
35
+ help="answer once instead of iterating autonomously")
36
+ parser.add_argument("--theme", help="colour theme (ember, midnight, matrix, mono)")
37
+ parser.add_argument("--ascii", action="store_true",
38
+ help="ASCII borders, for terminals without box-drawing glyphs")
39
+ parser.add_argument("--no-mouse", action="store_true", help="disable mouse support")
40
+ parser.add_argument("--cwd", help="workspace directory (default: the project root)")
41
+ parser.add_argument("--demo", action="store_true",
42
+ help="run the interface against a scripted offline provider")
43
+ parser.add_argument("--resume", nargs="?", const="__pick__",
44
+ help="reopen the most recent session, or one by id")
45
+
46
+ sub = parser.add_subparsers(dest="command")
47
+
48
+ run = sub.add_parser("run", help="run one task without the interface")
49
+ run.add_argument("task", help="what to do")
50
+ run.add_argument("--json", action="store_true", help="emit a JSON result")
51
+ run.add_argument("--yes", action="store_true",
52
+ help="approve file writes and commands automatically")
53
+ run.add_argument("--max-steps", type=int, help="override the step limit")
54
+
55
+ sub.add_parser("doctor", help="show configuration and environment diagnostics")
56
+
57
+ preview = sub.add_parser("preview",
58
+ help="render the interface at a given size and exit")
59
+ preview.add_argument("size", nargs="?", default="120x34", help="WIDTHxHEIGHT")
60
+ preview.add_argument("--svg", help="also write an SVG to this path")
61
+
62
+ return parser
63
+
64
+
65
+ def apply_overrides(config: Config, args: argparse.Namespace) -> Config:
66
+ if args.provider:
67
+ config.provider = args.provider
68
+ entry = config.providers.get(args.provider)
69
+ if entry and not args.model:
70
+ config.model = entry.model
71
+ if args.model:
72
+ config.model = args.model
73
+ if args.mode:
74
+ config.agent.mode = args.mode
75
+ if args.no_loop:
76
+ config.agent.loop = False
77
+ if args.theme:
78
+ config.ui.theme = args.theme
79
+ if args.ascii:
80
+ config.ui.ascii_borders = True
81
+ if args.no_mouse:
82
+ config.ui.mouse = False
83
+ return config
84
+
85
+
86
+ # --------------------------------------------------------------------------- #
87
+ # subcommands
88
+ # --------------------------------------------------------------------------- #
89
+
90
+
91
+ def run_headless(config: Config, args: argparse.Namespace) -> int:
92
+ """One task, no TUI. Used by scripts, hooks and CI."""
93
+ from .agent import AgentLoop, Conversation
94
+ from .events import EventBus, Kind
95
+ from .learning import LearningEngine
96
+ from .providers.gateway import Gateway
97
+ from .safety import CheckpointStore, PermissionEngine, Redactor
98
+ from .tools import ToolRegistry
99
+
100
+ if args.yes:
101
+ config.safety.auto_approve_writes = True
102
+ config.safety.auto_approve_shell = True
103
+ if args.max_steps:
104
+ config.agent.max_steps = args.max_steps
105
+
106
+ bus = EventBus()
107
+ gateway = Gateway(config)
108
+ # Headless runs learn from corrections too: a scripted run whose output the
109
+ # user later fixes by hand should teach the same lesson an interactive one
110
+ # would, or the brain would depend on how the agent happened to be invoked.
111
+ memory = LearningEngine(
112
+ config, bus, gateway,
113
+ checkpoints=CheckpointStore(config.paths.checkpoints),
114
+ redact=Redactor([entry.api_key for entry in config.providers.values()
115
+ if entry.api_key]),
116
+ )
117
+ permissions = PermissionEngine(config, bus)
118
+ permissions.on_denied = memory.on_denied
119
+ agent = AgentLoop(config, gateway, ToolRegistry(), bus,
120
+ permissions, Conversation(), memory)
121
+
122
+ if not args.json:
123
+ # Progress on stderr keeps stdout clean for the answer itself.
124
+ def echo(event: Any) -> None:
125
+ if event.kind is Kind.TOOL_START:
126
+ print(f"· {event.get('summary', event.get('name'))}", file=sys.stderr)
127
+ elif event.kind is Kind.ERROR:
128
+ print(f"! {event.text}", file=sys.stderr)
129
+
130
+ bus.subscribe(echo)
131
+
132
+ result = agent.run(args.task)
133
+ memory.wait_for_reflection(timeout=20.0)
134
+
135
+ if args.json:
136
+ print(json.dumps({
137
+ "text": result.text,
138
+ "ok": result.ok,
139
+ "stopped": result.stopped,
140
+ "steps": result.steps,
141
+ "tool_calls": result.tool_calls,
142
+ "error": result.error,
143
+ "usage": {
144
+ "input_tokens": result.usage.input_tokens,
145
+ "output_tokens": result.usage.output_tokens,
146
+ "cost_usd": round(result.usage.cost_usd, 6),
147
+ },
148
+ "elapsed": round(result.elapsed, 2),
149
+ }, ensure_ascii=False, indent=2))
150
+ else:
151
+ print(result.text)
152
+ if result.error:
153
+ print(f"\nerror: {result.error}", file=sys.stderr)
154
+
155
+ memory.close()
156
+ gateway.close()
157
+ return 0 if result.ok else 1
158
+
159
+
160
+ def run_doctor(config: Config) -> int:
161
+ from rich.table import Table
162
+
163
+ from .learning import BrainStore
164
+ from .ui import console as console_module
165
+
166
+ theme = console_module.prepare_theme(config.ui.theme, config.ui.ascii_borders,
167
+ no_color=False)
168
+ console = console_module.build(theme)
169
+
170
+ console.print(f"[title]Comodor {__version__}[/title]\n")
171
+
172
+ providers = Table(title="Providers", title_justify="left", box=theme.box,
173
+ border_style=theme.style("border.dim"), expand=False)
174
+ providers.add_column("name", style=theme.style("value"))
175
+ providers.add_column("model")
176
+ providers.add_column("endpoint", style=theme.style("dim"))
177
+ providers.add_column("key")
178
+ for entry in config.providers.values():
179
+ status = ("[good]ready[/good]" if entry.ready else
180
+ "[dim]local[/dim]" if entry.local else "[bad]missing[/bad]")
181
+ marker = " ←" if entry.name == config.provider else ""
182
+ providers.add_row(entry.name + marker, entry.model, entry.base_url, status)
183
+ console.print(providers)
184
+
185
+ brain = BrainStore(config.paths.brain_db)
186
+ stats = brain.stats()
187
+ console.print("\n[title]Brain[/title]")
188
+ console.print(f" reflex: {stats['rules_active']} active rules of "
189
+ f"{stats['rules']} · {stats['signals']} signals observed")
190
+ console.print(f" memory: {stats['lessons']} lessons · {stats['skills']} skills · "
191
+ f"{stats['episodes']} tasks "
192
+ f"({stats['success_rate']:.0%} succeeded)")
193
+ console.print(f" index: {'FTS5' if stats['fts'] else 'python BM25 fallback'}, "
194
+ f"{stats['indexed']} records in memory")
195
+ console.print(f" file: [dim]{stats['path']}[/dim]")
196
+ brain.close()
197
+
198
+ console.print("\n[title]Terminal[/title]")
199
+ console.print(f" size: {console.size.width}×{console.size.height}")
200
+ console.print(f" colour: {console.color_system or 'none'}")
201
+ console.print(f" unicode: {console_module.supports_unicode()}")
202
+ console.print(f" legacy windows console: {console.legacy_windows}")
203
+
204
+ console.print("\n[title]Paths[/title]")
205
+ console.print(f" workspace: [dim]{config.paths.project}[/dim]")
206
+ console.print(f" config: [dim]{config.paths.config_file}[/dim]")
207
+
208
+ if not config.available():
209
+ console.print("\n[bad]No provider has an API key.[/bad] Add one to a .env "
210
+ "file, or run [accent]comodor --demo[/accent] to try the "
211
+ "interface offline.")
212
+ return 1
213
+ return 0
214
+
215
+
216
+ def run_preview(config: Config, args: argparse.Namespace) -> int:
217
+ """Render one frame at a fixed size — for screenshots and layout checks."""
218
+ from .ui import console as console_module
219
+ from .ui import layout as layout_module
220
+ from .ui.screen import Screen, ScreenState
221
+ from .ui.widgets.statusbar import StatusModel
222
+
223
+ try:
224
+ width, _, height = args.size.lower().partition("x")
225
+ size = (int(width), int(height))
226
+ except ValueError:
227
+ print(f"bad size {args.size!r}; expected WIDTHxHEIGHT", file=sys.stderr)
228
+ return 2
229
+
230
+ theme = console_module.prepare_theme(config.ui.theme, config.ui.ascii_borders,
231
+ no_color=False)
232
+ console = console_module.build(theme, width=size[0], height=size[1],
233
+ record=bool(args.svg))
234
+ state = ScreenState()
235
+ state.status = StatusModel(
236
+ provider=config.active().display if config.active() else "none",
237
+ model=config.active_model(), connected=bool(config.available()),
238
+ mode=config.agent.mode, loop=config.agent.loop,
239
+ gateway="Disable" if not config.gateway.enabled else config.gateway.policy,
240
+ context_limit=config.agent.context_limit,
241
+ )
242
+ geometry = layout_module.compute(size[0], size[1])
243
+ console.print(Screen(console, theme).render(state, geometry))
244
+ if args.svg:
245
+ console.save_svg(args.svg, title="Comodor")
246
+ print(f"wrote {args.svg}", file=sys.stderr)
247
+ return 0
248
+
249
+
250
+ # --------------------------------------------------------------------------- #
251
+ # entry point
252
+ # --------------------------------------------------------------------------- #
253
+
254
+
255
+ def main(argv: list[str] | None = None) -> int:
256
+ args = build_parser().parse_args(argv)
257
+ config = apply_overrides(load_config(args.cwd), args)
258
+
259
+ if args.command == "doctor":
260
+ return run_doctor(config)
261
+ if args.command == "preview":
262
+ return run_preview(config, args)
263
+ if args.command == "run":
264
+ return run_headless(config, args)
265
+
266
+ if args.demo:
267
+ from .config import ProviderConfig
268
+
269
+ config.providers["fake"] = ProviderConfig(
270
+ name="fake", kind="fake", base_url="offline", api_key="demo",
271
+ model="comodor-demo", label="Demo (offline)")
272
+ config.provider = "fake"
273
+ config.model = "comodor-demo"
274
+ elif not config.available():
275
+ print("No provider is configured.\n"
276
+ " · add an API key to a .env file (see .env.example), or\n"
277
+ " · run `comodor --demo` to explore the interface offline, or\n"
278
+ " · run `comodor doctor` to see what is missing.", file=sys.stderr)
279
+ return 1
280
+
281
+ from .ui.app import App
282
+
283
+ resume = None
284
+ if args.resume and args.resume != "__pick__":
285
+ resume = args.resume
286
+
287
+ app = App(config, demo=args.demo, resume=resume)
288
+ try:
289
+ return app.run()
290
+ except KeyboardInterrupt:
291
+ return 130
292
+
293
+
294
+ if __name__ == "__main__":
295
+ raise SystemExit(main())