@r1z4x/tezgah 1.1.0 → 1.3.0
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.
- package/CHANGELOG.md +33 -1
- package/MANIFEST +2 -0
- package/bin/tezgah-taste +509 -5
- package/docs/clarity-baseline.json +1 -1
- package/docs/index.json +5 -2
- package/docs/judge.md +23 -9
- package/docs/operations.md +79 -21
- package/hooks/projects-posttooluse.py +7 -0
- package/hooks/tezgah_context.py +28 -1
- package/hooks/tezgah_judge.py +53 -14
- package/hooks/tezgah_taste.py +86 -10
- package/hooks/tezgah_taste_ledger.py +628 -0
- package/hosts/codex/hook.py +6 -1
- package/hosts/cursor/hook.py +6 -1
- package/hosts/omp/hook.py +6 -0
- package/hosts/opencode/plugins/tezgah.js +10 -6
- package/package.json +1 -1
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Taste learning: the symbolic layer and the meta loop of taste-1's architecture
|
|
3
|
+
(research line taste-architecture, design T1P), and what the hooks inject from it.
|
|
4
|
+
|
|
5
|
+
The decision - preference, defect or none; which category; which scope; how the
|
|
6
|
+
signal relates to each learning already held - is a typed System One call made
|
|
7
|
+
by `bin/tezgah-taste learn`. This module never calls out: it takes those typed
|
|
8
|
+
answers and keeps the ledger.
|
|
9
|
+
|
|
10
|
+
- Fixed categories (`CATEGORIES`); a learning outside them is never created.
|
|
11
|
+
- Confidence is the beta expectation (r+1)/(r+s+2) over supporting (r) and
|
|
12
|
+
contradicting (s) evidence, each weighted by the probability the decision gave
|
|
13
|
+
it, with a per-scope forgetting factor per day (Beta Reputation System).
|
|
14
|
+
- States: candidate -> active after `SESSIONS_TO_ACTIVE` sessions; a
|
|
15
|
+
contradiction flags an active learning `conflicted` and stores the contrary
|
|
16
|
+
claim `quarantined`; `narrows` adds a narrower learning beside the broader one;
|
|
17
|
+
a user's reject retires one. A conflict ends when one side falls below
|
|
18
|
+
`RESOLVE_BELOW` or the user decides.
|
|
19
|
+
- Meta loop: a learning injected in a session whose category then gets a
|
|
20
|
+
preference correction that does not support it takes s += weight.
|
|
21
|
+
- Apply: a rule needs confidence >= `RULE_AT` and a calibration bound >=
|
|
22
|
+
`BOUND_AT` (Clopper-Pearson on an independently labelled sample); a hint needs
|
|
23
|
+
`HINT_AT`; a learning whose evidence paths are all gone from the tree is
|
|
24
|
+
suspended. The benefit gate's stop file turns injection off.
|
|
25
|
+
|
|
26
|
+
Store: `<repo>/.tezgah/taste/ledger.json` for path, language and repository
|
|
27
|
+
learnings, `~/.config/tezgah/taste/ledger.json` for user learnings.
|
|
28
|
+
"""
|
|
29
|
+
import datetime
|
|
30
|
+
import errno
|
|
31
|
+
import fnmatch
|
|
32
|
+
import json
|
|
33
|
+
import math
|
|
34
|
+
import os
|
|
35
|
+
|
|
36
|
+
import tezgah_integrity as ti
|
|
37
|
+
from tezgah_paths import CONFIG_DIR, cache_dir
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
import fcntl
|
|
41
|
+
except ImportError: # Windows
|
|
42
|
+
fcntl = None
|
|
43
|
+
|
|
44
|
+
CATEGORIES = ("architecture", "naming", "structure", "error-handling", "testing",
|
|
45
|
+
"dependencies", "language-idiom", "formatting", "docs-and-comments",
|
|
46
|
+
"tooling", "workflow-output")
|
|
47
|
+
SCOPES = ("path", "language", "repository", "user") # narrowest first
|
|
48
|
+
FORGET = {"path": 0.98, "language": 0.98, "repository": 0.99, "user": 0.995}
|
|
49
|
+
RELATIONS = ("supports", "contradicts", "narrows", "unrelated")
|
|
50
|
+
RULE_AT, HINT_AT, RESOLVE_BELOW, BOUND_AT = 0.8, 0.6, 0.4, 0.8
|
|
51
|
+
SESSIONS_TO_ACTIVE = 2
|
|
52
|
+
FORGOTTEN_BELOW = 0.25 # a one-session candidate or quarantined claim whose r fell under this is dropped
|
|
53
|
+
INJECT_MAX, TEXT_MAX = 12, 160
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def now_day():
|
|
57
|
+
"""Days since the epoch, as a float: the unit forgetting is counted in."""
|
|
58
|
+
return datetime.datetime.now(datetime.timezone.utc).timestamp() / 86400.0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def store_dir(root):
|
|
62
|
+
return os.path.join(root, ".tezgah", "taste")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def user_dir():
|
|
66
|
+
return os.path.join(CONFIG_DIR, "taste")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _load(path):
|
|
70
|
+
try:
|
|
71
|
+
with open(path, encoding="utf-8") as fh:
|
|
72
|
+
data = json.load(fh)
|
|
73
|
+
if isinstance(data, dict) and isinstance(data.get("learnings"), dict):
|
|
74
|
+
data.setdefault("meta", {})
|
|
75
|
+
return data
|
|
76
|
+
except (OSError, ValueError):
|
|
77
|
+
pass
|
|
78
|
+
return {"v": 1, "learnings": {}, "meta": {}}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _save(path, data):
|
|
82
|
+
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
|
|
83
|
+
tmp = "%s.%d.tmp" % (path, os.getpid())
|
|
84
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
85
|
+
json.dump(data, fh, ensure_ascii=False, indent=1, sort_keys=True)
|
|
86
|
+
os.replace(tmp, path)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load(root):
|
|
90
|
+
"""{"repo": ledger, "user": ledger}."""
|
|
91
|
+
return {"repo": _load(os.path.join(store_dir(root), "ledger.json")),
|
|
92
|
+
"user": _load(os.path.join(user_dir(), "ledger.json"))}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def save(root, ledgers):
|
|
96
|
+
_save(os.path.join(store_dir(root), "ledger.json"), ledgers["repo"])
|
|
97
|
+
_save(os.path.join(user_dir(), "ledger.json"), ledgers["user"])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def every(ledgers):
|
|
101
|
+
"""Both ledgers' learnings in one id map (the ids are kept distinct)."""
|
|
102
|
+
return dict(ledgers["repo"]["learnings"], **ledgers["user"]["learnings"])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --- the evidence calculus -------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def decayed(learning, day):
|
|
108
|
+
"""(r, s) forgotten forward to `day` at the learning's scope rate."""
|
|
109
|
+
rate = FORGET.get(learning.get("scope"), FORGET["repository"])
|
|
110
|
+
age = max(0.0, day - float(learning.get("updated", day)))
|
|
111
|
+
keep = rate ** age
|
|
112
|
+
return learning.get("r", 0.0) * keep, learning.get("s", 0.0) * keep
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def confidence(learning, day=None):
|
|
116
|
+
r, s = decayed(learning, now_day() if day is None else day)
|
|
117
|
+
return (r + 1.0) / (r + s + 2.0)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _touch(learning, day):
|
|
121
|
+
learning["r"], learning["s"] = decayed(learning, day)
|
|
122
|
+
learning["updated"] = day
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cp_lower(x, n, alpha=0.05):
|
|
126
|
+
"""The two-sided (1-alpha) Clopper-Pearson lower bound on a proportion x/n:
|
|
127
|
+
the p at which P(X >= x | n, p) = alpha/2. 0 when there is no evidence."""
|
|
128
|
+
if n <= 0 or x <= 0:
|
|
129
|
+
return 0.0
|
|
130
|
+
if x >= n:
|
|
131
|
+
return (alpha / 2.0) ** (1.0 / n)
|
|
132
|
+
|
|
133
|
+
def tail(p):
|
|
134
|
+
return sum(math.comb(n, k) * p ** k * (1 - p) ** (n - k) for k in range(x, n + 1))
|
|
135
|
+
lo, hi = 0.0, x / n
|
|
136
|
+
for _ in range(60):
|
|
137
|
+
mid = (lo + hi) / 2.0
|
|
138
|
+
if tail(mid) < alpha / 2.0:
|
|
139
|
+
lo = mid
|
|
140
|
+
else:
|
|
141
|
+
hi = mid
|
|
142
|
+
return lo
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def rows(path):
|
|
146
|
+
try:
|
|
147
|
+
with open(path, encoding="utf-8") as fh:
|
|
148
|
+
for line in fh:
|
|
149
|
+
try:
|
|
150
|
+
row = json.loads(line)
|
|
151
|
+
except ValueError:
|
|
152
|
+
continue
|
|
153
|
+
if isinstance(row, dict):
|
|
154
|
+
yield row
|
|
155
|
+
except OSError:
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def append(path, row, strict=False):
|
|
160
|
+
"""One row through the ledger's own writer: flock, torn-tail repair, 0600.
|
|
161
|
+
That writer drops a row it cannot write, which a hook wants; `strict` (the
|
|
162
|
+
CLI's decision, defect and label rows) reads the tail back and raises
|
|
163
|
+
OSError when the row is not there, so `learn` stops before it saves a
|
|
164
|
+
ledger whose decision was never recorded and would be re-applied."""
|
|
165
|
+
line = json.dumps(row, ensure_ascii=False) + "\n"
|
|
166
|
+
ti._append(path, line)
|
|
167
|
+
if strict:
|
|
168
|
+
data = line.encode("utf-8")
|
|
169
|
+
with open(path, "rb") as fh:
|
|
170
|
+
fh.seek(max(0, os.path.getsize(path) - len(data)))
|
|
171
|
+
if fh.read() != data:
|
|
172
|
+
raise OSError("taste row not written to %s" % path)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class Busy(Exception):
|
|
176
|
+
"""Another command holds the taste ledgers' write lock."""
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class locked:
|
|
180
|
+
"""The one writer of every taste ledger on this machine: an exclusive,
|
|
181
|
+
non-blocking flock on `~/.config/tezgah/taste/ledger.lock`, held across a
|
|
182
|
+
command's whole read-change-write. One lock for all repositories, because
|
|
183
|
+
every command also rewrites the user ledger beside its repository's, and
|
|
184
|
+
two worktrees of one repository have two stores. A second writer fails
|
|
185
|
+
with `Busy` rather than overwriting the first. The hooks only read the
|
|
186
|
+
ledgers and never take it. Where flock does not exist (Windows) it is not
|
|
187
|
+
taken, as for the evidence ledger."""
|
|
188
|
+
|
|
189
|
+
def __init__(self):
|
|
190
|
+
self.path = os.path.join(user_dir(), "ledger.lock")
|
|
191
|
+
self.fh = None
|
|
192
|
+
|
|
193
|
+
def __enter__(self):
|
|
194
|
+
os.makedirs(os.path.dirname(self.path), mode=0o700, exist_ok=True)
|
|
195
|
+
self.fh = open(self.path, "a+", encoding="utf-8")
|
|
196
|
+
if fcntl is not None:
|
|
197
|
+
try:
|
|
198
|
+
fcntl.flock(self.fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
199
|
+
except OSError as exc:
|
|
200
|
+
self.fh.seek(0)
|
|
201
|
+
holder = self.fh.read().strip() or "another process"
|
|
202
|
+
self.fh.close()
|
|
203
|
+
if exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN):
|
|
204
|
+
raise Busy(holder)
|
|
205
|
+
raise # a filesystem that cannot lock is not a busy ledger
|
|
206
|
+
self.fh.seek(0)
|
|
207
|
+
self.fh.truncate()
|
|
208
|
+
self.fh.write("pid %d" % os.getpid())
|
|
209
|
+
self.fh.flush()
|
|
210
|
+
return self
|
|
211
|
+
|
|
212
|
+
def __exit__(self, *exc):
|
|
213
|
+
self.fh.close() # closing the descriptor releases the flock
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def calibration(root):
|
|
218
|
+
"""(x, n, bound): of the labelled signals the typed decision called a
|
|
219
|
+
preference, how many the label agrees with, and the Clopper-Pearson lower
|
|
220
|
+
bound on that precision. Only typed-provider decisions count; the labels are
|
|
221
|
+
a sample drawn independently of what was injected (`tezgah-taste label`)."""
|
|
222
|
+
x = n = 0
|
|
223
|
+
for row in rows(os.path.join(store_dir(root), "labels.jsonl")):
|
|
224
|
+
if row.get("provider") != "typesafe" or row.get("decided") != "preference":
|
|
225
|
+
continue
|
|
226
|
+
n += 1
|
|
227
|
+
x += row.get("label") == "preference"
|
|
228
|
+
return x, n, cp_lower(x, n)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# --- the symbolic layer -------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def where_for(scope, paths):
|
|
234
|
+
"""The scope's value for a signal's written paths: a directory glob, an
|
|
235
|
+
extension glob, the repository or every repository."""
|
|
236
|
+
paths = [p for p in paths or () if p]
|
|
237
|
+
if scope == "path" and paths:
|
|
238
|
+
common = os.path.commonpath([os.path.dirname(p) or "." for p in paths])
|
|
239
|
+
return (common.rstrip("/") + "/**") if common not in ("", ".") else "**"
|
|
240
|
+
if scope == "language" and paths:
|
|
241
|
+
ext = os.path.splitext(paths[0])[1]
|
|
242
|
+
return "*" + ext if ext else "*"
|
|
243
|
+
return "*" if scope == "user" else "."
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _evidence_row(sig):
|
|
247
|
+
return {"signal": sig.get("id"), "session": sig.get("session"),
|
|
248
|
+
"paths": list(sig.get("paths") or [])[:8]}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _new(ledgers, sig, category, scope, weight, day, state="candidate", parent=None):
|
|
252
|
+
book = ledgers["user" if scope == "user" else "repo"]
|
|
253
|
+
taken = every(ledgers)
|
|
254
|
+
prefix = "u" if scope == "user" else "t"
|
|
255
|
+
# a monotonic counter: an id a forgotten learning held is never issued
|
|
256
|
+
# again, so a stale `conflicts` entry or injected row cannot name a stranger
|
|
257
|
+
n = max(int(book["meta"].get("next_id", 1)), len(book["learnings"]) + 1)
|
|
258
|
+
while "%s%04d" % (prefix, n) in taken:
|
|
259
|
+
n += 1
|
|
260
|
+
book["meta"]["next_id"] = n + 1
|
|
261
|
+
lid = "%s%04d" % (prefix, n)
|
|
262
|
+
book["learnings"][lid] = {
|
|
263
|
+
"id": lid, "category": category, "scope": scope,
|
|
264
|
+
"where": where_for(scope, sig.get("paths")),
|
|
265
|
+
"text": " ".join((sig.get("text") or "").split())[:400],
|
|
266
|
+
"written": False, "r": weight, "s": 0.0, "sessions": [sig.get("session") or ""],
|
|
267
|
+
"evidence": [_evidence_row(sig)], "state": state, "conflicts": [],
|
|
268
|
+
"parent": parent, "accepted": False, "edited": False,
|
|
269
|
+
"created": day, "updated": day}
|
|
270
|
+
return book["learnings"][lid]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _support(learning, sig):
|
|
274
|
+
learning["evidence"] = (learning.get("evidence") or [])[-19:] + [_evidence_row(sig)]
|
|
275
|
+
if sig.get("session") and sig["session"] not in learning["sessions"]:
|
|
276
|
+
learning["sessions"].append(sig["session"])
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def apply(ledgers, sig, dec, day, applied=()):
|
|
280
|
+
"""Fold one decided signal into the ledgers; returns what happened:
|
|
281
|
+
`unverified`, `defect`, `none` or `learned`.
|
|
282
|
+
|
|
283
|
+
`dec` is the typed decision: `kind`, `category`, `scope` as (choice,
|
|
284
|
+
probability), `relations` {learning id: (relation, probability)} and
|
|
285
|
+
`verified`, True only when TypeSafe answered - any other provider's decision
|
|
286
|
+
is never applied. `applied` are the ids injected in the signal's session."""
|
|
287
|
+
if not dec.get("verified"):
|
|
288
|
+
return "unverified"
|
|
289
|
+
kind, p_kind = dec["kind"]
|
|
290
|
+
if kind != "preference":
|
|
291
|
+
return kind if kind in ("defect", "none") else "none"
|
|
292
|
+
category = dec["category"][0]
|
|
293
|
+
if category not in CATEGORIES:
|
|
294
|
+
return "none"
|
|
295
|
+
scope = dec["scope"][0] if dec["scope"][0] in SCOPES else "repository"
|
|
296
|
+
w = float(p_kind)
|
|
297
|
+
known = every(ledgers)
|
|
298
|
+
relations = dec.get("relations") or {}
|
|
299
|
+
contrary, linked = None, False
|
|
300
|
+
for lid, (rel, p) in relations.items():
|
|
301
|
+
learning = known.get(lid)
|
|
302
|
+
if learning is None or learning["state"] == "retired":
|
|
303
|
+
continue
|
|
304
|
+
_touch(learning, day)
|
|
305
|
+
if rel == "supports":
|
|
306
|
+
learning["r"] += w * p
|
|
307
|
+
_support(learning, sig)
|
|
308
|
+
if learning["state"] == "quarantined" and \
|
|
309
|
+
len(learning["sessions"]) >= SESSIONS_TO_ACTIVE:
|
|
310
|
+
# corroborated, but its partner still stands: withheld as a
|
|
311
|
+
# conflict until one side falls below RESOLVE_BELOW or the user decides
|
|
312
|
+
learning["state"] = "conflicted" if _live_partners(learning, known) \
|
|
313
|
+
else "candidate"
|
|
314
|
+
linked = True
|
|
315
|
+
elif rel == "contradicts":
|
|
316
|
+
learning["s"] += w * p
|
|
317
|
+
if contrary is None:
|
|
318
|
+
contrary = _new(ledgers, sig, category, scope, w, day, state=(
|
|
319
|
+
"quarantined" if learning["state"] in ("active", "conflicted")
|
|
320
|
+
else "candidate"))
|
|
321
|
+
if learning["state"] == "active":
|
|
322
|
+
learning["state"] = "conflicted"
|
|
323
|
+
for a, b in ((learning, contrary), (contrary, learning)):
|
|
324
|
+
if b["id"] not in a["conflicts"]:
|
|
325
|
+
a["conflicts"].append(b["id"])
|
|
326
|
+
linked = True
|
|
327
|
+
elif rel == "narrows" and SCOPES.index(scope) < SCOPES.index(learning["scope"]):
|
|
328
|
+
_new(ledgers, sig, category, scope, w * p, day, parent=lid)
|
|
329
|
+
linked = True
|
|
330
|
+
# the meta loop: a learning applied in this session and then corrected in
|
|
331
|
+
# its category and scope by a signal that neither supports nor contradicts it
|
|
332
|
+
for lid in applied:
|
|
333
|
+
learning = known.get(lid)
|
|
334
|
+
if learning is None or learning["category"] != category \
|
|
335
|
+
or not in_scope(learning, sig.get("paths")):
|
|
336
|
+
continue
|
|
337
|
+
if relations.get(lid, ("unrelated", 0.0))[0] in ("unrelated", "narrows"):
|
|
338
|
+
_touch(learning, day)
|
|
339
|
+
learning["s"] += w
|
|
340
|
+
if not linked:
|
|
341
|
+
_new(ledgers, sig, category, scope, w, day)
|
|
342
|
+
settle(ledgers, day)
|
|
343
|
+
return "learned"
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _live_partners(learning, known):
|
|
347
|
+
return [known[c] for c in learning["conflicts"]
|
|
348
|
+
if c in known and known[c]["state"] != "retired"]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def in_scope(learning, paths):
|
|
352
|
+
"""True when a path or language learning covers one of `paths`; a
|
|
353
|
+
repository or user learning covers everything."""
|
|
354
|
+
if learning["scope"] not in ("path", "language"):
|
|
355
|
+
return True
|
|
356
|
+
return any(fnmatch.fnmatch(p, learning["where"]) for p in paths or ())
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def settle(ledgers, day):
|
|
360
|
+
"""Promote, resolve and forget: the transitions that depend on totals."""
|
|
361
|
+
known = every(ledgers)
|
|
362
|
+
for learning in known.values():
|
|
363
|
+
if learning["state"] == "candidate" and \
|
|
364
|
+
len(learning["sessions"]) >= SESSIONS_TO_ACTIVE:
|
|
365
|
+
# two claims that contradict each other never go active together
|
|
366
|
+
learning["state"] = "conflicted" if _live_partners(learning, known) \
|
|
367
|
+
else "active"
|
|
368
|
+
if learning["state"] == "conflicted":
|
|
369
|
+
others = _live_partners(learning, known)
|
|
370
|
+
if confidence(learning, day) < RESOLVE_BELOW:
|
|
371
|
+
learning["state"] = "retired"
|
|
372
|
+
for other in others:
|
|
373
|
+
if other["state"] == "quarantined":
|
|
374
|
+
other["state"] = "candidate"
|
|
375
|
+
elif all(confidence(o, day) < RESOLVE_BELOW for o in others):
|
|
376
|
+
for other in others:
|
|
377
|
+
other["state"] = "retired"
|
|
378
|
+
learning["state"] = "active"
|
|
379
|
+
gone = set()
|
|
380
|
+
for book in (ledgers["repo"], ledgers["user"]):
|
|
381
|
+
for lid in [k for k, v in book["learnings"].items()
|
|
382
|
+
if v["state"] in ("candidate", "quarantined")
|
|
383
|
+
and len(v["sessions"]) < SESSIONS_TO_ACTIVE
|
|
384
|
+
and not v.get("accepted")
|
|
385
|
+
and decayed(v, day)[0] < FORGOTTEN_BELOW]:
|
|
386
|
+
del book["learnings"][lid]
|
|
387
|
+
gone.add(lid)
|
|
388
|
+
if gone:
|
|
389
|
+
for learning in every(ledgers).values():
|
|
390
|
+
learning["conflicts"] = [c for c in learning["conflicts"] if c not in gone]
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def decide_user(ledgers, lid, verdict, day, text=None):
|
|
394
|
+
"""The user's own call on one learning: accept, reject or edit. An accepted
|
|
395
|
+
learning beats any learned one, so the learnings it conflicts with retire.
|
|
396
|
+
True when the id was found."""
|
|
397
|
+
known = every(ledgers)
|
|
398
|
+
learning = known.get(lid)
|
|
399
|
+
if learning is None:
|
|
400
|
+
return False
|
|
401
|
+
_touch(learning, day)
|
|
402
|
+
if verdict == "accept":
|
|
403
|
+
learning.update(state="active", accepted=True)
|
|
404
|
+
learning["r"] += 1.0
|
|
405
|
+
for c in learning["conflicts"]:
|
|
406
|
+
if c in known:
|
|
407
|
+
known[c]["state"] = "retired"
|
|
408
|
+
elif verdict == "reject":
|
|
409
|
+
learning["state"] = "retired"
|
|
410
|
+
elif verdict == "edit" and text and text.strip():
|
|
411
|
+
learning.update(text=" ".join(text.split())[:400], edited=True, written=True)
|
|
412
|
+
else:
|
|
413
|
+
return False
|
|
414
|
+
return True
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# --- apply ----------------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
def suspended(learning, root):
|
|
420
|
+
"""True when every evidence path the learning cites is gone from `root`.
|
|
421
|
+
ponytail: capture records paths, not symbols, so a renamed symbol in a file
|
|
422
|
+
that still exists is not caught; add symbols when capture records them."""
|
|
423
|
+
if learning["scope"] == "user":
|
|
424
|
+
return False
|
|
425
|
+
paths = [p for ev in learning.get("evidence") or () for p in ev.get("paths") or ()]
|
|
426
|
+
return bool(paths) and not any(os.path.exists(os.path.join(root, p)) for p in paths)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def stopped(root):
|
|
430
|
+
"""True when the benefit gate stopped injection for this repository."""
|
|
431
|
+
try:
|
|
432
|
+
with open(os.path.join(store_dir(root), "gate.json"), encoding="utf-8") as fh:
|
|
433
|
+
return bool(json.load(fh).get("stopped"))
|
|
434
|
+
except (OSError, ValueError, AttributeError):
|
|
435
|
+
return False
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def usable(root, day=None):
|
|
439
|
+
"""[(mode, learning)] that may be injected, narrowest scope first, then by
|
|
440
|
+
confidence: mode `rule` or `hint`."""
|
|
441
|
+
day = now_day() if day is None else day
|
|
442
|
+
bound = calibration(root)[2]
|
|
443
|
+
out = []
|
|
444
|
+
for learning in every(load(root)).values():
|
|
445
|
+
if learning["state"] != "active" or suspended(learning, root):
|
|
446
|
+
continue
|
|
447
|
+
conf = confidence(learning, day)
|
|
448
|
+
if learning.get("accepted") or (conf >= RULE_AT and bound >= BOUND_AT):
|
|
449
|
+
out.append(("rule", learning))
|
|
450
|
+
elif conf >= HINT_AT:
|
|
451
|
+
out.append(("hint", learning))
|
|
452
|
+
out.sort(key=lambda m: (SCOPES.index(m[1]["scope"]), -confidence(m[1], day)))
|
|
453
|
+
return out[:INJECT_MAX]
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _line(mode, learning):
|
|
457
|
+
where = "" if learning["scope"] in ("repository", "user") else " in %s" % learning["where"]
|
|
458
|
+
text = " ".join((learning.get("text") or "").split())[:TEXT_MAX]
|
|
459
|
+
return "- %s [%s%s, %s]: %s" % (mode, learning["category"], where, learning["id"], text)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def block(root, day=None):
|
|
463
|
+
"""(text, ids): the session-start text and the learning ids in it, or
|
|
464
|
+
("", []). The caller records the ids with `record_injection` only once the
|
|
465
|
+
text survived its context budget, so a dropped block never counts against
|
|
466
|
+
the learnings the model did not see."""
|
|
467
|
+
if stopped(root):
|
|
468
|
+
return "", []
|
|
469
|
+
picked = usable(root, day)
|
|
470
|
+
if not picked:
|
|
471
|
+
return "", []
|
|
472
|
+
return (("Taste (learned from this user's own corrections; follow a rule, treat a "
|
|
473
|
+
"hint as the default; the narrower scope wins - path, then language, then "
|
|
474
|
+
"repository, then user - and AGENTS.md/CLAUDE.md beat every learning; "
|
|
475
|
+
"`tezgah-taste reject <id>` drops one):\n"
|
|
476
|
+
+ "\n".join(_line(mode, learning) for mode, learning in picked)),
|
|
477
|
+
[m[1]["id"] for m in picked])
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def record_injection(root, session_id, ids, day=None):
|
|
481
|
+
"""One `injected.jsonl` row: the meta loop counts a later correction in this
|
|
482
|
+
session against these ids, and the benefit gate splits its arms at the first."""
|
|
483
|
+
if ids:
|
|
484
|
+
append(os.path.join(store_dir(root), "injected.jsonl"),
|
|
485
|
+
{"session": str(session_id or ""), "day": now_day() if day is None else day,
|
|
486
|
+
"ids": list(ids)})
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def injected(root, session_id):
|
|
490
|
+
"""The learning ids injected in one session."""
|
|
491
|
+
ids = set()
|
|
492
|
+
for row in rows(os.path.join(store_dir(root), "injected.jsonl")):
|
|
493
|
+
if row.get("session") == session_id:
|
|
494
|
+
ids.update(row.get("ids") or ())
|
|
495
|
+
return ids
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def write_note(root, session_id, rel_path):
|
|
499
|
+
"""The in-scope learnings for a write to `rel_path` not yet shown in this
|
|
500
|
+
session, or "" - each learning once per session, so a long turn does not
|
|
501
|
+
repeat it and a path learning still shows at its directory's first write."""
|
|
502
|
+
if not session_id or not rel_path or stopped(root):
|
|
503
|
+
return ""
|
|
504
|
+
kind = os.path.splitext(rel_path)[1] or os.path.basename(rel_path)
|
|
505
|
+
seen_path = os.path.join(cache_dir(), "taste-notes",
|
|
506
|
+
"%s.json" % str(session_id).replace(os.sep, "_"))
|
|
507
|
+
try:
|
|
508
|
+
with open(seen_path, encoding="utf-8") as fh:
|
|
509
|
+
seen = set(json.load(fh))
|
|
510
|
+
except (OSError, ValueError, TypeError):
|
|
511
|
+
seen = set()
|
|
512
|
+
hits = [(mode, learning) for mode, learning in usable(root)
|
|
513
|
+
if learning["scope"] in ("path", "language") and learning["id"] not in seen
|
|
514
|
+
and fnmatch.fnmatch(rel_path, learning["where"])]
|
|
515
|
+
if not hits:
|
|
516
|
+
return ""
|
|
517
|
+
seen.update(m[1]["id"] for m in hits)
|
|
518
|
+
try:
|
|
519
|
+
os.makedirs(os.path.dirname(seen_path), mode=0o700, exist_ok=True)
|
|
520
|
+
with open(seen_path, "w", encoding="utf-8") as fh:
|
|
521
|
+
json.dump(sorted(seen), fh)
|
|
522
|
+
except OSError:
|
|
523
|
+
return ""
|
|
524
|
+
record_injection(root, session_id, [m[1]["id"] for m in hits])
|
|
525
|
+
return ("Taste for %s files (narrower scope wins):\n" % kind
|
|
526
|
+
+ "\n".join(_line(mode, learning) for mode, learning in hits))
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
# --- markdown and export ----------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
def markdown(root, day=None):
|
|
532
|
+
"""Rewrite `<store>/<category>/taste.md` from the active learnings; the JSON
|
|
533
|
+
ledger stays the source of truth. Returns the files written."""
|
|
534
|
+
day = now_day() if day is None else day
|
|
535
|
+
by_cat = {}
|
|
536
|
+
for learning in every(load(root)).values():
|
|
537
|
+
if learning["state"] == "active":
|
|
538
|
+
by_cat.setdefault(learning["category"], []).append(learning)
|
|
539
|
+
written = []
|
|
540
|
+
for category in CATEGORIES:
|
|
541
|
+
path = os.path.join(store_dir(root), category, "taste.md")
|
|
542
|
+
items = by_cat.get(category)
|
|
543
|
+
if not items:
|
|
544
|
+
if os.path.exists(path):
|
|
545
|
+
os.remove(path)
|
|
546
|
+
continue
|
|
547
|
+
lines = ["# %s" % category, ""]
|
|
548
|
+
for learning in sorted(items, key=lambda v: -confidence(v, day)):
|
|
549
|
+
scope = learning["scope"] + ("" if learning["scope"] in ("repository", "user")
|
|
550
|
+
else " " + learning["where"])
|
|
551
|
+
lines.append("- %s (%s, %s). Confidence: %.2f" % (
|
|
552
|
+
" ".join(learning["text"].split()), scope, learning["id"],
|
|
553
|
+
confidence(learning, day)))
|
|
554
|
+
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
|
|
555
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
556
|
+
fh.write("\n".join(lines) + "\n")
|
|
557
|
+
written.append(path)
|
|
558
|
+
return written
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
START, END = "<!-- tezgah-taste:start -->", "<!-- tezgah-taste:end -->"
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def export_agents(root):
|
|
565
|
+
"""Write the accepted learnings into the repository's AGENTS.md between the
|
|
566
|
+
two markers, creating the file or the block. Returns how many were written."""
|
|
567
|
+
picked = [v for v in every(load(root)).values()
|
|
568
|
+
if v.get("accepted") and v["state"] == "active"]
|
|
569
|
+
path = os.path.join(root, "AGENTS.md")
|
|
570
|
+
try:
|
|
571
|
+
with open(path, encoding="utf-8") as fh:
|
|
572
|
+
text = fh.read()
|
|
573
|
+
except OSError:
|
|
574
|
+
text = ""
|
|
575
|
+
has_block = START in text and END in text
|
|
576
|
+
if not picked and not has_block:
|
|
577
|
+
return 0
|
|
578
|
+
body = "\n".join([START, "## Taste (accepted learnings)", ""]
|
|
579
|
+
+ ["- %s" % " ".join(v["text"].split()) for v in picked] + [END])
|
|
580
|
+
if has_block:
|
|
581
|
+
text = text[:text.index(START)] + body + text[text.index(END) + len(END):]
|
|
582
|
+
else:
|
|
583
|
+
text = (text.rstrip("\n") + "\n\n" if text else "") + body + "\n"
|
|
584
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
585
|
+
fh.write(text)
|
|
586
|
+
return len(picked)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
# --- the benefit gate --------------------------------------------------------------------
|
|
590
|
+
|
|
591
|
+
def first_injection(root):
|
|
592
|
+
"""The day of the first injection, or None: where the gate's arms split."""
|
|
593
|
+
days = [r["day"] for r in rows(os.path.join(store_dir(root), "injected.jsonl"))
|
|
594
|
+
if isinstance(r.get("day"), (int, float))]
|
|
595
|
+
return min(days) if days else None
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def gate(root, need=980, alpha=0.1):
|
|
599
|
+
"""The pooled before/after test on preference corrections per writing turn.
|
|
600
|
+
|
|
601
|
+
Every decided signal is one prompt after a writing turn. The arms split at
|
|
602
|
+
the first injection, by when the turn happened (`at`), not by when `learn`
|
|
603
|
+
decided it, so a backfill of old sessions stays in the before arm. Once both
|
|
604
|
+
arms hold `need` turns, `gate.json` says stopped unless the after-rate is
|
|
605
|
+
lower at one-sided p < `alpha`. Returns the report."""
|
|
606
|
+
first = first_injection(root)
|
|
607
|
+
arms = {"before": [0, 0], "after": [0, 0]}
|
|
608
|
+
for row in rows(os.path.join(store_dir(root), "decisions.jsonl")):
|
|
609
|
+
at = row.get("at")
|
|
610
|
+
if row.get("provider") != "typesafe" or not isinstance(at, (int, float)):
|
|
611
|
+
continue
|
|
612
|
+
arm = arms["after" if first is not None and at >= first else "before"]
|
|
613
|
+
arm[0] += 1
|
|
614
|
+
arm[1] += row.get("kind") == "preference"
|
|
615
|
+
(n1, x1), (n2, x2) = arms["before"], arms["after"]
|
|
616
|
+
report = {"first_injection": first, "before": {"turns": n1, "preference": x1},
|
|
617
|
+
"after": {"turns": n2, "preference": x2}, "need": need, "p": None,
|
|
618
|
+
"stopped": stopped(root)}
|
|
619
|
+
if n1 and n2:
|
|
620
|
+
pool = (x1 + x2) / (n1 + n2)
|
|
621
|
+
se = math.sqrt(pool * (1 - pool) * (1 / n1 + 1 / n2)) if 0 < pool < 1 else 0.0
|
|
622
|
+
z = ((x1 / n1) - (x2 / n2)) / se if se else 0.0
|
|
623
|
+
report["p"] = round(0.5 * math.erfc(z / math.sqrt(2)), 4)
|
|
624
|
+
if n1 >= need and n2 >= need:
|
|
625
|
+
report["stopped"] = not (report["p"] is not None and report["p"] < alpha)
|
|
626
|
+
_save(os.path.join(store_dir(root), "gate.json"),
|
|
627
|
+
{"stopped": report["stopped"], "report": report})
|
|
628
|
+
return report
|
package/hosts/codex/hook.py
CHANGED
|
@@ -34,6 +34,7 @@ try:
|
|
|
34
34
|
SUBAGENT_CHANNEL, bind_session, changed_files_notice, note_tool, ran_nothing,
|
|
35
35
|
report_bytes, stop_reason, untrusted_source)
|
|
36
36
|
from tezgah_paths import HOST_DIRS, off, root_for # noqa: E402
|
|
37
|
+
from tezgah_taste import write_note # noqa: E402
|
|
37
38
|
from tezgah_untrusted import marks # noqa: E402
|
|
38
39
|
except Exception as exc:
|
|
39
40
|
import_failed(exc)
|
|
@@ -200,7 +201,11 @@ def main():
|
|
|
200
201
|
# carries a deny and nothing else.
|
|
201
202
|
advice = (safe(session_id, graph_advice, tool, inp, cwd, "codex")
|
|
202
203
|
if root_for(cwd) else None)
|
|
203
|
-
|
|
204
|
+
# the opt-in taste note for the first write to this file type
|
|
205
|
+
# (hooks/tezgah_taste.write_note), on the same channel
|
|
206
|
+
taste = (safe(session_id, write_note, session_id, inp, cwd, tool)
|
|
207
|
+
if root_for(cwd) and not verify_outcome(payload) else None)
|
|
208
|
+
notice = "\n".join(p for p in (notice, advice, taste) if p)
|
|
204
209
|
if notice:
|
|
205
210
|
# Codex's PostToolUse output carries `additionalContext` with the
|
|
206
211
|
# result - the field is part of its own hook output schema
|
package/hosts/cursor/hook.py
CHANGED
|
@@ -53,6 +53,7 @@ try:
|
|
|
53
53
|
SUBAGENT_CHANNEL, bind_session, note, note_tool, ran_nothing, report_bytes,
|
|
54
54
|
stop_reason)
|
|
55
55
|
from tezgah_paths import cache_dir, off # noqa: E402
|
|
56
|
+
from tezgah_taste import write_note # noqa: E402
|
|
56
57
|
from tezgah_untrusted import marks # noqa: E402
|
|
57
58
|
except Exception as exc:
|
|
58
59
|
import_failed(exc)
|
|
@@ -323,7 +324,11 @@ def dispatch(payload):
|
|
|
323
324
|
# result they belong to, read together rather than one replacing another
|
|
324
325
|
advice = (graph_advice(gate_name(payload.get("tool_name", "")), inp, cwd,
|
|
325
326
|
"cursor") if under(cwd) and not quiet else None)
|
|
326
|
-
|
|
327
|
+
# the opt-in taste note for the first write to this file type
|
|
328
|
+
# (hooks/tezgah_taste.write_note)
|
|
329
|
+
taste = (write_note(session_id, inp, cwd, gate_name(payload.get("tool_name", "")))
|
|
330
|
+
if under(cwd) and not call_failed(payload) else None)
|
|
331
|
+
text = "\n".join(t for t in (notice, reinforce, advice, taste) if t)
|
|
327
332
|
out = {"additional_context": text} if text else {}
|
|
328
333
|
elif event == "postToolUseFailure":
|
|
329
334
|
if kind:
|