@r1z4x/tezgah 1.2.0 → 1.4.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.
@@ -6,8 +6,8 @@ Opt-in and off by default. A row is written only when the `taste-on` marker is
6
6
  armed (`tezgah_paths.armed`), the repository carries no `.no-taste` mark, its
7
7
  `.tezgah/` already exists and is the user's own (`workspace_from_repo`: a
8
8
  workspace that came with the clone is data, and nothing of the user's is
9
- written into it). The store is `<git root>/.tezgah/taste/signals.jsonl`, one
10
- JSON object per line, owner-only.
9
+ written into it). The store is the `signals` table of
10
+ `<git root>/.tezgah/taste/taste.db` (`tezgah_store.taste`), owner-only.
11
11
 
12
12
  Every public function is total: it never raises and makes no network call.
13
13
  The capture functions append at most one row each. They run on the prompt and
@@ -15,15 +15,24 @@ PostToolUse hot paths, so the switch is read before anything else is.
15
15
 
16
16
  Learning lives in `bin/tezgah-taste learn` and `tezgah_taste_ledger`; the one
17
17
  thing fed back from here is `write_note`, the learnings in scope for a write.
18
+ `learn_later` starts that `learn` in the background at session start.
18
19
  """
19
20
  import datetime
20
21
  import json
21
22
  import os
23
+ import re
24
+ import subprocess
25
+ import sys
26
+ import time
22
27
 
23
28
  import tezgah_integrity as ti
29
+ import tezgah_paths
24
30
  from tezgah_paths import _toplevel, armed, root_for, workspace_from_repo
25
31
 
26
32
  ARM = "taste-on"
33
+ LEARN_EVERY = 3600 # seconds between two background `learn` starts per repo
34
+ BIN = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))),
35
+ "bin", "tezgah-taste")
27
36
  MARK = ".no-taste"
28
37
  VERSION = 1
29
38
  PROMPT_MAX = 2000
@@ -77,9 +86,10 @@ def _write(root, kind, session_id, host, **fields):
77
86
  .strftime("%Y-%m-%dT%H:%M:%SZ"),
78
87
  "kind": kind, "session": str(session_id or ""), "host": host or _host()}
79
88
  row.update(fields)
80
- # the ledger's own writer: its flock, torn-tail repair and 0700/0600 modes
81
- ti._append(os.path.join(root, ".tezgah", "taste", "signals.jsonl"),
82
- json.dumps(row, ensure_ascii=False) + "\n")
89
+ # imported here, not at the top: the off path never loads sqlite3
90
+ import tezgah_store as store
91
+ with store.taste(os.path.join(root, ".tezgah", "taste")) as db:
92
+ store.append(db, "signals", row)
83
93
 
84
94
 
85
95
  def _host():
@@ -203,6 +213,50 @@ def write_note(session_id, inp, cwd, tool=None):
203
213
  return ""
204
214
 
205
215
 
216
+ def learn_later(root):
217
+ """Start `tezgah-taste learn --repo <root> --no-fallback` detached when the
218
+ signals of `root` (the git root `enabled` returned) grew since the last
219
+ start and an hour has passed; the stamp (a `taste_learn` document of the
220
+ cache database) keeps the newest signal's append number. Only when
221
+ TypeSafe resolves: the automatic run never pays a
222
+ generative provider for a decision it may not apply. Not on a
223
+ machine without flock (Windows), where two background runs in two
224
+ repositories would race on the user ledger. ponytail: no Windows lock; add
225
+ one (msvcrt.locking) before enabling it there. Total; the stamp is written
226
+ before the spawn, so a failed start waits an hour."""
227
+ try:
228
+ if not root:
229
+ return
230
+ import tezgah_store as store
231
+ with store.taste(os.path.join(root, ".tezgah", "taste"), create=False) as db:
232
+ if db is None:
233
+ return
234
+ count = store.last(db, "signals")
235
+ base = os.path.join(tezgah_paths.cache_dir(), "taste-learn")
236
+ name = re.sub(r"[^A-Za-z0-9]+", "-", root).strip("-")
237
+ last = store.doc("taste_learn", name)
238
+ last = last if isinstance(last, dict) else {}
239
+ now = time.time()
240
+ if count <= last.get("rows", 0) or now - last.get("at", 0) < LEARN_EVERY:
241
+ return
242
+ # the costlier checks run only once a start is due: at most hourly
243
+ import tezgah_judge
244
+ import tezgah_taste_ledger
245
+ if tezgah_taste_ledger.fcntl is None or tezgah_paths.off("judge-off") \
246
+ or not tezgah_judge.named(("typesafe",)):
247
+ return
248
+ if not store.put_doc("taste_learn", name, {"rows": count, "at": now}):
249
+ return
250
+ os.makedirs(base, exist_ok=True)
251
+ with open(os.path.join(base, name + ".log"), "ab") as log:
252
+ subprocess.Popen([sys.executable, BIN, "learn", "--repo", root,
253
+ "--no-fallback"],
254
+ cwd=root, stdin=subprocess.DEVNULL, stdout=log,
255
+ stderr=log, start_new_session=True)
256
+ except Exception:
257
+ pass
258
+
259
+
206
260
  def main(argv):
207
261
  """`tezgah_taste.py '<json>'`: `note_write`, then `write_note`, for a host
