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,992 @@
1
+ """The brain's storage: SQLite, with FTS5 when the build has it.
2
+
3
+ Everything Comodor learns lives in one file at ``~/.comodor/brain.db``, shared
4
+ across every project. That is the point — a lesson about how the user likes
5
+ commit messages, or about a library that always needs an extra flag, should not
6
+ have to be relearned in the next repository. Project-specific findings are kept
7
+ apart by their ``scope`` instead.
8
+
9
+ Retrieval uses FTS5's built-in ``bm25()`` where available and falls back to the
10
+ pure-Python index otherwise, so the feature degrades rather than disappears on
11
+ an unusual Python build.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import math
18
+ import sqlite3
19
+ import threading
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any, Iterable
24
+
25
+ from .bm25 import BM25Index, coverage, similarity
26
+ from .hotindex import HotIndex
27
+ from .writer import AsyncWriter
28
+
29
+ SCHEMA_VERSION = 2
30
+ DAY = 86400.0
31
+
32
+ # How many agreeing observations a rule needs before it shapes the prompt,
33
+ # by where the evidence came from.
34
+ FLOORS: dict[str, int] = {
35
+ "user": 1, # stated outright
36
+ "correction": 2, # the user changed what the agent produced, or refused it
37
+ "evidence": 2, # the environment demonstrated it, more than once
38
+ "observation": 4, # merely how the existing code happens to look
39
+ }
40
+
41
+ # WAL keeps readers from blocking the writer; the memory settings matter because
42
+ # recall reads the same few pages thousands of times in a session and there is no
43
+ # reason for them ever to leave RAM.
44
+ PRAGMAS: dict[str, str] = {
45
+ "journal_mode": "WAL",
46
+ "synchronous": "NORMAL",
47
+ "temp_store": "MEMORY",
48
+ "cache_size": "-16000", # ~16 MB, negative means kibibytes
49
+ "mmap_size": "268435456", # 256 MB
50
+ "busy_timeout": "5000",
51
+ }
52
+
53
+
54
+ # --------------------------------------------------------------------------- #
55
+ # records
56
+ # --------------------------------------------------------------------------- #
57
+
58
+
59
+ @dataclass
60
+ class Lesson:
61
+ """One durable thing the agent has learned."""
62
+
63
+ id: int = 0
64
+ kind: str = "heuristic" # preference | fact | heuristic | pitfall | env
65
+ scope: str = "global" # global | project:<key> | lang:<x> | tool:<x>
66
+ trigger: str = "" # when it applies
67
+ guidance: str = "" # what to do
68
+ confidence: float = 0.5
69
+ uses: int = 0
70
+ wins: int = 0
71
+ losses: int = 0
72
+ pinned: bool = False
73
+ source: str = ""
74
+ created_at: float = field(default_factory=time.time)
75
+ updated_at: float = field(default_factory=time.time)
76
+ last_used: float = 0.0
77
+
78
+ @property
79
+ def text(self) -> str:
80
+ return f"{self.trigger} {self.guidance}".strip()
81
+
82
+ def effective_confidence(self, half_life_days: float = 45.0) -> float:
83
+ """Confidence after decay, and after the win/loss record is folded in.
84
+
85
+ A lesson that keeps being present when things go well grows more
86
+ trusted; one that is around for failures fades. Age decays everything,
87
+ so stale beliefs about a moving codebase do not linger forever.
88
+ """
89
+ if self.pinned:
90
+ return 1.0
91
+ evidence = (self.wins + 1) / (self.wins + self.losses + 2) # Laplace
92
+ blended = 0.5 * self.confidence + 0.5 * evidence
93
+ age_days = max(0.0, (time.time() - self.updated_at) / DAY)
94
+ decay = 0.5 ** (age_days / max(1.0, half_life_days))
95
+ return max(0.0, min(1.0, blended * decay))
96
+
97
+ def as_dict(self) -> dict[str, Any]:
98
+ return {
99
+ "id": self.id, "kind": self.kind, "scope": self.scope,
100
+ "trigger": self.trigger, "guidance": self.guidance,
101
+ "confidence": round(self.effective_confidence(), 3),
102
+ "uses": self.uses, "wins": self.wins, "losses": self.losses,
103
+ "pinned": self.pinned,
104
+ }
105
+
106
+
107
+ @dataclass
108
+ class Skill:
109
+ """A reusable procedure distilled from a successful multi-step task."""
110
+
111
+ id: int = 0
112
+ name: str = ""
113
+ description: str = ""
114
+ steps: list[str] = field(default_factory=list)
115
+ tags: list[str] = field(default_factory=list)
116
+ scope: str = "global"
117
+ uses: int = 0
118
+ wins: int = 0
119
+ losses: int = 0
120
+ avg_duration: float = 0.0
121
+ created_at: float = field(default_factory=time.time)
122
+ last_used: float = 0.0
123
+
124
+ @property
125
+ def text(self) -> str:
126
+ return f"{self.name} {self.description} {' '.join(self.tags)} {' '.join(self.steps)}"
127
+
128
+ @property
129
+ def success_rate(self) -> float:
130
+ total = self.wins + self.losses
131
+ return (self.wins / total) if total else 0.5
132
+
133
+ def as_dict(self) -> dict[str, Any]:
134
+ return {"id": self.id, "name": self.name, "description": self.description,
135
+ "steps": self.steps, "tags": self.tags, "uses": self.uses,
136
+ "success_rate": round(self.success_rate, 2)}
137
+
138
+
139
+ @dataclass
140
+ class Rule:
141
+ """A convention learned by observation rather than by asking a model.
142
+
143
+ Rules differ from lessons in where they come from and how much they are
144
+ trusted. A lesson is prose a model wrote about a task; a rule is a counted
145
+ fact about how this user works — "31 of 34 string literals use single
146
+ quotes" — so it carries its evidence and can be shown as a number.
147
+ """
148
+
149
+ id: int = 0
150
+ category: str = "style" # style | workflow | preference | avoid
151
+ key: str = "" # stable identity, e.g. "python.quotes"
152
+ statement: str = "" # the instruction given to the model
153
+ detail: str = "" # human-readable evidence
154
+ scope: str = "global"
155
+ support: int = 0 # observations agreeing
156
+ against: int = 0 # observations disagreeing
157
+ source: str = "observation" # observation | correction | user
158
+ pinned: bool = False
159
+ active: bool = True
160
+ created_at: float = field(default_factory=time.time)
161
+ updated_at: float = field(default_factory=time.time)
162
+
163
+ @property
164
+ def text(self) -> str:
165
+ return f"{self.key} {self.statement} {self.detail}".strip()
166
+
167
+ @property
168
+ def total(self) -> int:
169
+ return self.support + self.against
170
+
171
+ @property
172
+ def strength(self) -> float:
173
+ """Agreement rate, smoothed so one observation is not proof."""
174
+ return (self.support + 1) / (self.total + 2)
175
+
176
+ @property
177
+ def confident(self) -> bool:
178
+ """Whether this rule has earned a place in the prompt.
179
+
180
+ How much evidence is needed depends on where it came from. Watching the
181
+ codebase is weak evidence — plenty of code is written a certain way by
182
+ accident. A user editing what the agent produced, or refusing a command,
183
+ is a deliberate statement and needs far less repetition. Something the
184
+ environment demonstrated twice sits in between, but is verified fact.
185
+ """
186
+ if self.pinned or self.source == "user":
187
+ return True
188
+ floor = FLOORS.get(self.source, 4)
189
+ return self.support >= floor and self.strength >= 0.7
190
+
191
+ def as_dict(self) -> dict[str, Any]:
192
+ return {"id": self.id, "category": self.category, "key": self.key,
193
+ "statement": self.statement, "detail": self.detail,
194
+ "support": self.support, "against": self.against,
195
+ "strength": round(self.strength, 2), "source": self.source,
196
+ "pinned": self.pinned, "active": self.active}
197
+
198
+
199
+ @dataclass
200
+ class Signal:
201
+ """One observed user action that carries a preference."""
202
+
203
+ id: int = 0
204
+ kind: str = "" # correction | undo | denial | rephrase | retry
205
+ session_id: str = ""
206
+ episode_id: int = 0
207
+ subject: str = "" # file path, command, tool name
208
+ payload: str = "" # the diff or detail, already redacted
209
+ weight: float = 1.0
210
+ created_at: float = field(default_factory=time.time)
211
+
212
+
213
+ @dataclass
214
+ class Episode:
215
+ """One completed task, kept so reflection has something to look at."""
216
+
217
+ id: int = 0
218
+ session_id: str = ""
219
+ goal: str = ""
220
+ scope: str = "global"
221
+ success: bool = True
222
+ stopped: str = "done"
223
+ steps: int = 0
224
+ elapsed: float = 0.0
225
+ tools_used: list[str] = field(default_factory=list)
226
+ error_kind: str = ""
227
+ created_at: float = field(default_factory=time.time)
228
+ # Metrics behind /progress — the numbers that make "it improves" checkable.
229
+ corrections: int = 0
230
+ approvals_asked: int = 0
231
+ retries: int = 0
232
+ tokens: int = 0
233
+ rules_active: int = 0
234
+
235
+
236
+ # --------------------------------------------------------------------------- #
237
+ # the store
238
+ # --------------------------------------------------------------------------- #
239
+
240
+
241
+ class BrainStore:
242
+ """SQLite-backed persistence with full-text retrieval."""
243
+
244
+ def __init__(self, path: Path | str, async_writes: bool = True) -> None:
245
+ self.path = Path(path)
246
+ self.path.parent.mkdir(parents=True, exist_ok=True)
247
+ self._local = threading.local()
248
+ self._lock = threading.Lock()
249
+ self.fts_available = False
250
+ self._fallback = BM25Index()
251
+ self._fallback_loaded = False
252
+ self.hot = HotIndex()
253
+ self._migrate()
254
+
255
+ # Reinforcement and signals go through a background writer so nothing
256
+ # user-facing ever waits on a commit. Inserts that need their row id
257
+ # back still run inline — they are rare and the caller needs the id.
258
+ self.writer = AsyncWriter(str(self.path), PRAGMAS) if async_writes else None
259
+ if self.writer is not None:
260
+ self.writer.start()
261
+ self.warm()
262
+
263
+ # -- connection ------------------------------------------------------- #
264
+
265
+ @property
266
+ def connection(self) -> sqlite3.Connection:
267
+ """One connection per thread — SQLite objects are not shareable."""
268
+ existing = getattr(self._local, "connection", None)
269
+ if existing is None:
270
+ existing = sqlite3.connect(self.path, timeout=15.0)
271
+ existing.row_factory = sqlite3.Row
272
+ for name, value in PRAGMAS.items():
273
+ try:
274
+ existing.execute(f"PRAGMA {name}={value}")
275
+ except sqlite3.Error:
276
+ continue
277
+ self._local.connection = existing
278
+ return existing
279
+
280
+ def close(self) -> None:
281
+ if self.writer is not None:
282
+ self.writer.stop()
283
+ existing = getattr(self._local, "connection", None)
284
+ if existing is not None:
285
+ existing.close()
286
+ self._local.connection = None
287
+
288
+ def flush(self, timeout: float = 3.0) -> bool:
289
+ """Wait for queued writes to land. Used at exit and in tests."""
290
+ return self.writer.flush(timeout) if self.writer is not None else True
291
+
292
+ # -- the RAM mirror --------------------------------------------------- #
293
+
294
+ def warm(self) -> int:
295
+ """Load every record into the hot index. Milliseconds, once, at startup."""
296
+ records = [("lesson", lesson.id, lesson.text, lesson.scope)
297
+ for lesson in self.all_lessons()]
298
+ records += [("rule", rule.id, rule.text, rule.scope)
299
+ for rule in self.all_rules()]
300
+ return self.hot.rebuild(records)
301
+
302
+ # -- schema ----------------------------------------------------------- #
303
+
304
+ def _migrate(self) -> None:
305
+ connection = self.connection
306
+ with self._lock, connection:
307
+ connection.executescript(
308
+ """
309
+ CREATE TABLE IF NOT EXISTS meta (
310
+ key TEXT PRIMARY KEY, value TEXT
311
+ );
312
+ CREATE TABLE IF NOT EXISTS lessons (
313
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
314
+ kind TEXT NOT NULL DEFAULT 'heuristic',
315
+ scope TEXT NOT NULL DEFAULT 'global',
316
+ trigger_text TEXT NOT NULL,
317
+ guidance TEXT NOT NULL,
318
+ confidence REAL NOT NULL DEFAULT 0.5,
319
+ uses INTEGER NOT NULL DEFAULT 0,
320
+ wins INTEGER NOT NULL DEFAULT 0,
321
+ losses INTEGER NOT NULL DEFAULT 0,
322
+ pinned INTEGER NOT NULL DEFAULT 0,
323
+ source TEXT DEFAULT '',
324
+ created_at REAL NOT NULL,
325
+ updated_at REAL NOT NULL,
326
+ last_used REAL NOT NULL DEFAULT 0
327
+ );
328
+ CREATE INDEX IF NOT EXISTS idx_lessons_scope ON lessons(scope);
329
+ -- Partial: pinned lessons are fetched on every single recall,
330
+ -- and there are only ever a handful of them.
331
+ CREATE INDEX IF NOT EXISTS idx_lessons_pinned ON lessons(scope)
332
+ WHERE pinned = 1;
333
+ CREATE TABLE IF NOT EXISTS skills (
334
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
335
+ name TEXT NOT NULL UNIQUE,
336
+ description TEXT NOT NULL DEFAULT '',
337
+ steps TEXT NOT NULL DEFAULT '[]',
338
+ tags TEXT NOT NULL DEFAULT '[]',
339
+ scope TEXT NOT NULL DEFAULT 'global',
340
+ uses INTEGER NOT NULL DEFAULT 0,
341
+ wins INTEGER NOT NULL DEFAULT 0,
342
+ losses INTEGER NOT NULL DEFAULT 0,
343
+ avg_duration REAL NOT NULL DEFAULT 0,
344
+ created_at REAL NOT NULL,
345
+ last_used REAL NOT NULL DEFAULT 0
346
+ );
347
+ CREATE TABLE IF NOT EXISTS episodes (
348
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
349
+ session_id TEXT NOT NULL DEFAULT '',
350
+ goal TEXT NOT NULL DEFAULT '',
351
+ scope TEXT NOT NULL DEFAULT 'global',
352
+ success INTEGER NOT NULL DEFAULT 1,
353
+ stopped TEXT NOT NULL DEFAULT 'done',
354
+ steps INTEGER NOT NULL DEFAULT 0,
355
+ elapsed REAL NOT NULL DEFAULT 0,
356
+ tools_used TEXT NOT NULL DEFAULT '[]',
357
+ error_kind TEXT NOT NULL DEFAULT '',
358
+ created_at REAL NOT NULL
359
+ );
360
+ CREATE TABLE IF NOT EXISTS feedback (
361
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
362
+ target_kind TEXT NOT NULL,
363
+ target_id INTEGER NOT NULL,
364
+ signal REAL NOT NULL,
365
+ note TEXT DEFAULT '',
366
+ created_at REAL NOT NULL
367
+ );
368
+ CREATE TABLE IF NOT EXISTS rules (
369
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
370
+ category TEXT NOT NULL DEFAULT 'style',
371
+ key TEXT NOT NULL,
372
+ statement TEXT NOT NULL DEFAULT '',
373
+ detail TEXT NOT NULL DEFAULT '',
374
+ scope TEXT NOT NULL DEFAULT 'global',
375
+ support INTEGER NOT NULL DEFAULT 0,
376
+ against INTEGER NOT NULL DEFAULT 0,
377
+ source TEXT NOT NULL DEFAULT 'observation',
378
+ pinned INTEGER NOT NULL DEFAULT 0,
379
+ active INTEGER NOT NULL DEFAULT 1,
380
+ created_at REAL NOT NULL,
381
+ updated_at REAL NOT NULL,
382
+ UNIQUE(key, scope)
383
+ );
384
+ CREATE TABLE IF NOT EXISTS signals (
385
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
386
+ kind TEXT NOT NULL,
387
+ session_id TEXT NOT NULL DEFAULT '',
388
+ episode_id INTEGER NOT NULL DEFAULT 0,
389
+ subject TEXT NOT NULL DEFAULT '',
390
+ payload TEXT NOT NULL DEFAULT '',
391
+ weight REAL NOT NULL DEFAULT 1.0,
392
+ created_at REAL NOT NULL
393
+ );
394
+ CREATE INDEX IF NOT EXISTS idx_signals_kind ON signals(kind, created_at);
395
+ CREATE INDEX IF NOT EXISTS idx_episodes_created ON episodes(created_at);
396
+ """
397
+ )
398
+ self._add_missing_columns(connection)
399
+ connection.execute(
400
+ "INSERT OR REPLACE INTO meta(key, value) VALUES('schema', ?)",
401
+ (str(SCHEMA_VERSION),),
402
+ )
403
+
404
+ self._setup_fts()
405
+
406
+ @staticmethod
407
+ def _add_missing_columns(connection: sqlite3.Connection) -> None:
408
+ """Bring an older brain forward without losing what it has learned.
409
+
410
+ A brain accumulated over months is the whole point of this feature, so
411
+ upgrades add columns in place rather than rebuilding the database.
412
+ """
413
+ additions = {
414
+ "episodes": [
415
+ ("corrections", "INTEGER NOT NULL DEFAULT 0"),
416
+ ("approvals_asked", "INTEGER NOT NULL DEFAULT 0"),
417
+ ("retries", "INTEGER NOT NULL DEFAULT 0"),
418
+ ("tokens", "INTEGER NOT NULL DEFAULT 0"),
419
+ ("rules_active", "INTEGER NOT NULL DEFAULT 0"),
420
+ ],
421
+ }
422
+ for table, columns in additions.items():
423
+ existing = {row[1] for row in connection.execute(f"PRAGMA table_info({table})")}
424
+ for name, definition in columns:
425
+ if name not in existing:
426
+ connection.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
427
+
428
+ def _setup_fts(self) -> None:
429
+ """Create the FTS mirrors, or fall back to the Python index."""
430
+ connection = self.connection
431
+ try:
432
+ with self._lock, connection:
433
+ connection.executescript(
434
+ """
435
+ CREATE VIRTUAL TABLE IF NOT EXISTS lessons_fts
436
+ USING fts5(trigger_text, guidance, content='lessons',
437
+ content_rowid='id', tokenize='porter unicode61');
438
+ CREATE TRIGGER IF NOT EXISTS lessons_ai AFTER INSERT ON lessons BEGIN
439
+ INSERT INTO lessons_fts(rowid, trigger_text, guidance)
440
+ VALUES (new.id, new.trigger_text, new.guidance);
441
+ END;
442
+ CREATE TRIGGER IF NOT EXISTS lessons_ad AFTER DELETE ON lessons BEGIN
443
+ INSERT INTO lessons_fts(lessons_fts, rowid, trigger_text, guidance)
444
+ VALUES ('delete', old.id, old.trigger_text, old.guidance);
445
+ END;
446
+ CREATE TRIGGER IF NOT EXISTS lessons_au AFTER UPDATE ON lessons BEGIN
447
+ INSERT INTO lessons_fts(lessons_fts, rowid, trigger_text, guidance)
448
+ VALUES ('delete', old.id, old.trigger_text, old.guidance);
449
+ INSERT INTO lessons_fts(rowid, trigger_text, guidance)
450
+ VALUES (new.id, new.trigger_text, new.guidance);
451
+ END;
452
+ CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts
453
+ USING fts5(name, description, body, content='');
454
+ """
455
+ )
456
+ self.fts_available = True
457
+ except sqlite3.OperationalError:
458
+ # No FTS5 in this build: the Python BM25 index takes over.
459
+ self.fts_available = False
460
+
461
+ # -- lessons: write --------------------------------------------------- #
462
+
463
+ def add_lesson(self, lesson: Lesson) -> Lesson:
464
+ now = time.time()
465
+ with self._lock, self.connection as connection:
466
+ cursor = connection.execute(
467
+ """INSERT INTO lessons(kind, scope, trigger_text, guidance, confidence,
468
+ uses, wins, losses, pinned, source,
469
+ created_at, updated_at, last_used)
470
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
471
+ (lesson.kind, lesson.scope, lesson.trigger, lesson.guidance,
472
+ lesson.confidence, lesson.uses, lesson.wins, lesson.losses,
473
+ int(lesson.pinned), lesson.source, now, now, lesson.last_used),
474
+ )
475
+ lesson.id = int(cursor.lastrowid or 0)
476
+ lesson.created_at = lesson.updated_at = now
477
+ self.hot.add("lesson", lesson.id, lesson.text, lesson.scope)
478
+ if not self.fts_available:
479
+ self._fallback.add(f"lesson:{lesson.id}", lesson.text)
480
+ return lesson
481
+
482
+ def update_lesson(self, lesson: Lesson) -> None:
483
+ with self._lock, self.connection as connection:
484
+ connection.execute(
485
+ """UPDATE lessons SET kind=?, scope=?, trigger_text=?, guidance=?,
486
+ confidence=?, uses=?, wins=?, losses=?, pinned=?,
487
+ updated_at=?, last_used=? WHERE id=?""",
488
+ (lesson.kind, lesson.scope, lesson.trigger, lesson.guidance,
489
+ lesson.confidence, lesson.uses, lesson.wins, lesson.losses,
490
+ int(lesson.pinned), time.time(), lesson.last_used, lesson.id),
491
+ )
492
+ self.hot.add("lesson", lesson.id, lesson.text, lesson.scope)
493
+ if not self.fts_available:
494
+ self._fallback.add(f"lesson:{lesson.id}", lesson.text)
495
+
496
+ def delete_lesson(self, lesson_id: int) -> bool:
497
+ with self._lock, self.connection as connection:
498
+ cursor = connection.execute("DELETE FROM lessons WHERE id=?", (lesson_id,))
499
+ self.hot.remove("lesson", lesson_id)
500
+ self._fallback.remove(f"lesson:{lesson_id}")
501
+ return cursor.rowcount > 0
502
+
503
+ def credit(self, lesson_ids: Iterable[int], won: bool) -> None:
504
+ """Reinforce or penalise the lessons that were in play.
505
+
506
+ Queued rather than committed inline: this runs at the end of every turn
507
+ and nothing downstream needs it to have landed yet.
508
+ """
509
+ ids = [int(value) for value in lesson_ids]
510
+ if not ids:
511
+ return
512
+ column = "wins" if won else "losses"
513
+ placeholders = ",".join("?" * len(ids))
514
+ now = time.time()
515
+ sql = (f"UPDATE lessons SET {column} = {column} + 1, uses = uses + 1, "
516
+ f"last_used = ?, updated_at = ? WHERE id IN ({placeholders})")
517
+ self._write(sql, (now, now, *ids))
518
+
519
+ def _write(self, sql: str, params: tuple[Any, ...]) -> None:
520
+ """Queue a statement, or run it inline when there is no writer."""
521
+ if self.writer is not None and self.writer.running:
522
+ self.writer.submit(sql, params)
523
+ return
524
+ with self._lock, self.connection as connection:
525
+ connection.execute(sql, params)
526
+
527
+ # -- lessons: read ---------------------------------------------------- #
528
+
529
+ def _row_to_lesson(self, row: sqlite3.Row) -> Lesson:
530
+ return Lesson(
531
+ id=row["id"], kind=row["kind"], scope=row["scope"],
532
+ trigger=row["trigger_text"], guidance=row["guidance"],
533
+ confidence=row["confidence"], uses=row["uses"], wins=row["wins"],
534
+ losses=row["losses"], pinned=bool(row["pinned"]), source=row["source"],
535
+ created_at=row["created_at"], updated_at=row["updated_at"],
536
+ last_used=row["last_used"],
537
+ )
538
+
539
+ def all_lessons(self, scopes: list[str] | None = None) -> list[Lesson]:
540
+ query = "SELECT * FROM lessons"
541
+ params: tuple[Any, ...] = ()
542
+ if scopes:
543
+ query += f" WHERE scope IN ({','.join('?' * len(scopes))})"
544
+ params = tuple(scopes)
545
+ query += " ORDER BY updated_at DESC"
546
+ return [self._row_to_lesson(row) for row in self.connection.execute(query, params)]
547
+
548
+ def pinned_lessons(self, scopes: list[str] | None = None) -> list[Lesson]:
549
+ """Pinned entries only — a narrow query, not a table scan.
550
+
551
+ This runs on every single recall, so it must not pull the whole table
552
+ through SQLite the way ``all_lessons`` does.
553
+ """
554
+ sql = "SELECT * FROM lessons WHERE pinned = 1"
555
+ params: tuple[Any, ...] = ()
556
+ if scopes:
557
+ sql += f" AND scope IN ({','.join('?' * len(scopes))})"
558
+ params = tuple(scopes)
559
+ sql += " ORDER BY updated_at DESC"
560
+ return [self._row_to_lesson(row) for row in self.connection.execute(sql, params)]
561
+
562
+ def lessons_by_id(self, ids: Iterable[int]) -> dict[int, Lesson]:
563
+ """Fetch exactly the rows the hot index pointed at."""
564
+ wanted = [int(value) for value in ids]
565
+ if not wanted:
566
+ return {}
567
+ placeholders = ",".join("?" * len(wanted))
568
+ rows = self.connection.execute(
569
+ f"SELECT * FROM lessons WHERE id IN ({placeholders})", wanted)
570
+ return {row["id"]: self._row_to_lesson(row) for row in rows}
571
+
572
+ def search_lessons(self, query: str, scopes: list[str] | None = None,
573
+ limit: int = 20) -> list[tuple[Lesson, float]]:
574
+ """Rank lessons against a query. Returns ``(lesson, relevance)``.
575
+
576
+ The RAM mirror answers first because it is bounded and fast — measured
577
+ at a fraction of a millisecond where the FTS join costs several. FTS is
578
+ consulted only when the mirror comes back short, which is where it earns
579
+ its cost: its porter stemming matches "running" against "run", and plain
580
+ token overlap does not.
581
+ """
582
+ results = self._search_fallback(query, scopes, limit)
583
+ if len(results) >= limit or not self.fts_available:
584
+ return results
585
+
586
+ seen = {lesson.id for lesson, _ in results}
587
+ for lesson, relevance in self._search_fts(query, scopes, limit):
588
+ if lesson.id not in seen:
589
+ results.append((lesson, relevance))
590
+ seen.add(lesson.id)
591
+ return results[:limit]
592
+
593
+ def _search_fts(self, query: str, scopes: list[str] | None,
594
+ limit: int) -> list[tuple[Lesson, float]]:
595
+ match = _to_fts_query(query)
596
+ if not match:
597
+ return []
598
+ sql = ["""SELECT lessons.*, bm25(lessons_fts) AS rank
599
+ FROM lessons_fts JOIN lessons ON lessons.id = lessons_fts.rowid
600
+ WHERE lessons_fts MATCH ?"""]
601
+ params: list[Any] = [match]
602
+ if scopes:
603
+ sql.append(f"AND lessons.scope IN ({','.join('?' * len(scopes))})")
604
+ params.extend(scopes)
605
+ sql.append("ORDER BY rank LIMIT ?")
606
+ params.append(limit)
607
+
608
+ try:
609
+ rows = self.connection.execute(" ".join(sql), params).fetchall()
610
+ except sqlite3.OperationalError:
611
+ return self._search_fallback(query, scopes, limit)
612
+
613
+ results: list[tuple[Lesson, float]] = []
614
+ for row in rows:
615
+ # FTS5 bm25() is negative, more negative meaning a better match.
616
+ relevance = max(0.0, -float(row["rank"]))
617
+ results.append((self._row_to_lesson(row), relevance))
618
+ return results
619
+
620
+ def _search_fallback(self, query: str, scopes: list[str] | None,
621
+ limit: int) -> list[tuple[Lesson, float]]:
622
+ """Retrieval without FTS5, served entirely from the RAM mirror.
623
+
624
+ Scores are coverage fractions in ``[0, 1]`` rather than BM25 magnitudes;
625
+ :func:`score` treats both the same way, taking whichever signal is
626
+ stronger, so the two paths rank consistently.
627
+ """
628
+ ranked = self.hot.coverage_scan(query, kind="lesson", scopes=scopes,
629
+ limit=limit)
630
+ if not ranked:
631
+ return []
632
+ lessons = self.lessons_by_id(doc.id for doc, _ in ranked)
633
+ return [(lessons[doc.id], value) for doc, value in ranked
634
+ if doc.id in lessons]
635
+
636
+ def find_similar(self, text: str, threshold: float = 0.6,
637
+ scopes: list[str] | None = None) -> Lesson | None:
638
+ """The existing lesson that already says this, if there is one.
639
+
640
+ Served from the RAM mirror. The previous version re-tokenised every
641
+ stored lesson on each call, which reached 22 ms at three thousand
642
+ lessons — on the path of every reflection.
643
+ """
644
+ doc, _ = self.hot.nearest(text, threshold=threshold, kind="lesson",
645
+ scopes=scopes)
646
+ if doc is None:
647
+ return None
648
+ return self.lessons_by_id([doc.id]).get(doc.id)
649
+
650
+ # -- skills ----------------------------------------------------------- #
651
+
652
+ def add_skill(self, skill: Skill) -> Skill:
653
+ now = time.time()
654
+ with self._lock, self.connection as connection:
655
+ cursor = connection.execute(
656
+ """INSERT INTO skills(name, description, steps, tags, scope, uses,
657
+ wins, losses, avg_duration, created_at, last_used)
658
+ VALUES(?,?,?,?,?,?,?,?,?,?,?)
659
+ ON CONFLICT(name) DO UPDATE SET
660
+ description=excluded.description,
661
+ steps=excluded.steps,
662
+ tags=excluded.tags""",
663
+ (skill.name, skill.description, json.dumps(skill.steps),
664
+ json.dumps(skill.tags), skill.scope, skill.uses, skill.wins,
665
+ skill.losses, skill.avg_duration, now, skill.last_used),
666
+ )
667
+ skill.id = int(cursor.lastrowid or 0)
668
+ return skill
669
+
670
+ def _row_to_skill(self, row: sqlite3.Row) -> Skill:
671
+ return Skill(
672
+ id=row["id"], name=row["name"], description=row["description"],
673
+ steps=json.loads(row["steps"] or "[]"), tags=json.loads(row["tags"] or "[]"),
674
+ scope=row["scope"], uses=row["uses"], wins=row["wins"], losses=row["losses"],
675
+ avg_duration=row["avg_duration"], created_at=row["created_at"],
676
+ last_used=row["last_used"],
677
+ )
678
+
679
+ def all_skills(self) -> list[Skill]:
680
+ return [self._row_to_skill(row) for row in
681
+ self.connection.execute("SELECT * FROM skills ORDER BY uses DESC")]
682
+
683
+ def search_skills(self, query: str, limit: int = 3) -> list[tuple[Skill, float]]:
684
+ """Skills are few, so a direct similarity scan is the simplest thing."""
685
+ index = BM25Index()
686
+ skills = {skill.id: skill for skill in self.all_skills()}
687
+ for skill in skills.values():
688
+ index.add(f"skill:{skill.id}", skill.text)
689
+ results = []
690
+ for doc_id, score in index.search(query, limit=limit):
691
+ skill = skills.get(int(doc_id.split(":")[1]))
692
+ if skill is not None:
693
+ results.append((skill, score))
694
+ return results
695
+
696
+ def credit_skill(self, skill_id: int, won: bool, duration: float = 0.0) -> None:
697
+ column = "wins" if won else "losses"
698
+ with self._lock, self.connection as connection:
699
+ connection.execute(
700
+ f"""UPDATE skills SET {column} = {column} + 1, uses = uses + 1,
701
+ last_used = ?,
702
+ avg_duration = CASE WHEN uses = 0 THEN ?
703
+ ELSE (avg_duration * uses + ?) / (uses + 1) END
704
+ WHERE id = ?""",
705
+ (time.time(), duration, duration, skill_id),
706
+ )
707
+
708
+ def delete_skill(self, skill_id: int) -> bool:
709
+ with self._lock, self.connection as connection:
710
+ cursor = connection.execute("DELETE FROM skills WHERE id=?", (skill_id,))
711
+ return cursor.rowcount > 0
712
+
713
+ # -- rules ------------------------------------------------------------ #
714
+
715
+ def _row_to_rule(self, row: sqlite3.Row) -> Rule:
716
+ return Rule(
717
+ id=row["id"], category=row["category"], key=row["key"],
718
+ statement=row["statement"], detail=row["detail"], scope=row["scope"],
719
+ support=row["support"], against=row["against"], source=row["source"],
720
+ pinned=bool(row["pinned"]), active=bool(row["active"]),
721
+ created_at=row["created_at"], updated_at=row["updated_at"],
722
+ )
723
+
724
+ def observe_rule(self, key: str, scope: str, *, agrees: bool = True,
725
+ category: str = "style", statement: str = "",
726
+ detail: str = "", source: str = "observation",
727
+ weight: int = 1) -> Rule:
728
+ """Record one observation for a convention, creating it if it is new.
729
+
730
+ Every observation of the same convention lands on the same row, so a
731
+ rule's support count is a real tally rather than a pile of duplicates.
732
+ """
733
+ now = time.time()
734
+ with self._lock, self.connection as connection:
735
+ connection.execute(
736
+ """INSERT INTO rules(category, key, statement, detail, scope,
737
+ support, against, source, created_at, updated_at)
738
+ VALUES(?,?,?,?,?,0,0,?,?,?)
739
+ ON CONFLICT(key, scope) DO NOTHING""",
740
+ (category, key, statement, detail, scope, source, now, now),
741
+ )
742
+ column = "support" if agrees else "against"
743
+ # A correction outranks a passive observation, so it also upgrades
744
+ # the recorded source and refreshes the wording.
745
+ connection.execute(
746
+ f"""UPDATE rules SET {column} = {column} + ?, updated_at = ?,
747
+ statement = CASE WHEN ? <> '' THEN ? ELSE statement END,
748
+ detail = CASE WHEN ? <> '' THEN ? ELSE detail END,
749
+ source = CASE WHEN source = 'observation' AND ? <> 'observation'
750
+ THEN ? ELSE source END
751
+ WHERE key = ? AND scope = ?""",
752
+ (weight, now, statement, statement, detail, detail,
753
+ source, source, key, scope),
754
+ )
755
+ row = connection.execute(
756
+ "SELECT * FROM rules WHERE key = ? AND scope = ?", (key, scope)
757
+ ).fetchone()
758
+
759
+ rule = self._row_to_rule(row)
760
+ self.hot.add("rule", rule.id, rule.text, rule.scope)
761
+ return rule
762
+
763
+ def all_rules(self, scopes: list[str] | None = None,
764
+ active_only: bool = False) -> list[Rule]:
765
+ sql = "SELECT * FROM rules"
766
+ clauses: list[str] = []
767
+ params: list[Any] = []
768
+ if scopes:
769
+ clauses.append(f"scope IN ({','.join('?' * len(scopes))})")
770
+ params.extend(scopes)
771
+ if active_only:
772
+ clauses.append("active = 1")
773
+ if clauses:
774
+ sql += " WHERE " + " AND ".join(clauses)
775
+ sql += " ORDER BY support DESC, updated_at DESC"
776
+ return [self._row_to_rule(row) for row in self.connection.execute(sql, params)]
777
+
778
+ def confident_rules(self, scopes: list[str] | None = None) -> list[Rule]:
779
+ """Rules that have earned their place in the prompt."""
780
+ return [rule for rule in self.all_rules(scopes, active_only=True)
781
+ if rule.confident]
782
+
783
+ def get_rule(self, rule_id: int) -> Rule | None:
784
+ row = self.connection.execute("SELECT * FROM rules WHERE id=?",
785
+ (rule_id,)).fetchone()
786
+ return self._row_to_rule(row) if row else None
787
+
788
+ def set_rule_flags(self, rule_id: int, *, pinned: bool | None = None,
789
+ active: bool | None = None) -> bool:
790
+ updates: list[str] = []
791
+ params: list[Any] = []
792
+ if pinned is not None:
793
+ updates.append("pinned = ?")
794
+ params.append(int(pinned))
795
+ if active is not None:
796
+ updates.append("active = ?")
797
+ params.append(int(active))
798
+ if not updates:
799
+ return False
800
+ params.extend([time.time(), rule_id])
801
+ with self._lock, self.connection as connection:
802
+ cursor = connection.execute(
803
+ f"UPDATE rules SET {', '.join(updates)}, updated_at = ? WHERE id = ?",
804
+ params)
805
+ return cursor.rowcount > 0
806
+
807
+ def delete_rule(self, rule_id: int) -> bool:
808
+ with self._lock, self.connection as connection:
809
+ cursor = connection.execute("DELETE FROM rules WHERE id=?", (rule_id,))
810
+ self.hot.remove("rule", rule_id)
811
+ return cursor.rowcount > 0
812
+
813
+ # -- signals ---------------------------------------------------------- #
814
+
815
+ def add_signal(self, signal: Signal) -> None:
816
+ """Queue an observed user action. Never blocks the caller."""
817
+ self._write(
818
+ """INSERT INTO signals(kind, session_id, episode_id, subject, payload,
819
+ weight, created_at)
820
+ VALUES(?,?,?,?,?,?,?)""",
821
+ (signal.kind, signal.session_id, signal.episode_id, signal.subject,
822
+ signal.payload, signal.weight, signal.created_at),
823
+ )
824
+
825
+ def recent_signals(self, kind: str = "", limit: int = 50) -> list[Signal]:
826
+ sql = "SELECT * FROM signals"
827
+ params: list[Any] = []
828
+ if kind:
829
+ sql += " WHERE kind = ?"
830
+ params.append(kind)
831
+ sql += " ORDER BY created_at DESC LIMIT ?"
832
+ params.append(limit)
833
+ return [
834
+ Signal(id=row["id"], kind=row["kind"], session_id=row["session_id"],
835
+ episode_id=row["episode_id"], subject=row["subject"],
836
+ payload=row["payload"], weight=row["weight"],
837
+ created_at=row["created_at"])
838
+ for row in self.connection.execute(sql, params)
839
+ ]
840
+
841
+ def signal_counts(self, since: float = 0.0) -> dict[str, int]:
842
+ rows = self.connection.execute(
843
+ "SELECT kind, COUNT(*) AS n FROM signals WHERE created_at >= ? GROUP BY kind",
844
+ (since,))
845
+ return {row["kind"]: row["n"] for row in rows}
846
+
847
+ # -- episodes and feedback -------------------------------------------- #
848
+
849
+ def add_episode(self, episode: Episode) -> Episode:
850
+ with self._lock, self.connection as connection:
851
+ cursor = connection.execute(
852
+ """INSERT INTO episodes(session_id, goal, scope, success, stopped,
853
+ steps, elapsed, tools_used, error_kind, created_at,
854
+ corrections, approvals_asked, retries, tokens,
855
+ rules_active)
856
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
857
+ (episode.session_id, episode.goal, episode.scope, int(episode.success),
858
+ episode.stopped, episode.steps, episode.elapsed,
859
+ json.dumps(episode.tools_used), episode.error_kind, episode.created_at,
860
+ episode.corrections, episode.approvals_asked, episode.retries,
861
+ episode.tokens, episode.rules_active),
862
+ )
863
+ episode.id = int(cursor.lastrowid or 0)
864
+ return episode
865
+
866
+ def episodes(self, limit: int = 200, scope: str = "") -> list[Episode]:
867
+ """Recent episodes, oldest first — the series behind /progress."""
868
+ sql = "SELECT * FROM episodes"
869
+ params: list[Any] = []
870
+ if scope:
871
+ sql += " WHERE scope = ?"
872
+ params.append(scope)
873
+ sql += " ORDER BY created_at DESC LIMIT ?"
874
+ params.append(limit)
875
+ rows = list(self.connection.execute(sql, params))
876
+ return [
877
+ Episode(
878
+ id=row["id"], session_id=row["session_id"], goal=row["goal"],
879
+ scope=row["scope"], success=bool(row["success"]), stopped=row["stopped"],
880
+ steps=row["steps"], elapsed=row["elapsed"],
881
+ tools_used=json.loads(row["tools_used"] or "[]"),
882
+ error_kind=row["error_kind"], created_at=row["created_at"],
883
+ corrections=row["corrections"], approvals_asked=row["approvals_asked"],
884
+ retries=row["retries"], tokens=row["tokens"],
885
+ rules_active=row["rules_active"],
886
+ )
887
+ for row in reversed(rows)
888
+ ]
889
+
890
+ def update_episode(self, episode_id: int, **fields: Any) -> None:
891
+ """Amend an episode after the fact — corrections arrive a turn later."""
892
+ allowed = {"corrections", "approvals_asked", "retries", "tokens",
893
+ "rules_active", "success"}
894
+ updates = {k: v for k, v in fields.items() if k in allowed}
895
+ if not updates or not episode_id:
896
+ return
897
+ assignments = ", ".join(f"{name} = ?" for name in updates)
898
+ self._write(f"UPDATE episodes SET {assignments} WHERE id = ?",
899
+ (*updates.values(), episode_id))
900
+
901
+ def add_feedback(self, target_kind: str, target_id: int, signal: float,
902
+ note: str = "") -> None:
903
+ with self._lock, self.connection as connection:
904
+ connection.execute(
905
+ """INSERT INTO feedback(target_kind, target_id, signal, note, created_at)
906
+ VALUES(?,?,?,?,?)""",
907
+ (target_kind, target_id, signal, note, time.time()),
908
+ )
909
+
910
+ # -- housekeeping ----------------------------------------------------- #
911
+
912
+ def consolidate(self, min_confidence: float, half_life_days: float) -> int:
913
+ """Drop lessons that decayed below the floor. Returns how many went."""
914
+ removed = 0
915
+ for lesson in self.all_lessons():
916
+ if lesson.pinned:
917
+ continue
918
+ # Give every lesson a grace period; a brand new one has no record
919
+ # yet and would otherwise be pruned before it is ever used.
920
+ if lesson.uses < 2 and time.time() - lesson.created_at < 7 * DAY:
921
+ continue
922
+ if lesson.effective_confidence(half_life_days) < min_confidence:
923
+ self.delete_lesson(lesson.id)
924
+ removed += 1
925
+ return removed
926
+
927
+ def stats(self) -> dict[str, Any]:
928
+ connection = self.connection
929
+ def count(table: str) -> int:
930
+ return int(connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
931
+
932
+ wins, losses = connection.execute(
933
+ "SELECT COALESCE(SUM(wins),0), COALESCE(SUM(losses),0) FROM lessons"
934
+ ).fetchone()
935
+ successes = connection.execute(
936
+ "SELECT COALESCE(SUM(success),0) FROM episodes"
937
+ ).fetchone()[0]
938
+ episodes = count("episodes")
939
+ return {
940
+ "lessons": count("lessons"),
941
+ "skills": count("skills"),
942
+ "rules": count("rules"),
943
+ "rules_active": len(self.confident_rules()),
944
+ "signals": count("signals"),
945
+ "episodes": episodes,
946
+ "success_rate": (successes / episodes) if episodes else 0.0,
947
+ "reinforcements": int(wins) + int(losses),
948
+ "fts": self.fts_available,
949
+ "indexed": len(self.hot),
950
+ "queued_writes": self.writer.pending if self.writer else 0,
951
+ "path": str(self.path),
952
+ }
953
+
954
+
955
+ # --------------------------------------------------------------------------- #
956
+ # helpers
957
+ # --------------------------------------------------------------------------- #
958
+
959
+
960
+ def _to_fts_query(text: str) -> str:
961
+ """Turn free text into a safe FTS5 MATCH expression.
962
+
963
+ Every term is quoted so that user text containing FTS operators (``AND``,
964
+ ``*``, ``"``) is treated as words rather than syntax — otherwise a search
965
+ for ``a "quoted" phrase`` raises instead of returning results.
966
+ """
967
+ from .bm25 import tokenize
968
+
969
+ # Longer terms are the selective ones; a query of two dozen common words
970
+ # makes FTS union half the table for no gain in ranking quality.
971
+ terms = sorted(set(tokenize(text)), key=len, reverse=True)[:12]
972
+ if not terms:
973
+ return ""
974
+ return " OR ".join(f'"{term}"' for term in terms)
975
+
976
+
977
+ def score(relevance: float, lesson: Lesson, half_life_days: float,
978
+ query: str = "") -> float:
979
+ """Final ranking score: how well it matches, times how much we trust it.
980
+
981
+ Relevance comes from two sources because neither alone is reliable across
982
+ the lifetime of a brain. BM25 is well behaved once there are hundreds of
983
+ lessons but degenerates toward zero on a nearly empty one, so it is blended
984
+ with term coverage, which is stable at any size. The two are combined with
985
+ ``max`` rather than an average: a strong signal from either is enough.
986
+ """
987
+ confidence = lesson.effective_confidence(half_life_days)
988
+ # Compress BM25 so one very strong keyword match cannot drown out a lesson
989
+ # that is slightly less on-topic but far better established.
990
+ ranked = math.log1p(max(0.0, relevance)) / math.log1p(8.0)
991
+ overlap = coverage(query, lesson.text) if query else 0.0
992
+ return min(1.0, max(ranked, overlap)) * confidence