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
comodor/learning/bm25.py
ADDED
|
@@ -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()}}
|