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,450 @@
|
|
|
1
|
+
"""The learning engine — what makes Comodor better the more it is used.
|
|
2
|
+
|
|
3
|
+
The cycle, once per task:
|
|
4
|
+
|
|
5
|
+
1. **Recall.** Before answering, rank stored lessons against the request and
|
|
6
|
+
inject the best few as a *Playbook* block, under a hard token cap.
|
|
7
|
+
2. **Act.** The agent works normally.
|
|
8
|
+
3. **Credit.** Whatever was recalled shares the outcome: a clean finish is a win
|
|
9
|
+
for those lessons, a failure is a loss. Confidence moves accordingly.
|
|
10
|
+
4. **Reflect.** In the background, the model reads the episode and proposes new
|
|
11
|
+
lessons. Near-duplicates merge into the existing lesson instead of piling up.
|
|
12
|
+
5. **Consolidate.** Old, unused, low-confidence lessons decay away.
|
|
13
|
+
|
|
14
|
+
Two design choices are load-bearing. Reflection runs on a background thread so a
|
|
15
|
+
finished task never waits on it. And everything recalled is *shown* to the user
|
|
16
|
+
— a memory system you cannot inspect is one you cannot trust, and a wrong lesson
|
|
17
|
+
that silently shapes every answer is worse than no memory at all.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ..config import Config
|
|
29
|
+
from ..events import EventBus, Kind
|
|
30
|
+
from ..paths import project_key
|
|
31
|
+
from . import reflect as reflection_module
|
|
32
|
+
from . import rules as rules_module
|
|
33
|
+
from .signals import Outcome, SignalDetector
|
|
34
|
+
from .store import BrainStore, Episode, Lesson, Rule, Skill, score
|
|
35
|
+
|
|
36
|
+
PLAYBOOK_HEADER = """\
|
|
37
|
+
Learned playbook — things earlier sessions established. Treat these as strong \
|
|
38
|
+
priors, not orders: if what you observe now contradicts one, trust your \
|
|
39
|
+
observation and say so."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class RecallResult:
|
|
44
|
+
lessons: list[Lesson]
|
|
45
|
+
skills: list[Skill]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class LearningEngine:
|
|
49
|
+
"""Owns the brain: recall, credit assignment, reflection, consolidation."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, config: Config, bus: EventBus, gateway: Any = None,
|
|
52
|
+
store: BrainStore | None = None, checkpoints: Any = None,
|
|
53
|
+
redact: Any = None) -> None:
|
|
54
|
+
self.config = config
|
|
55
|
+
self.bus = bus
|
|
56
|
+
self.gateway = gateway
|
|
57
|
+
self.store = store or BrainStore(config.paths.brain_db)
|
|
58
|
+
self.session_id = f"s{int(time.time())}"
|
|
59
|
+
self.project_scope = f"project:{project_key(config.paths.project)}"
|
|
60
|
+
self._reflect_lock = threading.Lock()
|
|
61
|
+
self._threads: list[threading.Thread] = []
|
|
62
|
+
|
|
63
|
+
# Reflex: the fast lane. Deterministic, model-free, always on.
|
|
64
|
+
self.detector = SignalDetector(
|
|
65
|
+
store=self.store, checkpoints=checkpoints, scope=self.project_scope,
|
|
66
|
+
session_id=self.session_id, redact=redact,
|
|
67
|
+
)
|
|
68
|
+
self._prefetched: tuple[str, list[Lesson]] | None = None
|
|
69
|
+
self._prefetch_lock = threading.Lock()
|
|
70
|
+
|
|
71
|
+
# -- scoping ---------------------------------------------------------- #
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def scopes(self) -> list[str]:
|
|
75
|
+
"""Which buckets of memory apply to the current project."""
|
|
76
|
+
return ["global", self.project_scope]
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def write_scope(self) -> str:
|
|
80
|
+
return (self.project_scope if self.config.learning.share_scope == "project"
|
|
81
|
+
else "global")
|
|
82
|
+
|
|
83
|
+
# -- 0. reflex: what the user changed since last time ------------------ #
|
|
84
|
+
|
|
85
|
+
def before_turn(self, user_text: str, episode_id: int = 0) -> Outcome:
|
|
86
|
+
"""Fold in everything the user has done since the previous turn.
|
|
87
|
+
|
|
88
|
+
Deliberately runs *before* recall rather than after the task, so a
|
|
89
|
+
correction made a moment ago is already in force for the request being
|
|
90
|
+
typed now. That immediacy is the whole point: fix it once, and the next
|
|
91
|
+
answer is different.
|
|
92
|
+
"""
|
|
93
|
+
if not self.config.learning.enabled or not self.config.learning.corrections:
|
|
94
|
+
return Outcome()
|
|
95
|
+
|
|
96
|
+
outcome = self.detector.scan_corrections(episode_id)
|
|
97
|
+
message_outcome = self.detector.record_user_message(user_text, episode_id)
|
|
98
|
+
outcome.new_rules.extend(message_outcome.new_rules)
|
|
99
|
+
|
|
100
|
+
if outcome.new_rules and self.config.learning.announce:
|
|
101
|
+
self.bus.emit(Kind.MEMORY, action="rule",
|
|
102
|
+
items=[rule.as_dict() for rule in outcome.new_rules],
|
|
103
|
+
corrections=len(outcome.corrections))
|
|
104
|
+
return outcome
|
|
105
|
+
|
|
106
|
+
def on_undo(self, paths: list[str]) -> None:
|
|
107
|
+
self.detector.record_undo(paths)
|
|
108
|
+
|
|
109
|
+
def on_denied(self, tool: str, subject: str) -> Outcome:
|
|
110
|
+
"""A refused permission, recorded as a preference."""
|
|
111
|
+
if not self.config.learning.enabled or not self.config.learning.corrections:
|
|
112
|
+
return Outcome()
|
|
113
|
+
outcome = self.detector.record_denial(tool, subject)
|
|
114
|
+
if outcome.new_rules and self.config.learning.announce:
|
|
115
|
+
self.bus.emit(Kind.MEMORY, action="rule",
|
|
116
|
+
items=[rule.as_dict() for rule in outcome.new_rules])
|
|
117
|
+
return outcome
|
|
118
|
+
|
|
119
|
+
def bootstrap_project(self) -> int:
|
|
120
|
+
"""Read the project's existing conventions once, in the background."""
|
|
121
|
+
if not self.config.learning.enabled or not self.config.learning.rules:
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
def work() -> None:
|
|
125
|
+
try:
|
|
126
|
+
self.detector.scan_project(self.config.paths.project)
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
thread = threading.Thread(target=work, daemon=True, name="comodor-scan")
|
|
131
|
+
thread.start()
|
|
132
|
+
self._threads.append(thread)
|
|
133
|
+
return 1
|
|
134
|
+
|
|
135
|
+
# -- 1. recall -------------------------------------------------------- #
|
|
136
|
+
|
|
137
|
+
def active_rules(self) -> list[Rule]:
|
|
138
|
+
"""The house rules confident enough to shape this turn."""
|
|
139
|
+
if not self.config.learning.enabled or not self.config.learning.rules:
|
|
140
|
+
return []
|
|
141
|
+
return self.store.confident_rules(self.scopes)
|
|
142
|
+
|
|
143
|
+
def prefetch(self, query: str) -> None:
|
|
144
|
+
"""Warm recall for a draft the user is still typing.
|
|
145
|
+
|
|
146
|
+
Called from the UI's idle path. By the time Enter is pressed the answer
|
|
147
|
+
is usually already computed, so recall costs nothing on the turn itself.
|
|
148
|
+
"""
|
|
149
|
+
if not query.strip() or not self.config.learning.enabled:
|
|
150
|
+
return
|
|
151
|
+
try:
|
|
152
|
+
lessons = self.recall(query)
|
|
153
|
+
except Exception:
|
|
154
|
+
return
|
|
155
|
+
with self._prefetch_lock:
|
|
156
|
+
self._prefetched = (query, lessons)
|
|
157
|
+
|
|
158
|
+
def take_prefetched(self, query: str) -> list[Lesson] | None:
|
|
159
|
+
"""The prefetched result, if it was for this exact query."""
|
|
160
|
+
with self._prefetch_lock:
|
|
161
|
+
cached = self._prefetched
|
|
162
|
+
self._prefetched = None
|
|
163
|
+
if cached and cached[0].strip() == query.strip():
|
|
164
|
+
return cached[1]
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
def recall(self, query: str) -> list[Lesson]:
|
|
168
|
+
"""The lessons worth spending context on for this request."""
|
|
169
|
+
if not self.config.learning.enabled or not query.strip():
|
|
170
|
+
return []
|
|
171
|
+
|
|
172
|
+
learning = self.config.learning
|
|
173
|
+
selected: list[Lesson] = []
|
|
174
|
+
seen: set[int] = set()
|
|
175
|
+
|
|
176
|
+
# Pinned lessons are unconditional — the user asked for them every time.
|
|
177
|
+
for lesson in self.store.pinned_lessons(self.scopes):
|
|
178
|
+
selected.append(lesson)
|
|
179
|
+
seen.add(lesson.id)
|
|
180
|
+
|
|
181
|
+
ranked = self.store.search_lessons(query, self.scopes, limit=learning.top_k * 3)
|
|
182
|
+
scored = sorted(
|
|
183
|
+
((lesson, score(relevance, lesson, learning.half_life_days, query))
|
|
184
|
+
for lesson, relevance in ranked if lesson.id not in seen),
|
|
185
|
+
key=lambda pair: pair[1], reverse=True,
|
|
186
|
+
)
|
|
187
|
+
for lesson, value in scored:
|
|
188
|
+
if len(selected) >= learning.top_k:
|
|
189
|
+
break
|
|
190
|
+
if value <= 0.01: # too weak or too distrusted to bother
|
|
191
|
+
continue
|
|
192
|
+
selected.append(lesson)
|
|
193
|
+
seen.add(lesson.id)
|
|
194
|
+
|
|
195
|
+
return selected
|
|
196
|
+
|
|
197
|
+
def recall_skills(self, query: str, limit: int = 2) -> list[Skill]:
|
|
198
|
+
if not self.config.learning.enabled:
|
|
199
|
+
return []
|
|
200
|
+
return [skill for skill, _ in self.store.search_skills(query, limit=limit)
|
|
201
|
+
if skill.success_rate >= 0.4]
|
|
202
|
+
|
|
203
|
+
def render_playbook(self, lessons: list[Lesson], skills: list[Skill] | None = None,
|
|
204
|
+
max_tokens: int | None = None,
|
|
205
|
+
rules: list[Rule] | None = None) -> str:
|
|
206
|
+
"""Format recalled memory for the system prompt, within a token budget.
|
|
207
|
+
|
|
208
|
+
House rules come first and carry their own budget. They are counted facts
|
|
209
|
+
about how this user works rather than prose a model wrote, so they are
|
|
210
|
+
both cheaper and more reliable than a lesson, and they should survive a
|
|
211
|
+
tight budget that truncates the rest.
|
|
212
|
+
"""
|
|
213
|
+
blocks: list[str] = []
|
|
214
|
+
if rules:
|
|
215
|
+
rules_block = rules_module.render_rules(rules, max_tokens=300)
|
|
216
|
+
if rules_block:
|
|
217
|
+
blocks.append(rules_block)
|
|
218
|
+
|
|
219
|
+
if not lessons and not skills:
|
|
220
|
+
return "\n\n".join(blocks)
|
|
221
|
+
|
|
222
|
+
budget = max_tokens or self.config.learning.max_playbook_tokens
|
|
223
|
+
lines = [PLAYBOOK_HEADER, ""]
|
|
224
|
+
used = len(PLAYBOOK_HEADER) // 4
|
|
225
|
+
|
|
226
|
+
for lesson in lessons:
|
|
227
|
+
confidence = lesson.effective_confidence(self.config.learning.half_life_days)
|
|
228
|
+
entry = f"- ({lesson.kind}, {confidence:.0%}) When {lesson.trigger}: {lesson.guidance}"
|
|
229
|
+
cost = len(entry) // 4 + 1
|
|
230
|
+
if used + cost > budget:
|
|
231
|
+
break
|
|
232
|
+
lines.append(entry)
|
|
233
|
+
used += cost
|
|
234
|
+
|
|
235
|
+
for skill in skills or []:
|
|
236
|
+
entry = (f"- (skill: {skill.name}, {skill.success_rate:.0%} success) "
|
|
237
|
+
f"{skill.description} Steps: {'; '.join(skill.steps[:6])}")
|
|
238
|
+
cost = len(entry) // 4 + 1
|
|
239
|
+
if used + cost > budget:
|
|
240
|
+
break
|
|
241
|
+
lines.append(entry)
|
|
242
|
+
used += cost
|
|
243
|
+
|
|
244
|
+
if len(lines) > 2:
|
|
245
|
+
blocks.append("\n".join(lines))
|
|
246
|
+
return "\n\n".join(blocks)
|
|
247
|
+
|
|
248
|
+
# -- 3. credit -------------------------------------------------------- #
|
|
249
|
+
|
|
250
|
+
def record_outcome(self, goal: str, messages: list[Any], recalled: list[Lesson],
|
|
251
|
+
success: bool, stopped: str, steps: int, elapsed: float,
|
|
252
|
+
approvals: int = 0, tokens: int = 0,
|
|
253
|
+
corrections: int = 0) -> None:
|
|
254
|
+
"""Close the loop on one task: credit, store, then reflect in the background."""
|
|
255
|
+
tools_used = sorted({message.name for message in messages
|
|
256
|
+
if getattr(message.role, "value", "") == "tool" and message.name})
|
|
257
|
+
errors = [message for message in messages
|
|
258
|
+
if getattr(message.role, "value", "") == "tool" and message.is_error]
|
|
259
|
+
|
|
260
|
+
episode = self.store.add_episode(Episode(
|
|
261
|
+
session_id=self.session_id,
|
|
262
|
+
goal=goal[:1000],
|
|
263
|
+
scope=self.project_scope,
|
|
264
|
+
success=success,
|
|
265
|
+
stopped=stopped,
|
|
266
|
+
steps=steps,
|
|
267
|
+
elapsed=elapsed,
|
|
268
|
+
tools_used=tools_used,
|
|
269
|
+
error_kind=(errors[-1].name if errors else ""),
|
|
270
|
+
approvals_asked=approvals,
|
|
271
|
+
tokens=tokens,
|
|
272
|
+
corrections=corrections,
|
|
273
|
+
retries=len(errors),
|
|
274
|
+
rules_active=len(self.active_rules()),
|
|
275
|
+
))
|
|
276
|
+
|
|
277
|
+
if self.config.learning.corrections:
|
|
278
|
+
self.detector.record_retries(messages, episode.id)
|
|
279
|
+
|
|
280
|
+
if recalled:
|
|
281
|
+
# Credit assignment is coarse on purpose: precisely attributing a
|
|
282
|
+
# multi-step outcome to individual lessons is not solvable here, and
|
|
283
|
+
# over many episodes the coarse signal still separates the useful
|
|
284
|
+
# lessons from the useless ones.
|
|
285
|
+
self.store.credit([lesson.id for lesson in recalled], won=success)
|
|
286
|
+
|
|
287
|
+
if self.config.learning.reflect and self.gateway is not None:
|
|
288
|
+
self._reflect_async(goal, list(messages), stopped, episode.id)
|
|
289
|
+
|
|
290
|
+
# -- 4. reflect ------------------------------------------------------- #
|
|
291
|
+
|
|
292
|
+
def _reflect_async(self, goal: str, messages: list[Any], outcome: str,
|
|
293
|
+
episode_id: int) -> None:
|
|
294
|
+
thread = threading.Thread(
|
|
295
|
+
target=self._reflect, args=(goal, messages, outcome, episode_id),
|
|
296
|
+
daemon=True, name="comodor-reflect",
|
|
297
|
+
)
|
|
298
|
+
thread.start()
|
|
299
|
+
self._threads.append(thread)
|
|
300
|
+
|
|
301
|
+
def _reflect(self, goal: str, messages: list[Any], outcome: str,
|
|
302
|
+
episode_id: int) -> None:
|
|
303
|
+
model = self.config.learning.reflect_model or self.config.active_model()
|
|
304
|
+
result = reflection_module.reflect(
|
|
305
|
+
gateway=self.gateway, model=model, goal=goal, messages=messages,
|
|
306
|
+
outcome=outcome, scope=self.write_scope, source=f"episode:{episode_id}",
|
|
307
|
+
)
|
|
308
|
+
if result.empty:
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
with self._reflect_lock:
|
|
312
|
+
learned, merged = self._absorb(result.lessons)
|
|
313
|
+
if result.skill is not None:
|
|
314
|
+
self.store.add_skill(result.skill)
|
|
315
|
+
|
|
316
|
+
if learned or merged or result.skill:
|
|
317
|
+
self.bus.emit(
|
|
318
|
+
Kind.MEMORY, action="learned",
|
|
319
|
+
items=[lesson.as_dict() for lesson in learned],
|
|
320
|
+
merged=merged,
|
|
321
|
+
skill=result.skill.name if result.skill else "",
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def _absorb(self, lessons: list[Lesson]) -> tuple[list[Lesson], int]:
|
|
325
|
+
"""Store new lessons, merging anything we already know."""
|
|
326
|
+
stored: list[Lesson] = []
|
|
327
|
+
merged = 0
|
|
328
|
+
for lesson in lessons:
|
|
329
|
+
existing = self.store.find_similar(lesson.text, threshold=0.55,
|
|
330
|
+
scopes=self.scopes)
|
|
331
|
+
if existing is not None:
|
|
332
|
+
# Seeing the same thing twice is evidence, not a duplicate.
|
|
333
|
+
existing.confidence = min(0.98, existing.confidence + 0.08)
|
|
334
|
+
existing.wins += 1
|
|
335
|
+
self.store.update_lesson(existing)
|
|
336
|
+
merged += 1
|
|
337
|
+
continue
|
|
338
|
+
stored.append(self.store.add_lesson(lesson))
|
|
339
|
+
return stored, merged
|
|
340
|
+
|
|
341
|
+
def wait_for_reflection(self, timeout: float = 30.0) -> None:
|
|
342
|
+
"""Block until background reflection settles — used by tests and exit."""
|
|
343
|
+
deadline = time.monotonic() + timeout
|
|
344
|
+
for thread in list(self._threads):
|
|
345
|
+
remaining = deadline - time.monotonic()
|
|
346
|
+
if remaining <= 0:
|
|
347
|
+
break
|
|
348
|
+
thread.join(timeout=remaining)
|
|
349
|
+
self._threads = [thread for thread in self._threads if thread.is_alive()]
|
|
350
|
+
|
|
351
|
+
# -- 5. consolidate --------------------------------------------------- #
|
|
352
|
+
|
|
353
|
+
def consolidate(self) -> int:
|
|
354
|
+
learning = self.config.learning
|
|
355
|
+
return self.store.consolidate(learning.min_confidence, learning.half_life_days)
|
|
356
|
+
|
|
357
|
+
# -- user-facing controls --------------------------------------------- #
|
|
358
|
+
|
|
359
|
+
def teach(self, text: str, kind: str = "preference", pinned: bool = True) -> Lesson:
|
|
360
|
+
"""Record something the user stated directly.
|
|
361
|
+
|
|
362
|
+
Pinned by default and given high confidence: an explicit instruction is
|
|
363
|
+
much better evidence than anything the agent infers on its own.
|
|
364
|
+
"""
|
|
365
|
+
trigger, _, guidance = text.partition(":")
|
|
366
|
+
if not guidance:
|
|
367
|
+
trigger, guidance = "generally", text
|
|
368
|
+
lesson = Lesson(
|
|
369
|
+
kind=kind, scope=self.write_scope,
|
|
370
|
+
trigger=trigger.strip()[:300] or "generally",
|
|
371
|
+
guidance=guidance.strip()[:600],
|
|
372
|
+
confidence=0.9, pinned=pinned, source="user",
|
|
373
|
+
)
|
|
374
|
+
stored = self.store.add_lesson(lesson)
|
|
375
|
+
self.bus.emit(Kind.MEMORY, action="taught", items=[stored.as_dict()])
|
|
376
|
+
return stored
|
|
377
|
+
|
|
378
|
+
def forget(self, lesson_id: int) -> bool:
|
|
379
|
+
removed = self.store.delete_lesson(lesson_id)
|
|
380
|
+
if removed:
|
|
381
|
+
self.bus.emit(Kind.MEMORY, action="forgot", id=lesson_id)
|
|
382
|
+
return removed
|
|
383
|
+
|
|
384
|
+
def pin(self, lesson_id: int, pinned: bool = True) -> bool:
|
|
385
|
+
for lesson in self.store.all_lessons():
|
|
386
|
+
if lesson.id == lesson_id:
|
|
387
|
+
lesson.pinned = pinned
|
|
388
|
+
self.store.update_lesson(lesson)
|
|
389
|
+
return True
|
|
390
|
+
return False
|
|
391
|
+
|
|
392
|
+
def feedback(self, lessons: list[Lesson], good: bool, note: str = "") -> None:
|
|
393
|
+
"""Explicit /good or /bad on the last answer."""
|
|
394
|
+
if lessons:
|
|
395
|
+
self.store.credit([lesson.id for lesson in lessons], won=good)
|
|
396
|
+
for lesson in lessons:
|
|
397
|
+
self.store.add_feedback("lesson", lesson.id, 1.0 if good else -1.0, note)
|
|
398
|
+
self.bus.emit(Kind.MEMORY, action="feedback", good=good,
|
|
399
|
+
count=len(lessons), note=note)
|
|
400
|
+
|
|
401
|
+
def search(self, query: str, limit: int = 20) -> list[Lesson]:
|
|
402
|
+
if not query.strip():
|
|
403
|
+
return self.store.all_lessons(self.scopes)[:limit]
|
|
404
|
+
return [lesson for lesson, _ in
|
|
405
|
+
self.store.search_lessons(query, self.scopes, limit=limit)]
|
|
406
|
+
|
|
407
|
+
# -- house rules ------------------------------------------------------ #
|
|
408
|
+
|
|
409
|
+
def all_rules(self) -> list[Rule]:
|
|
410
|
+
return self.store.all_rules(self.scopes)
|
|
411
|
+
|
|
412
|
+
def teach_rule(self, statement: str) -> Rule:
|
|
413
|
+
"""A rule the user stated outright, which outranks anything inferred."""
|
|
414
|
+
key = "user." + "-".join(statement.lower().split()[:4])[:60]
|
|
415
|
+
rule = self.store.observe_rule(
|
|
416
|
+
key=key, scope=self.write_scope, category="preference",
|
|
417
|
+
statement=statement.strip()[:300], detail="you told me directly",
|
|
418
|
+
source="user", weight=3)
|
|
419
|
+
self.bus.emit(Kind.MEMORY, action="rule", items=[rule.as_dict()])
|
|
420
|
+
return rule
|
|
421
|
+
|
|
422
|
+
def forget_rule(self, rule_id: int) -> bool:
|
|
423
|
+
removed = self.store.delete_rule(rule_id)
|
|
424
|
+
if removed:
|
|
425
|
+
self.bus.emit(Kind.MEMORY, action="forgot_rule", id=rule_id)
|
|
426
|
+
return removed
|
|
427
|
+
|
|
428
|
+
def set_rule(self, rule_id: int, *, pinned: bool | None = None,
|
|
429
|
+
active: bool | None = None) -> bool:
|
|
430
|
+
return self.store.set_rule_flags(rule_id, pinned=pinned, active=active)
|
|
431
|
+
|
|
432
|
+
def export_rules(self, target: Path | None = None) -> Path:
|
|
433
|
+
"""Write the learned conventions where a team can read and commit them."""
|
|
434
|
+
destination = Path(target) if target else (
|
|
435
|
+
self.config.paths.project_dir / "house-rules.md")
|
|
436
|
+
rules = [rule for rule in self.all_rules() if rule.confident]
|
|
437
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
438
|
+
destination.write_text(
|
|
439
|
+
rules_module.export_markdown(rules, project=str(self.config.paths.project)),
|
|
440
|
+
encoding="utf-8")
|
|
441
|
+
return destination
|
|
442
|
+
|
|
443
|
+
def stats(self) -> dict[str, Any]:
|
|
444
|
+
data = self.store.stats()
|
|
445
|
+
data["scope"] = self.project_scope
|
|
446
|
+
return data
|
|
447
|
+
|
|
448
|
+
def close(self) -> None:
|
|
449
|
+
self.wait_for_reflection(timeout=5.0)
|
|
450
|
+
self.store.close()
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Evidence that the agent is actually improving.
|
|
2
|
+
|
|
3
|
+
"Gets better over time" is the sort of claim every tool makes and none of them
|
|
4
|
+
support. This module exists so Comodor can support it: every finished task
|
|
5
|
+
records how many steps it took, how often it had to ask, how many corrections
|
|
6
|
+
followed, and what it cost. Comparing the first stretch of tasks against the
|
|
7
|
+
most recent turns that into a number.
|
|
8
|
+
|
|
9
|
+
The measures are deliberately the ones a user feels rather than the ones that
|
|
10
|
+
flatter the tool. Fewer steps means less waiting. Fewer corrections means the
|
|
11
|
+
output needed less fixing. Fewer approvals means it stopped asking about things
|
|
12
|
+
you had already approved. If those do not move, the learning is not working, and
|
|
13
|
+
this panel will say so.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
|
|
21
|
+
from .store import Episode
|
|
22
|
+
|
|
23
|
+
SPARK = "▁▂▃▄▅▆▇█"
|
|
24
|
+
SPARK_ASCII = ".:-=+*#%"
|
|
25
|
+
MIN_FOR_TREND = 6 # below this, a "trend" is just noise
|
|
26
|
+
# A count metric has to have started above this to headline a claim; a fall from
|
|
27
|
+
# 0.4 to 0 is a real 100% but not a real improvement.
|
|
28
|
+
MEANINGFUL_BASE = 1.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Series:
|
|
33
|
+
"""One measured quantity over time."""
|
|
34
|
+
|
|
35
|
+
key: str
|
|
36
|
+
label: str
|
|
37
|
+
values: list[float] = field(default_factory=list)
|
|
38
|
+
lower_is_better: bool = True
|
|
39
|
+
unit: str = ""
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def current(self) -> float:
|
|
43
|
+
return self.values[-1] if self.values else 0.0
|
|
44
|
+
|
|
45
|
+
def window(self, fraction: float = 0.3) -> tuple[float, float]:
|
|
46
|
+
"""Mean of the earliest and latest slices, for a first-versus-now delta."""
|
|
47
|
+
if len(self.values) < MIN_FOR_TREND:
|
|
48
|
+
return 0.0, 0.0
|
|
49
|
+
size = max(2, int(len(self.values) * fraction))
|
|
50
|
+
early = self.values[:size]
|
|
51
|
+
late = self.values[-size:]
|
|
52
|
+
return sum(early) / len(early), sum(late) / len(late)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def change(self) -> float:
|
|
56
|
+
"""Relative change from the early window to the late one, in percent."""
|
|
57
|
+
early, late = self.window()
|
|
58
|
+
if early <= 0:
|
|
59
|
+
return 0.0
|
|
60
|
+
return (late - early) / early * 100.0
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def improving(self) -> bool | None:
|
|
64
|
+
"""True, False, or None when there is not enough evidence yet."""
|
|
65
|
+
if len(self.values) < MIN_FOR_TREND:
|
|
66
|
+
return None
|
|
67
|
+
delta = self.change
|
|
68
|
+
if abs(delta) < 5.0:
|
|
69
|
+
return None # flat is not a direction
|
|
70
|
+
return delta < 0 if self.lower_is_better else delta > 0
|
|
71
|
+
|
|
72
|
+
def sparkline(self, width: int = 24, ascii_only: bool = False) -> str:
|
|
73
|
+
"""A compact plot of the series, bucketed to fit the width available."""
|
|
74
|
+
glyphs = SPARK_ASCII if ascii_only else SPARK
|
|
75
|
+
if not self.values:
|
|
76
|
+
return ""
|
|
77
|
+
points = _bucket(self.values, width)
|
|
78
|
+
low, high = min(points), max(points)
|
|
79
|
+
if high - low < 1e-9:
|
|
80
|
+
return glyphs[len(glyphs) // 2] * len(points)
|
|
81
|
+
span = high - low
|
|
82
|
+
return "".join(
|
|
83
|
+
glyphs[min(len(glyphs) - 1, int((value - low) / span * len(glyphs)))]
|
|
84
|
+
for value in points
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _bucket(values: list[float], width: int) -> list[float]:
|
|
89
|
+
"""Average values into at most ``width`` buckets."""
|
|
90
|
+
if len(values) <= width:
|
|
91
|
+
return list(values)
|
|
92
|
+
size = len(values) / width
|
|
93
|
+
buckets: list[float] = []
|
|
94
|
+
for index in range(width):
|
|
95
|
+
start = int(index * size)
|
|
96
|
+
end = max(start + 1, int((index + 1) * size))
|
|
97
|
+
chunk = values[start:end]
|
|
98
|
+
buckets.append(sum(chunk) / len(chunk))
|
|
99
|
+
return buckets
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class Progress:
|
|
104
|
+
"""Everything the dashboard shows."""
|
|
105
|
+
|
|
106
|
+
episodes: int = 0
|
|
107
|
+
series: list[Series] = field(default_factory=list)
|
|
108
|
+
success_rate: float = 0.0
|
|
109
|
+
early_success_rate: float = 0.0
|
|
110
|
+
rules_active: int = 0
|
|
111
|
+
lessons: int = 0
|
|
112
|
+
corrections_total: int = 0
|
|
113
|
+
since: float = 0.0
|
|
114
|
+
days: float = 0.0
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def enough_data(self) -> bool:
|
|
118
|
+
return self.episodes >= MIN_FOR_TREND
|
|
119
|
+
|
|
120
|
+
def get(self, key: str) -> Series | None:
|
|
121
|
+
return next((series for series in self.series if series.key == key), None)
|
|
122
|
+
|
|
123
|
+
def headline(self) -> str:
|
|
124
|
+
"""One sentence a user could screenshot, or an honest 'not yet'.
|
|
125
|
+
|
|
126
|
+
Candidates are considered in the order a user actually feels them, and
|
|
127
|
+
a metric is only allowed to lead if it started from a base worth
|
|
128
|
+
talking about. "Tool errors down 100%" sounds impressive and means
|
|
129
|
+
almost nothing when the early average was 0.4 per task.
|
|
130
|
+
"""
|
|
131
|
+
if not self.enough_data:
|
|
132
|
+
remaining = MIN_FOR_TREND - self.episodes
|
|
133
|
+
return (f"{self.episodes} task{'s' if self.episodes != 1 else ''} recorded — "
|
|
134
|
+
f"{remaining} more before a trend means anything.")
|
|
135
|
+
|
|
136
|
+
for key in ("steps", "corrections", "tokens", "approvals", "retries", "success"):
|
|
137
|
+
series = self.get(key)
|
|
138
|
+
if series is None or series.improving is not True:
|
|
139
|
+
continue
|
|
140
|
+
early, _ = series.window()
|
|
141
|
+
if key != "success" and early < MEANINGFUL_BASE:
|
|
142
|
+
continue
|
|
143
|
+
direction = "down" if series.lower_is_better else "up"
|
|
144
|
+
return (f"{series.label} {direction} {abs(series.change):.0f}% "
|
|
145
|
+
f"since the first tasks in this project.")
|
|
146
|
+
|
|
147
|
+
return "No measurable change yet. The numbers below are the honest ones."
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def analyse(episodes: list[Episode], lessons: int = 0,
|
|
151
|
+
rules_active: int = 0) -> Progress:
|
|
152
|
+
"""Turn stored episodes into the series behind the dashboard."""
|
|
153
|
+
usable = [episode for episode in episodes if episode.stopped != "cancelled"]
|
|
154
|
+
progress = Progress(episodes=len(usable), lessons=lessons,
|
|
155
|
+
rules_active=rules_active)
|
|
156
|
+
if not usable:
|
|
157
|
+
return progress
|
|
158
|
+
|
|
159
|
+
progress.since = usable[0].created_at
|
|
160
|
+
progress.days = max(0.0, (time.time() - progress.since) / 86400.0)
|
|
161
|
+
progress.corrections_total = sum(episode.corrections for episode in usable)
|
|
162
|
+
|
|
163
|
+
successes = [1.0 if episode.success else 0.0 for episode in usable]
|
|
164
|
+
progress.success_rate = sum(successes) / len(successes)
|
|
165
|
+
early = successes[: max(2, int(len(successes) * 0.3))]
|
|
166
|
+
progress.early_success_rate = sum(early) / len(early)
|
|
167
|
+
|
|
168
|
+
progress.series = [
|
|
169
|
+
Series("steps", "Steps per task",
|
|
170
|
+
[float(episode.steps) for episode in usable], unit=""),
|
|
171
|
+
Series("corrections", "Corrections per task",
|
|
172
|
+
[float(episode.corrections) for episode in usable]),
|
|
173
|
+
Series("approvals", "Approvals asked",
|
|
174
|
+
[float(episode.approvals_asked) for episode in usable]),
|
|
175
|
+
Series("retries", "Tool errors per task",
|
|
176
|
+
[float(episode.retries) for episode in usable]),
|
|
177
|
+
Series("tokens", "Tokens per task",
|
|
178
|
+
[float(episode.tokens) for episode in usable]),
|
|
179
|
+
Series("success", "First-try success", successes, lower_is_better=False),
|
|
180
|
+
]
|
|
181
|
+
# A series that is zero throughout says nothing; leave it out rather than
|
|
182
|
+
# pad the panel with flat lines.
|
|
183
|
+
progress.series = [series for series in progress.series if any(series.values)]
|
|
184
|
+
return progress
|