208
262
  that cannot call them in process (opencode's plugin is JavaScript). The one
@@ -223,5 +277,4 @@ def main(argv):
223
277
 
224
278
 
225
279
  if __name__ == "__main__":
226
- import sys
227
280
  sys.exit(main(sys.argv[1:]))
@@ -21,20 +21,20 @@ answers and keeps the ledger.
21
21
  - Apply: a rule needs confidence >= `RULE_AT` and a calibration bound >=
22
22
  `BOUND_AT` (Clopper-Pearson on an independently labelled sample); a hint needs
23
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.
24
+ suspended. The benefit gate's stopped state turns injection off.
25
25
 
26
- Store: `<repo>/.tezgah/taste/ledger.json` for path, language and repository
27
- learnings, `~/.config/tezgah/taste/ledger.json` for user learnings.
26
+ Store: `tezgah_store.taste` - `<repo>/.tezgah/taste/taste.db` for path,
27
+ language and repository learnings and every taste row, and
28
+ `~/.config/tezgah/taste/taste.db` for user learnings.
28
29
  """
29
30
  import datetime
30
31
  import errno
31
32
  import fnmatch
32
- import json
33
33
  import math
34
34
  import os
35
35
 
36
- import tezgah_integrity as ti
37
- from tezgah_paths import CONFIG_DIR, cache_dir
36
+ import tezgah_store as store
37
+ from tezgah_paths import CONFIG_DIR
38
38
 
39
39
  try:
40
40
  import fcntl
@@ -66,35 +66,21 @@ def user_dir():
66
66
  return os.path.join(CONFIG_DIR, "taste")
67
67
 
68
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)
69
+ def _load(dirpath, user=False):
70
+ with store.taste(dirpath, user, create=False) as db:
71
+ return store.load_ledger(db) if db else {"v": 1, "learnings": {}, "meta": {}}
87
72
 
88
73
 
89
74
  def load(root):
90
75
  """{"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"))}
76
+ return {"repo": _load(store_dir(root)), "user": _load(user_dir(), user=True)}
93
77
 
94
78
 
95
79
  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"])
80
+ with store.taste(store_dir(root)) as db:
81
+ store.save_ledger(db, ledgers["repo"])
82
+ with store.taste(user_dir(), user=True) as db:
83
+ store.save_ledger(db, ledgers["user"])
98
84
 
99
85
 
100
86
  def every(ledgers):
@@ -142,34 +128,24 @@ def cp_lower(x, n, alpha=0.05):
142
128
  return lo
143
129
 
144
130
 
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
131
+ def rows(root, table, where="", args=()):
132
+ """One taste table's rows in append order (`tezgah_store.TASTE_ROWS`); []
133
+ when the repository has no store yet."""
134
+ with store.taste(store_dir(root), create=False) as db:
135
+ return store.rows(db, table, where, args) if db else []
157
136
 
