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