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/agent/prompts.py
ADDED
|
@@ -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."""
|
comodor/agent/tokens.py
ADDED
|
@@ -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())
|