158
137
 
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)
138
+ def append(root, table, row, strict=False):
139
+ """One row into a taste table. A hook drops a row it cannot write; `strict`
140
+ (the CLI's decision, defect and label rows) raises instead, so `learn` stops
141
+ before it saves a ledger whose decision was never recorded and would be
142
+ re-applied."""
143
+ try:
144
+ with store.taste(store_dir(root)) as db:
145
+ store.append(db, table, row)
146
+ except store.ERRORS:
147
+ if strict:
148
+ raise
173
149
 
174
150
 
175
151
  class Busy(Exception):
@@ -220,8 +196,8 @@ def calibration(root):
220
196
  bound on that precision. Only typed-provider decisions count; the labels are
221
197
  a sample drawn independently of what was injected (`tezgah-taste label`)."""
222
198
  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":
199
+ for row in rows(root, "labels", "WHERE provider = ?", ("typesafe",)):
200
+ if row.get("decided") != "preference":
225
201
  continue
226
202
  n += 1
227
203
  x += row.get("label") == "preference"
@@ -428,11 +404,8 @@ def suspended(learning, root):
428
404
 
429
405
  def stopped(root):
430
406
  """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
407
+ with store.taste(store_dir(root), create=False) as db:
408
+ return bool(db) and store.gate_stopped(db)
436
409
 
437
410
 
438
411
  def usable(root, day=None):
@@ -478,10 +451,10 @@ def block(root, day=None):
478
451
 
479
452
 
480
453
  def record_injection(root, session_id, ids, day=None):
481
- """One `injected.jsonl` row: the meta loop counts a later correction in this
454
+ """One `injected` row: the meta loop counts a later correction in this
482
455
  session against these ids, and the benefit gate splits its arms at the first."""
483
456
  if ids:
484
- append(os.path.join(store_dir(root), "injected.jsonl"),
457
+ append(root, "injected",
485
458
  {"session": str(session_id or ""), "day": now_day() if day is None else day,
486
459
  "ids": list(ids)})
487
460
 
@@ -489,9 +462,8 @@ def record_injection(root, session_id, ids, day=None):
489
462
  def injected(root, session_id):
490
463
  """The learning ids injected in one session."""
491
464
  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 ())
465
+ for row in rows(root, "injected", "WHERE session = ?", (session_id,)):
466
+ ids.update(row.get("ids") or ())
495
467
  return ids
496
468
 
497
469
 
@@ -502,24 +474,18 @@ def write_note(root, session_id, rel_path):
502
474
  if not session_id or not rel_path or stopped(root):
503
475
  return ""
504
476
  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()
477
+ session_id = str(session_id)
478
+ with store.taste(store_dir(root), create=False) as db:
479
+ seen = store.seen(db, session_id) if db else set()
512
480
  hits = [(mode, learning) for mode, learning in usable(root)
513
481
  if learning["scope"] in ("path", "language") and learning["id"] not in seen
514
482
  and fnmatch.fnmatch(rel_path, learning["where"])]
515
483
  if not hits:
516
484
  return ""
517
- seen.update(m[1]["id"] for m in hits)
518
485
  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:
486
+ with store.taste(store_dir(root)) as db:
487
+ store.mark_seen(db, session_id, [m[1]["id"] for m in hits])
488
+ except store.ERRORS:
523
489
  return ""
524
490
  record_injection(root, session_id, [m[1]["id"] for m in hits])
525
491
  return ("Taste for %s files (narrower scope wins):\n" % kind
@@ -529,8 +495,8 @@ def write_note(root, session_id, rel_path):
529
495
  # --- markdown and export ----------------------------------------------------------------
530
496
 
531
497
  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."""
498
+ """Rewrite `<store>/<category>/taste.md` from the active learnings; the
499
+ database stays the source of truth. Returns the files written."""
534
500
  day = now_day() if day is None else day
535
501
  by_cat = {}
