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,147 @@
1
+ """BM25 ranking in pure Python.
2
+
3
+ SQLite's FTS5 already provides ``bm25()`` and is used when the interpreter was
4
+ built with it. This module is the fallback for the builds that were not, and it
5
+ doubles as the similarity function used to detect that a "new" lesson is really
6
+ a restatement of one already stored.
7
+
8
+ The corpus here is small — a few hundred lessons, not a web index — so a plain
9
+ in-memory inverted index is both simpler and fast enough.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ import re
16
+ from collections import Counter
17
+ from dataclasses import dataclass, field
18
+
19
+ # Split on anything that is not a word character; keep identifiers like
20
+ # `read_file` and `src/tools/fs.py` together because those are exactly the terms
21
+ # that make a lesson findable. Trailing punctuation is stripped afterwards —
22
+ # leaving it on turns "unittest." and "unittest" into different terms, which
23
+ # quietly destroys both retrieval and duplicate detection.
24
+ _TOKEN = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_\-./]*")
25
+ _TRAILING = ".-/_"
26
+
27
+ STOPWORDS = frozenset("""
28
+ a an and are as at be but by for from has have if in into is it its of on or
29
+ that the their then there these this to was were will with you your do does
30
+ i we they he she them our us me my than when over while after before also
31
+ just so such not no yes can could should would may might must
32
+ """.split())
33
+
34
+ K1 = 1.5 # term-frequency saturation
35
+ B = 0.75 # length normalisation
36
+
37
+
38
+ def tokenize(text: str) -> list[str]:
39
+ tokens: list[str] = []
40
+ for raw in _TOKEN.findall(text or ""):
41
+ token = raw.lower().rstrip(_TRAILING)
42
+ if len(token) > 1 and token not in STOPWORDS:
43
+ tokens.append(token)
44
+ return tokens
45
+
46
+
47
+ @dataclass
48
+ class BM25Index:
49
+ """An inverted index over ``(doc_id, text)`` pairs."""
50
+
51
+ documents: dict[str, list[str]] = field(default_factory=dict)
52
+ frequencies: dict[str, dict[str, int]] = field(default_factory=dict) # term -> doc -> tf
53
+ lengths: dict[str, int] = field(default_factory=dict)
54
+ _avg_length: float = 0.0
55
+
56
+ def add(self, doc_id: str, text: str) -> None:
57
+ tokens = tokenize(text)
58
+ self.remove(doc_id)
59
+ self.documents[doc_id] = tokens
60
+ self.lengths[doc_id] = len(tokens)
61
+ for term, count in Counter(tokens).items():
62
+ self.frequencies.setdefault(term, {})[doc_id] = count
63
+ self._avg_length = 0.0
64
+
65
+ def remove(self, doc_id: str) -> None:
66
+ tokens = self.documents.pop(doc_id, None)
67
+ if tokens is None:
68
+ return
69
+ self.lengths.pop(doc_id, None)
70
+ for term in set(tokens):
71
+ postings = self.frequencies.get(term)
72
+ if postings:
73
+ postings.pop(doc_id, None)
74
+ if not postings:
75
+ del self.frequencies[term]
76
+ self._avg_length = 0.0
77
+
78
+ @property
79
+ def avg_length(self) -> float:
80
+ if not self._avg_length and self.lengths:
81
+ self._avg_length = sum(self.lengths.values()) / len(self.lengths)
82
+ return self._avg_length or 1.0
83
+
84
+ def search(self, query: str, limit: int = 10) -> list[tuple[str, float]]:
85
+ """Return ``(doc_id, score)`` pairs, best first."""
86
+ terms = tokenize(query)
87
+ if not terms or not self.documents:
88
+ return []
89
+
90
+ total_docs = len(self.documents)
91
+ avg = self.avg_length
92
+ scores: dict[str, float] = {}
93
+
94
+ for term in terms:
95
+ postings = self.frequencies.get(term)
96
+ if not postings:
97
+ continue
98
+ # Standard BM25 IDF, floored so a term in nearly every document
99
+ # contributes nothing rather than something negative.
100
+ idf = max(0.0, math.log(
101
+ 1 + (total_docs - len(postings) + 0.5) / (len(postings) + 0.5)))
102
+ for doc_id, frequency in postings.items():
103
+ length = self.lengths.get(doc_id, 1)
104
+ denominator = frequency + K1 * (1 - B + B * length / avg)
105
+ scores[doc_id] = scores.get(doc_id, 0.0) + idf * frequency * (K1 + 1) / denominator
106
+
107
+ ranked = sorted(scores.items(), key=lambda item: item[1], reverse=True)
108
+ return ranked[:limit]
109
+
110
+ def __len__(self) -> int:
111
+ return len(self.documents)
112
+
113
+
114
+ def similarity(left: str, right: str) -> float:
115
+ """How much two texts say the same thing, in ``[0, 1]``.
116
+
117
+ This uses the overlap coefficient — the intersection over the *smaller*
118
+ token set — rather than Jaccard. Deduplication compares a freshly written
119
+ lesson against a stored one, and the two are rarely the same length; Jaccard
120
+ punishes the longer text for its extra words and scores obvious restatements
121
+ far too low. Very short texts fall back to Jaccard, where the overlap
122
+ coefficient is too easy to satisfy by accident.
123
+ """
124
+ a, b = set(tokenize(left)), set(tokenize(right))
125
+ if not a or not b:
126
+ return 0.0
127
+ shared = len(a & b)
128
+ if min(len(a), len(b)) < 3:
129
+ return shared / len(a | b)
130
+ return shared / min(len(a), len(b))
131
+
132
+
133
+ def coverage(query: str, document: str) -> float:
134
+ """The fraction of the query's terms that appear in the document.
135
+
136
+ BM25 is the right ranking function for a large corpus and the wrong one for
137
+ a small one: with a handful of documents the IDF of every term collapses
138
+ toward zero and all scores become indistinguishable noise. A brand-new brain
139
+ is exactly that case — and it is when good recall matters most, because the
140
+ user is deciding whether the learning is worth anything. Coverage stays
141
+ meaningful at any corpus size, so retrieval blends the two.
142
+ """
143
+ terms = set(tokenize(query))
144
+ if not terms:
145
+ return 0.0
146
+ present = set(tokenize(document))
147
+ return len(terms & present) / len(terms)
@@ -0,0 +1,228 @@
1
+ """The RAM mirror that makes memory instant.
2
+
3
+ Retrieval used to go through SQL and re-tokenise every stored lesson on every
4
+ call. That is fine at ten lessons and unacceptable at three thousand: measured
5
+ before this module existed, deduplication took 22 ms and recall pulled the whole
6
+ table through SQLite. Both sat directly on the path between the user pressing
7
+ Enter and the first token arriving.
8
+
9
+ The fix is boring and effective: keep the whole corpus in memory with its tokens
10
+ already computed, plus a term to id inverted index. Lookups then touch only the
11
+ documents that share a word with the query — a set intersection over a handful
12
+ of candidates instead of a scan over everything.
13
+
14
+ The trade is memory: a few hundred bytes per lesson, so ten thousand lessons cost
15
+ single-digit megabytes. That is the right side of the trade for something on the
16
+ critical path.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import threading
22
+ from itertools import islice
23
+ from dataclasses import dataclass
24
+ from typing import Iterable, Iterator
25
+
26
+ from .bm25 import tokenize
27
+
28
+ EMPTY: frozenset[str] = frozenset()
29
+
30
+ # Ceiling on how many documents a single lookup will score. Bounding the work
31
+ # is what makes recall cost the same at ten lessons and at ten thousand.
32
+ MAX_CANDIDATES = 400
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class Doc:
37
+ """One indexed record: its id, its scope, and its tokens."""
38
+
39
+ id: int
40
+ kind: str # lesson | rule | skill
41
+ scope: str
42
+ tokens: frozenset[str]
43
+ text: str = ""
44
+
45
+ @property
46
+ def key(self) -> tuple[str, int]:
47
+ return (self.kind, self.id)
48
+
49
+
50
+ class HotIndex:
51
+ """An in-memory inverted index, maintained incrementally.
52
+
53
+ Thread-safe because reflection writes from a background thread while the UI
54
+ thread prefetches recall. The lock is held only for the pointer swaps, never
55
+ across scoring, so a slow query cannot block a write.
56
+ """
57
+
58
+ def __init__(self) -> None:
59
+ self._docs: dict[tuple[str, int], Doc] = {}
60
+ self._terms: dict[str, set[tuple[str, int]]] = {}
61
+ self._lock = threading.RLock()
62
+
63
+ # -- maintenance ------------------------------------------------------ #
64
+
65
+ def add(self, kind: str, doc_id: int, text: str, scope: str = "global") -> Doc:
66
+ """Index one record, replacing any earlier version of it."""
67
+ tokens = frozenset(tokenize(text))
68
+ doc = Doc(id=doc_id, kind=kind, scope=scope, tokens=tokens, text=text)
69
+ with self._lock:
70
+ self._remove_locked(doc.key)
71
+ self._docs[doc.key] = doc
72
+ for term in tokens:
73
+ self._terms.setdefault(term, set()).add(doc.key)
74
+ return doc
75
+
76
+ def remove(self, kind: str, doc_id: int) -> bool:
77
+ with self._lock:
78
+ return self._remove_locked((kind, doc_id))
79
+
80
+ def _remove_locked(self, key: tuple[str, int]) -> bool:
81
+ doc = self._docs.pop(key, None)
82
+ if doc is None:
83
+ return False
84
+ for term in doc.tokens:
85
+ postings = self._terms.get(term)
86
+ if postings is not None:
87
+ postings.discard(key)
88
+ if not postings:
89
+ del self._terms[term]
90
+ return True
91
+
92
+ def rebuild(self, records: Iterable[tuple[str, int, str, str]]) -> int:
93
+ """Replace the whole index. Used once at startup."""
94
+ docs: dict[tuple[str, int], Doc] = {}
95
+ terms: dict[str, set[tuple[str, int]]] = {}
96
+ for kind, doc_id, text, scope in records:
97
+ tokens = frozenset(tokenize(text))
98
+ doc = Doc(id=doc_id, kind=kind, scope=scope, tokens=tokens, text=text)
99
+ docs[doc.key] = doc
100
+ for term in tokens:
101
+ terms.setdefault(term, set()).add(doc.key)
102
+ with self._lock:
103
+ self._docs, self._terms = docs, terms
104
+ return len(docs)
105
+
106
+ def clear(self) -> None:
107
+ with self._lock:
108
+ self._docs.clear()
109
+ self._terms.clear()
110
+
111
+ # -- lookup ----------------------------------------------------------- #
112
+
113
+ def candidates(self, query: str, kind: str = "",
114
+ scopes: Iterable[str] | None = None,
115
+ max_candidates: int = MAX_CANDIDATES) -> tuple[set[str], list[Doc]]:
116
+ """Documents sharing at least one term with the query.
117
+
118
+ Rare terms are expanded first and the candidate set is capped, which is
119
+ what keeps lookup flat as the brain grows. The reasoning is the same one
120
+ behind IDF: a term that appears in most of the corpus barely changes the
121
+ ranking but costs the most to scan, so once there are enough candidates
122
+ the common terms are not worth expanding. Anything they would have added
123
+ would have scored near the bottom anyway.
124
+
125
+ Returns the query tokens too, because every caller needs them next and
126
+ tokenising twice is pure waste on a hot path.
127
+ """
128
+ query_tokens = set(tokenize(query))
129
+ if not query_tokens:
130
+ return query_tokens, []
131
+
132
+ allowed = set(scopes) if scopes else None
133
+ with self._lock:
134
+ postings = sorted(
135
+ (self._terms.get(term, EMPTY) for term in query_tokens),
136
+ key=len,
137
+ )
138
+ keys: set[tuple[str, int]] = set()
139
+ for matches in postings:
140
+ room = max_candidates - len(keys)
141
+ if room <= 0:
142
+ break
143
+ if len(matches) <= room:
144
+ keys |= matches # type: ignore[arg-type]
145
+ else:
146
+ # This term is too common to be selective, so the documents
147
+ # it would add are near-random with respect to relevance.
148
+ # Take a bounded slice: the genuinely informative matches
149
+ # already came from the rarer terms processed before it.
150
+ keys.update(islice(matches, room))
151
+ break
152
+ docs = [self._docs[key] for key in keys if key in self._docs]
153
+
154
+ return query_tokens, [
155
+ doc for doc in docs
156
+ if (not kind or doc.kind == kind) and (allowed is None or doc.scope in allowed)
157
+ ]
158
+
159
+ def coverage_scan(self, query: str, kind: str = "",
160
+ scopes: Iterable[str] | None = None,
161
+ limit: int = 20) -> list[tuple[Doc, float]]:
162
+ """Rank candidates by how much of the query they cover.
163
+
164
+ Coverage rather than BM25 because this index exists to be fast and
165
+ stable at any corpus size; BM25's IDF is meaningless on a small brain.
166
+ The caller blends this with FTS ranking where that is available.
167
+ """
168
+ query_tokens, docs = self.candidates(query, kind, scopes)
169
+ if not query_tokens or not docs:
170
+ return []
171
+ size = len(query_tokens)
172
+ scored = [(doc, len(query_tokens & doc.tokens) / size) for doc in docs]
173
+ scored.sort(key=lambda pair: pair[1], reverse=True)
174
+ return scored[:limit]
175
+
176
+ def nearest(self, text: str, threshold: float = 0.55, kind: str = "",
177
+ scopes: Iterable[str] | None = None) -> tuple[Doc | None, float]:
178
+ """The closest existing record, for duplicate detection.
179
+
180
+ Uses the overlap coefficient, matching
181
+ :func:`comodor.learning.bm25.similarity`, so deduplication behaves the
182
+ same whether it runs through here or through the store.
183
+ """
184
+ query_tokens, docs = self.candidates(text, kind, scopes)
185
+ if not query_tokens:
186
+ return None, 0.0
187
+
188
+ best: Doc | None = None
189
+ best_score = threshold
190
+ for doc in docs:
191
+ if not doc.tokens:
192
+ continue
193
+ smaller = min(len(query_tokens), len(doc.tokens))
194
+ if smaller < 3:
195
+ score = len(query_tokens & doc.tokens) / len(query_tokens | doc.tokens)
196
+ else:
197
+ score = len(query_tokens & doc.tokens) / smaller
198
+ if score > best_score:
199
+ best, best_score = doc, score
200
+ return best, (best_score if best is not None else 0.0)
201
+
202
+ # -- introspection ---------------------------------------------------- #
203
+
204
+ def get(self, kind: str, doc_id: int) -> Doc | None:
205
+ with self._lock:
206
+ return self._docs.get((kind, doc_id))
207
+
208
+ def of_kind(self, kind: str) -> Iterator[Doc]:
209
+ with self._lock:
210
+ return iter([doc for doc in self._docs.values() if doc.kind == kind])
211
+
212
+ def __len__(self) -> int:
213
+ with self._lock:
214
+ return len(self._docs)
215
+
216
+ @property
217
+ def terms(self) -> int:
218
+ with self._lock:
219
+ return len(self._terms)
220
+
221
+ def stats(self) -> dict[str, int]:
222
+ with self._lock:
223
+ kinds: dict[str, int] = {}
224
+ for doc in self._docs.values():
225
+ kinds[doc.kind] = kinds.get(doc.kind, 0) + 1
226
+ postings = sum(len(keys) for keys in self._terms.values())
227
+ return {"documents": len(self._docs), "terms": len(self._terms),
228
+ "postings": postings, **{f"kind_{k}": v for k, v in kinds.items()}}