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,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]