536
502
  for learning in every(load(root)).values():
@@ -590,7 +556,7 @@ def export_agents(root):
590
556
 
591
557
  def first_injection(root):
592
558
  """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"))
559
+ days = [r["day"] for r in rows(root, "injected")
594
560
  if isinstance(r.get("day"), (int, float))]
595
561
  return min(days) if days else None
596
562
 
@@ -601,13 +567,13 @@ def gate(root, need=980, alpha=0.1):
601
567
  Every decided signal is one prompt after a writing turn. The arms split at
602
568
  the first injection, by when the turn happened (`at`), not by when `learn`
603
569
  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
570
+ arms hold `need` turns, the gate's state says stopped unless the after-rate is
605
571
  lower at one-sided p < `alpha`. Returns the report."""
606
572
  first = first_injection(root)
607
573
  arms = {"before": [0, 0], "after": [0, 0]}
608
- for row in rows(os.path.join(store_dir(root), "decisions.jsonl")):
574
+ for row in rows(root, "decisions", "WHERE provider = ?", ("typesafe",)):
609
575
  at = row.get("at")
610
- if row.get("provider") != "typesafe" or not isinstance(at, (int, float)):
576
+ if not isinstance(at, (int, float)):
611
577
  continue
612
578
  arm = arms["after" if first is not None and at >= first else "before"]
613
579
  arm[0] += 1
@@ -623,6 +589,6 @@ def gate(root, need=980, alpha=0.1):
623
589
  report["p"] = round(0.5 * math.erfc(z / math.sqrt(2)), 4)
624
590
  if n1 >= need and n2 >= need:
625
591
  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})
592
+ with store.taste(store_dir(root)) as db:
593
+ store.set_gate(db, report["stopped"], report)
628
594
  return report
@@ -22,6 +22,7 @@ import sys
22
22
  import time
23
23
 
24
24
  import tezgah_paths as tp
25
+ import tezgah_store
25
26
 
26
27
  REPO = os.environ.get("TEZGAH_REPO") or "r1z4x/tezgah"
27
28
  CHECK_EVERY = 24 * 3600
@@ -30,10 +31,6 @@ SEMVER = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
30
31
  ARROW = "\u2191" # ↑: East Asian width Ambiguous, one cell, never an emoji
31
32
 
32
33
 
33
- def cache_path():
34
- return os.path.join(tp.cache_dir(), "update.json")
35
-
36
-
37
34
  def endpoint():
38
35
  """Where the newest release is read, repointed by TEZGAH_UPDATE_URL (tests).
39
36
 
@@ -73,32 +70,15 @@ def disabled():
73
70
 
74
71
 
75
72
  def read_cache():
76
- try:
77
- with open(cache_path(), encoding="utf-8") as fh:
78
- data = json.load(fh)
79
- return data if isinstance(data, dict) else {}
80
- except Exception:
81
- return {}
73
+ """The `update_check` store's one document, `{checked, latest}`, or {}."""
74
+ data = tezgah_store.doc("update_check", "")
75
+ return data if isinstance(data, dict) else {}
82
76
 
83
77
 
84
78
  def write_cache(data):
