comodor 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- comodor/__init__.py +18 -0
- comodor/__main__.py +6 -0
- comodor/agent/__init__.py +9 -0
- comodor/agent/context.py +149 -0
- comodor/agent/loop.py +409 -0
- comodor/agent/prompts.py +209 -0
- comodor/agent/tokens.py +131 -0
- comodor/cli.py +295 -0
- comodor/config.py +394 -0
- comodor/events.py +211 -0
- comodor/learning/__init__.py +8 -0
- comodor/learning/bm25.py +147 -0
- comodor/learning/hotindex.py +228 -0
- comodor/learning/memory.py +450 -0
- comodor/learning/progress.py +184 -0
- comodor/learning/reflect.py +152 -0
- comodor/learning/rules.py +418 -0
- comodor/learning/signals.py +310 -0
- comodor/learning/store.py +992 -0
- comodor/learning/writer.py +185 -0
- comodor/net/__init__.py +11 -0
- comodor/net/http.py +2036 -0
- comodor/net/sse.py +113 -0
- comodor/paths.py +120 -0
- comodor/providers/__init__.py +24 -0
- comodor/providers/anthropic.py +267 -0
- comodor/providers/base.py +266 -0
- comodor/providers/fake.py +136 -0
- comodor/providers/gateway.py +275 -0
- comodor/providers/openai_compat.py +281 -0
- comodor/providers/registry.py +178 -0
- comodor/safety/__init__.py +11 -0
- comodor/safety/checkpoints.py +232 -0
- comodor/safety/permissions.py +199 -0
- comodor/safety/redact.py +92 -0
- comodor/session/__init__.py +5 -0
- comodor/session/store.py +203 -0
- comodor/tools/__init__.py +7 -0
- comodor/tools/base.py +173 -0
- comodor/tools/fs.py +314 -0
- comodor/tools/registry.py +85 -0
- comodor/tools/search.py +252 -0
- comodor/tools/shell.py +234 -0
- comodor/tools/todo.py +93 -0
- comodor/tools/web.py +174 -0
- comodor/ui/__init__.py +8 -0
- comodor/ui/app.py +1159 -0
- comodor/ui/console.py +125 -0
- comodor/ui/input/__init__.py +15 -0
- comodor/ui/input/keys.py +322 -0
- comodor/ui/input/reader.py +288 -0
- comodor/ui/layout.py +189 -0
- comodor/ui/markdown.py +76 -0
- comodor/ui/screen.py +185 -0
- comodor/ui/theme.py +251 -0
- comodor/ui/widgets/__init__.py +21 -0
- comodor/ui/widgets/buttons.py +108 -0
- comodor/ui/widgets/chat.py +236 -0
- comodor/ui/widgets/history.py +131 -0
- comodor/ui/widgets/overlay.py +246 -0
- comodor/ui/widgets/panel.py +91 -0
- comodor/ui/widgets/progress.py +143 -0
- comodor/ui/widgets/prompt.py +310 -0
- comodor/ui/widgets/statusbar.py +193 -0
- comodor/ui/widgets/toast.py +67 -0
- comodor-0.1.0.dist-info/METADATA +313 -0
- comodor-0.1.0.dist-info/RECORD +70 -0
- comodor-0.1.0.dist-info/WHEEL +4 -0
- comodor-0.1.0.dist-info/entry_points.txt +2 -0
- comodor-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|