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
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Learning from what the user does, not from what they say.
|
|
2
|
+
|
|
3
|
+
Praise is rare and vague; correction is frequent and precise. When somebody
|
|
4
|
+
rewrites the function the agent just wrote, undoes an edit, or refuses a command,
|
|
5
|
+
they have labelled that behaviour more clearly than any rating could — and they
|
|
6
|
+
did it as a side effect of working, at no cost to themselves.
|
|
7
|
+
|
|
8
|
+
Every detector here is deterministic and runs in microseconds, so this learning
|
|
9
|
+
continues when the user is offline, on a cheap model, or has reflection switched
|
|
10
|
+
off. That is the difference between a memory that needs a budget and one that is
|
|
11
|
+
simply always on.
|
|
12
|
+
|
|
13
|
+
Detection happens at the *start* of a turn rather than the end, which gives the
|
|
14
|
+
system its most useful property: fix something the agent wrote, ask it for the
|
|
15
|
+
next thing, and the correction is already in force.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from . import rules as rules_module
|
|
26
|
+
from .bm25 import similarity
|
|
27
|
+
from .store import BrainStore, Rule, Signal
|
|
28
|
+
|
|
29
|
+
# A correction is only attributed to the agent for a while after it wrote the
|
|
30
|
+
# file. Beyond that the user is simply working on their code, and reading intent
|
|
31
|
+
# into it would be an invention.
|
|
32
|
+
CORRECTION_WINDOW = 90 * 60.0
|
|
33
|
+
REPHRASE_WINDOW = 120.0
|
|
34
|
+
REPHRASE_SIMILARITY = 0.6
|
|
35
|
+
MAX_DIFF_CHARS = 4000
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Correction:
|
|
40
|
+
"""One file the user changed after the agent wrote it."""
|
|
41
|
+
|
|
42
|
+
path: str
|
|
43
|
+
before: str # what the agent left
|
|
44
|
+
after: str # what the user made of it
|
|
45
|
+
observations: list[rules_module.Observation] = field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def understood(self) -> bool:
|
|
49
|
+
"""Whether anything transferable was extracted from the change."""
|
|
50
|
+
return bool(self.observations)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Outcome:
|
|
55
|
+
"""What one detection pass found, and what it changed."""
|
|
56
|
+
|
|
57
|
+
corrections: list[Correction] = field(default_factory=list)
|
|
58
|
+
new_rules: list[Rule] = field(default_factory=list)
|
|
59
|
+
reinforced: list[Rule] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def empty(self) -> bool:
|
|
63
|
+
return not (self.corrections or self.new_rules or self.reinforced)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SignalDetector:
|
|
67
|
+
"""Turns observed user actions into counted rules."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, store: BrainStore, checkpoints: Any, scope: str,
|
|
70
|
+
session_id: str = "", redact: Any = None) -> None:
|
|
71
|
+
self.store = store
|
|
72
|
+
self.checkpoints = checkpoints
|
|
73
|
+
self.scope = scope
|
|
74
|
+
self.session_id = session_id
|
|
75
|
+
self.redact = redact or (lambda text: text)
|
|
76
|
+
self.last_user_text = ""
|
|
77
|
+
self.last_user_at = 0.0
|
|
78
|
+
self._seen_hashes: dict[str, str] = {}
|
|
79
|
+
|
|
80
|
+
# -- 1. the file the user rewrote ------------------------------------- #
|
|
81
|
+
|
|
82
|
+
def scan_corrections(self, episode_id: int = 0) -> Outcome:
|
|
83
|
+
"""Find files the agent wrote that the user has since changed."""
|
|
84
|
+
outcome = Outcome()
|
|
85
|
+
if self.checkpoints is None:
|
|
86
|
+
return outcome
|
|
87
|
+
|
|
88
|
+
cutoff = time.time() - CORRECTION_WINDOW
|
|
89
|
+
try:
|
|
90
|
+
entries = self.checkpoints.touched_since(cutoff)
|
|
91
|
+
except Exception:
|
|
92
|
+
return outcome
|
|
93
|
+
|
|
94
|
+
for entry in entries:
|
|
95
|
+
current = self.checkpoints.hash_of(entry.path)
|
|
96
|
+
if current is None or current == entry.after_blob:
|
|
97
|
+
continue
|
|
98
|
+
# Only report a given rewrite once, however many turns follow it.
|
|
99
|
+
if self._seen_hashes.get(entry.path) == current:
|
|
100
|
+
continue
|
|
101
|
+
self._seen_hashes[entry.path] = current
|
|
102
|
+
|
|
103
|
+
written = self.checkpoints.read_blob(entry.after_blob)
|
|
104
|
+
try:
|
|
105
|
+
now_text = Path(entry.path).read_text(encoding="utf-8", errors="replace")
|
|
106
|
+
except OSError:
|
|
107
|
+
continue
|
|
108
|
+
if not written or written == now_text:
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
observations = rules_module.analyse_correction(written, now_text, entry.path)
|
|
112
|
+
correction = Correction(path=entry.path, before=written, after=now_text,
|
|
113
|
+
observations=observations)
|
|
114
|
+
outcome.corrections.append(correction)
|
|
115
|
+
|
|
116
|
+
self.store.add_signal(Signal(
|
|
117
|
+
kind="correction", session_id=self.session_id, episode_id=episode_id,
|
|
118
|
+
subject=entry.path,
|
|
119
|
+
payload=self.redact(_summarise_change(written, now_text)),
|
|
120
|
+
weight=2.0,
|
|
121
|
+
))
|
|
122
|
+
self._apply(observations, source="correction", outcome=outcome)
|
|
123
|
+
|
|
124
|
+
return outcome
|
|
125
|
+
|
|
126
|
+
# -- 2. the change the user threw away -------------------------------- #
|
|
127
|
+
|
|
128
|
+
def record_undo(self, paths: list[str], episode_id: int = 0) -> Outcome:
|
|
129
|
+
"""`/undo` is an unambiguous rejection of what the agent just did."""
|
|
130
|
+
outcome = Outcome()
|
|
131
|
+
for path in paths:
|
|
132
|
+
self.store.add_signal(Signal(
|
|
133
|
+
kind="undo", session_id=self.session_id, episode_id=episode_id,
|
|
134
|
+
subject=str(path), weight=2.0,
|
|
135
|
+
))
|
|
136
|
+
# Whatever the agent wrote there is no longer the user's file, so it
|
|
137
|
+
# must not later be read back as if they had accepted it.
|
|
138
|
+
self._seen_hashes.pop(str(path), None)
|
|
139
|
+
return outcome
|
|
140
|
+
|
|
141
|
+
# -- 3. the command the user refused ---------------------------------- #
|
|
142
|
+
|
|
143
|
+
def record_denial(self, tool: str, subject: str, episode_id: int = 0) -> Outcome:
|
|
144
|
+
"""A denied permission names one thing this user does not want done."""
|
|
145
|
+
outcome = Outcome()
|
|
146
|
+
self.store.add_signal(Signal(
|
|
147
|
+
kind="denial", session_id=self.session_id, episode_id=episode_id,
|
|
148
|
+
subject=f"{tool}:{subject}"[:200], payload=self.redact(subject)[:400],
|
|
149
|
+
weight=2.0,
|
|
150
|
+
))
|
|
151
|
+
target, statement = _describe_denial(tool, subject)
|
|
152
|
+
rule = self.store.observe_rule(
|
|
153
|
+
key=f"avoid.{tool}.{target}"[:80],
|
|
154
|
+
scope=self.scope,
|
|
155
|
+
category="avoid",
|
|
156
|
+
statement=statement,
|
|
157
|
+
detail=f"declined: {self.redact(subject)[:120]}",
|
|
158
|
+
source="correction",
|
|
159
|
+
weight=2, # a refusal is explicit; it needs no repetition
|
|
160
|
+
)
|
|
161
|
+
self._collect(rule, outcome)
|
|
162
|
+
return outcome
|
|
163
|
+
|
|
164
|
+
# -- 4. the question the user had to ask twice ------------------------ #
|
|
165
|
+
|
|
166
|
+
def record_user_message(self, text: str, episode_id: int = 0) -> Outcome:
|
|
167
|
+
"""A near-repeat of the last message means the answer missed."""
|
|
168
|
+
outcome = Outcome()
|
|
169
|
+
now = time.time()
|
|
170
|
+
previous, previous_at = self.last_user_text, self.last_user_at
|
|
171
|
+
self.last_user_text, self.last_user_at = text, now
|
|
172
|
+
|
|
173
|
+
if not previous or now - previous_at > REPHRASE_WINDOW:
|
|
174
|
+
return outcome
|
|
175
|
+
if similarity(previous, text) < REPHRASE_SIMILARITY:
|
|
176
|
+
return outcome
|
|
177
|
+
|
|
178
|
+
self.store.add_signal(Signal(
|
|
179
|
+
kind="rephrase", session_id=self.session_id, episode_id=episode_id,
|
|
180
|
+
subject=self.redact(text)[:200], weight=1.0,
|
|
181
|
+
))
|
|
182
|
+
return outcome
|
|
183
|
+
|
|
184
|
+
# -- 5. the tool that kept failing the same way ----------------------- #
|
|
185
|
+
|
|
186
|
+
def record_retries(self, messages: list[Any], episode_id: int = 0) -> Outcome:
|
|
187
|
+
"""Two identical failures in one task is a pitfall, not bad luck."""
|
|
188
|
+
outcome = Outcome()
|
|
189
|
+
failures: dict[tuple[str, str], int] = {}
|
|
190
|
+
for message in messages:
|
|
191
|
+
if getattr(message.role, "value", "") != "tool" or not message.is_error:
|
|
192
|
+
continue
|
|
193
|
+
key = (message.name, _error_class(message.content))
|
|
194
|
+
failures[key] = failures.get(key, 0) + 1
|
|
195
|
+
|
|
196
|
+
for (tool, error), count in failures.items():
|
|
197
|
+
if count < 2 or not error:
|
|
198
|
+
continue
|
|
199
|
+
self.store.add_signal(Signal(
|
|
200
|
+
kind="retry", session_id=self.session_id, episode_id=episode_id,
|
|
201
|
+
subject=f"{tool}:{error}", weight=1.0,
|
|
202
|
+
))
|
|
203
|
+
rule = self.store.observe_rule(
|
|
204
|
+
key=f"pitfall.{tool}.{error}"[:80],
|
|
205
|
+
scope=self.scope,
|
|
206
|
+
category="workflow",
|
|
207
|
+
statement=f"`{tool}` fails here with \"{error}\" — check that "
|
|
208
|
+
f"before calling it again.",
|
|
209
|
+
detail=f"hit {count} times in one task",
|
|
210
|
+
source="evidence",
|
|
211
|
+
weight=2,
|
|
212
|
+
)
|
|
213
|
+
self._collect(rule, outcome)
|
|
214
|
+
return outcome
|
|
215
|
+
|
|
216
|
+
# -- 6. what the project already looks like --------------------------- #
|
|
217
|
+
|
|
218
|
+
def scan_project(self, root: Path, max_files: int = 60) -> Outcome:
|
|
219
|
+
"""A one-off read of the codebase's existing conventions."""
|
|
220
|
+
outcome = Outcome()
|
|
221
|
+
try:
|
|
222
|
+
observations = rules_module.scan_project(Path(root), max_files=max_files)
|
|
223
|
+
except Exception:
|
|
224
|
+
return outcome
|
|
225
|
+
self._apply(observations, source="observation", outcome=outcome)
|
|
226
|
+
return outcome
|
|
227
|
+
|
|
228
|
+
# -- shared ----------------------------------------------------------- #
|
|
229
|
+
|
|
230
|
+
def _apply(self, observations: list[rules_module.Observation], source: str,
|
|
231
|
+
outcome: Outcome) -> None:
|
|
232
|
+
for observation in observations:
|
|
233
|
+
rule = self.store.observe_rule(
|
|
234
|
+
key=observation.key,
|
|
235
|
+
scope=self.scope,
|
|
236
|
+
agrees=observation.agrees,
|
|
237
|
+
category=observation.category,
|
|
238
|
+
statement=observation.statement,
|
|
239
|
+
detail=observation.detail,
|
|
240
|
+
source=source if observation.agrees else "observation",
|
|
241
|
+
weight=observation.weight,
|
|
242
|
+
)
|
|
243
|
+
self._collect(rule, outcome)
|
|
244
|
+
|
|
245
|
+
def _collect(self, rule: Rule, outcome: Outcome) -> None:
|
|
246
|
+
"""Separate rules that just crossed into use from ones already in use.
|
|
247
|
+
|
|
248
|
+
Only a rule that has *become* confident is worth announcing; repeating
|
|
249
|
+
"learned: use single quotes" on every turn would be noise.
|
|
250
|
+
"""
|
|
251
|
+
if not rule.confident:
|
|
252
|
+
return
|
|
253
|
+
from .store import FLOORS
|
|
254
|
+
|
|
255
|
+
floor = FLOORS.get(rule.source, 4)
|
|
256
|
+
just_became_confident = rule.source == "user" or rule.support - 2 < floor
|
|
257
|
+
(outcome.new_rules if just_became_confident else outcome.reinforced).append(rule)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _describe_denial(tool: str, subject: str) -> tuple[str, str]:
|
|
261
|
+
"""Name what was refused, in the terms that tool works in.
|
|
262
|
+
|
|
263
|
+
The permission dialog shows a human summary like ``run: rm -rf build``, so
|
|
264
|
+
the leading verb has to come off before the real subject — a rule that says
|
|
265
|
+
"do not run `run:`" is worse than no rule at all.
|
|
266
|
+
"""
|
|
267
|
+
text = subject.strip()
|
|
268
|
+
if ": " in text:
|
|
269
|
+
text = text.split(": ", 1)[1].strip()
|
|
270
|
+
|
|
271
|
+
if tool == "run_shell":
|
|
272
|
+
command = text.split()[0] if text.split() else "that command"
|
|
273
|
+
return command, (f"Do not run `{command}` without asking first — this "
|
|
274
|
+
f"user declined it before.")
|
|
275
|
+
if tool in ("web_fetch", "web_search"):
|
|
276
|
+
return tool, (f"Be careful with `{tool}`: this user declined it before, "
|
|
277
|
+
f"so ask before reaching the network.")
|
|
278
|
+
|
|
279
|
+
target = text.split()[0] if text.split() else tool
|
|
280
|
+
return target, (f"Ask before using `{tool}` on {target} — this user "
|
|
281
|
+
f"declined that before.")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _summarise_change(before: str, after: str) -> str:
|
|
285
|
+
"""A compact record of a correction, capped so the brain stays small."""
|
|
286
|
+
import difflib
|
|
287
|
+
|
|
288
|
+
diff = list(difflib.unified_diff(before.splitlines(), after.splitlines(),
|
|
289
|
+
lineterm="", n=1))
|
|
290
|
+
text = "\n".join(diff[:80])
|
|
291
|
+
return text[:MAX_DIFF_CHARS]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _error_class(content: str) -> str:
|
|
295
|
+
"""A stable label for an error, so the same failure groups together.
|
|
296
|
+
|
|
297
|
+
Paths, numbers and quoted values are stripped out: "file a.py not found" and
|
|
298
|
+
"file b.py not found" are the same pitfall.
|
|
299
|
+
"""
|
|
300
|
+
text = (content or "").strip().lower()
|
|
301
|
+
if text.startswith("error:"):
|
|
302
|
+
text = text[6:].strip()
|
|
303
|
+
words: list[str] = []
|
|
304
|
+
for word in text.split()[:6]:
|
|
305
|
+
if any(character.isdigit() for character in word):
|
|
306
|
+
continue
|
|
307
|
+
if "/" in word or "\\" in word or word.endswith((".py", ".js", ".ts")):
|
|
308
|
+
continue
|
|
309
|
+
words.append(word.strip("`'\".,:;()"))
|
|
310
|
+
return " ".join(word for word in words if word)[:60]
|