85
- """Write the cache whole, owner-only, through a rename, so a redraw reading it
79
+ """Write the cache whole, in one statement, so a redraw reading it
86
80
  mid-write sees the old answer or the new one and never half of either."""
87
- path = cache_path()
88
- tmp = "%s.%d.tmp" % (path, os.getpid())
89
- try:
90
- os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
91
- fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
92
- with os.fdopen(fd, "w", encoding="utf-8") as fh:
93
- json.dump(data, fh)
94
- os.replace(tmp, path)
95
- return True
96
- except OSError:
97
- try:
98
- os.remove(tmp)
99
- except OSError:
100
- pass
101
- return False
81
+ return tezgah_store.put_doc("update_check", "", data)
102
82
 
103
83
 
104
84
  # Every 0.x ever published, retired by 1.0.0: npm served 0.17.0 through 0.32.0
@@ -52,7 +52,8 @@ try:
52
52
  from tezgah_integrity import ( # noqa: E402
53
53
  SUBAGENT_CHANNEL, bind_session, note, note_tool, ran_nothing, report_bytes,
54
54
  stop_reason)
55
- from tezgah_paths import cache_dir, off # noqa: E402
55
+ from tezgah_paths import off # noqa: E402
56
+ import tezgah_store # noqa: E402
56
57
  from tezgah_taste import write_note # noqa: E402
57
58
  from tezgah_untrusted import marks # noqa: E402
58
59
  except Exception as exc:
@@ -215,25 +216,21 @@ def first_time(session_id, tag):
215
216
  """True the first time a tag is seen for a session; fail-open when unwritable."""
216
217
  if not session_id:
217
218
  return True
218
- mark = os.path.join(cache_dir(), "reinforced", slug(str(session_id)), tag)
219
- if os.path.exists(mark):
220
- return False
221
219
  try:
222
- os.makedirs(os.path.dirname(mark), exist_ok=True)
223
- open(mark, "w", encoding="utf-8").close()
224
- except OSError:
225
- pass
226
- return True
220
+ return tezgah_store.mark("cursor_reinforced", "%s/%s" % (slug(str(session_id)), tag))
221
+ except tezgah_store.ERRORS:
222
+ return True
227
223
 
228
224
 
229
- def answer_path(session_id):
230
- """Where the last assistant text of a conversation is kept.
225
+ def answer_key(session_id):
226
+ """The key of the last assistant text of a conversation in the
227
+ `cursor_answer` store.
231
228
 
232
229
  Cursor's `stop` payload carries only `status`/`loop_count`, so the reply the
233
230
  Stop rule has to read arrives on the earlier `afterAgentResponse`, which
234
- documents `{"text": "<assistant final text>"}`. One file per conversation,
235
- overwritten."""
236
- return os.path.join(cache_dir(), "answer", slug(str(session_id or "nosession")))
231
+ documents `{"text": "<assistant final text>"}`. One document per
232
+ conversation, overwritten."""
233
+ return slug(str(session_id or "nosession"))
237
234
 
238
235
 
239
236
  # A long reply is kept by its two ends rather than by one slice: the Stop rule's
@@ -255,29 +252,20 @@ def remember_answer(session_id, text):
255
252
  count of what was dropped, then `ANSWER_TAIL` characters of its tail - so
256
253
  both the opener the Stop rule reads and the closing marker it must not lose
257
254
  survive the round trip. A reply with no `conversation_id` is not stored; a
258
- file that cannot be written is not fatal."""
255
+ store that cannot be written is not fatal."""
259
256
  if not session_id or not text:
260
257
  return
261
- path = answer_path(session_id)
262
- try:
263
- os.makedirs(os.path.dirname(path), exist_ok=True)
264
- text = str(text)
265
- dropped = len(text) - ANSWER_HEAD - ANSWER_TAIL
266
- if dropped > 0:
267
- text = text[:ANSWER_HEAD] + ANSWER_CUT % dropped + text[-ANSWER_TAIL:]
268
- with open(path, "w", encoding="utf-8") as fh:
269
- fh.write(text)
270
- except OSError:
271
- pass
258
+ text = str(text)
259
+ dropped = len(text) - ANSWER_HEAD - ANSWER_TAIL
260
+ if dropped > 0:
261
+ text = text[:ANSWER_HEAD] + ANSWER_CUT % dropped + text[-ANSWER_TAIL:]
262
+ tezgah_store.put_doc("cursor_answer", answer_key(session_id), text)
272
263
 
273
264
 
274
265
  def last_answer(session_id):
275
266
  """The reply remembered for this conversation, or ""."""
276
- try:
277
- with open(answer_path(session_id), encoding="utf-8") as fh:
278
- return fh.read()
279
- except OSError:
280
- return ""
267
+ text = tezgah_store.doc("cursor_answer", answer_key(session_id))
268
+ return text if isinstance(text, str) else ""
281
269
 
282
270
 
283
271
  def dispatch(payload):