@r1z4x/tezgah 1.1.0 → 1.2.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 +22 -1
- package/MANIFEST +2 -0
- package/bin/tezgah-taste +504 -5
- package/docs/index.json +5 -2
- package/docs/judge.md +13 -4
- package/docs/operations.md +71 -21
- package/hooks/projects-posttooluse.py +7 -0
- package/hooks/tezgah_context.py +20 -1
- package/hooks/tezgah_judge.py +53 -14
- package/hooks/tezgah_taste.py +35 -9
- 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
package/docs/judge.md
CHANGED
|
@@ -28,11 +28,14 @@ A session CLI answers in 4-10 s (measured 2026-10-07), so the skill hint's
|
|
|
28
28
|
4 s deadline usually ends first. `TEZGAH_TYPESAFE_URL`, `TEZGAH_OPENROUTER_URL`
|
|
29
29
|
and `TEZGAH_OMP_BIN`/`TEZGAH_CLAUDE_BIN` are the test seams.
|
|
30
30
|
|
|
31
|
-
Every caller reads an answer through the same
|
|
31
|
+
Every caller reads an answer through the same accessors rather than reaching
|
|
32
32
|
into the raw reply, so a Choice and a Noul are read one way for all five
|
|
33
33
|
(`choice`, `hooks/tezgah_judge.py::choice`; `noul`,
|
|
34
|
-
`hooks/tezgah_judge.py::noul`).
|
|
35
|
-
|
|
34
|
+
`hooks/tezgah_judge.py::noul`). A `text` question is the one shape that asks
|
|
35
|
+
for prose. `bin/tezgah-taste` asks it for a learning's readable line, and only
|
|
36
|
+
a generative provider answers one. TypeSafe is never sent one (`text`,
|
|
37
|
+
`hooks/tezgah_judge.py::text`). All three are total: a missing or
|
|
38
|
+
wrongly-typed answer is a `None`, never an exception.
|
|
36
39
|
|
|
37
40
|
A judgement is an aid, never the claim. It ranks units or names a page; the agent
|
|
38
41
|
still reads the selected refs and owns the finding, and the ledger's step kinds
|
|
@@ -69,7 +72,7 @@ why its redaction is not optional. The switches below are the off buttons.
|
|
|
69
72
|
| `bin/tezgah-docs` | the docs page fallback: only when the keyword index placed nothing, one Choice over the pages with `none` offered (`judge_pick`, `bin/tezgah-docs::judge_pick`; the question wording is `ASK`, `bin/tezgah-docs::ASK`) | reads as no judgement; with none (no credential, `judge-off`, `docs-judge-off`, or a call that failed or came back without one of its options) the pages are ranked by shared words instead (`ranked`, BM25 in `hooks/tezgah_rank.py` over each page's title and answers and their Turkish phrasings) and the top three printed; a query sharing no word with any page, or a judged `none`, exits 1. With the opt-in embedding feature on, the ranking is fused with a static embedding (`hooks/tezgah_embed.py`), which places every page, so only a judged `none` exits 1 |
|
|
70
73
|
| `hooks/tezgah_skill_pick.py` | the prompt-path skill hint: a Choice over the roster skills plus one Noul (`judge`, `hooks/tezgah_skill_pick.py::judge`, with the criteria cut from each skill's own clauses, `clause`, `hooks/tezgah_skill_pick.py::clause`), behind a threshold (`GATE`, `hooks/tezgah_skill_pick.py::GATE`), one attempt and a 4 s wall-clock deadline (`ASK_DEADLINE`, `hooks/tezgah_skill_pick.py::ASK_DEADLINE`), the prompt redacted and cut at 2,000 characters | returns `""`; the turn loses the hint |
|
|
71
74
|
| `bin/tezgah-route` | the tier router: after the deterministic overrides, one Choice over the three tiers for a delegation brief (`route`, `hooks/tezgah_models.py::route`; `TIER_QUESTION`, `hooks/tezgah_models.py::TIER_QUESTION`) - see [models](models.md) | the static phase table, else the middle tier |
|
|
72
|
-
| `bin/tezgah-taste` | the coding-taste measurement: `measure` and `rate` label the prompts a user sent after a writing turn, one Choice per prompt batched into one request, each prompt redacted first (`classify`, `bin/tezgah-taste::classify`) | with no credential or `judge-off` it exits 2 before any request
|
|
75
|
+
| `bin/tezgah-taste` | the coding-taste measurement and learning: `measure` and `rate` label the prompts a user sent after a writing turn, one Choice per prompt batched into one request, each prompt redacted first (`classify`, `bin/tezgah-taste::classify`). `learn` asks one typed decision per signal and requires TypeSafe (`decide`, `bin/tezgah-taste::decide`). For a learning that turns active it asks one `text` question of a generative provider (`write_line`, `bin/tezgah-taste::write_line`) | with no credential or `judge-off` it exits 2 before any request. A failed call leaves its prompts unlabelled. A decision another provider gave is recorded `unverified` and never applied. A missing line keeps the user's own words |
|
|
73
76
|
|
|
74
77
|
The third caller is the one no shell row can see: it runs on the prompt path,
|
|
75
78
|
caches one answer per `(session, prompt)` (`_remember`,
|
|
@@ -114,6 +117,12 @@ not. That answer, and a refusal with its failures, is said on stderr as one
|
|
|
114
117
|
`hooks/tezgah_judge.py::_record`), which `tezgah-status --judge` prints. The skill
|
|
115
118
|
hint's cost row and the router's `route` row also carry `judge=<provider>/<model>`.
|
|
116
119
|
|
|
120
|
+
A caller that needs one provider in particular passes `only` (`named`,
|
|
121
|
+
`hooks/tezgah_judge.py::named`). `learn`'s decision has to come from a typed
|
|
122
|
+
model, so it passes `only=("typesafe",)`. It gets `None` rather than another
|
|
123
|
+
provider's answer. The caller named the provider, so the `vendor` order does not
|
|
124
|
+
apply. `none` still keeps every third party out.
|
|
125
|
+
|
|
117
126
|
## The switches
|
|
118
127
|
|
|
119
128
|
`judge-off` is the master: with it armed `available()` is false and no caller
|
package/docs/operations.md
CHANGED
|
@@ -385,15 +385,18 @@ extension the engine has no parser for and an extensionless script it cannot key
|
|
|
385
385
|
on at all. The extensionless case is why `bin/*` carries `.py` twins, and the
|
|
386
386
|
report counts a tracked `bin/x` as covered when the index holds `bin/x.py`.
|
|
387
387
|
|
|
388
|
-
## Opt-in taste capture
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
388
|
+
## Opt-in taste: capture, learn, apply
|
|
389
|
+
|
|
390
|
+
Taste learns the user's coding preferences from their own corrections. It is
|
|
391
|
+
taste-1's architecture as Command Code documents it, ported to tezgah (research
|
|
392
|
+
line taste-architecture, design T1P). An LLM-free structured signal goes to a
|
|
393
|
+
typed decision step, then a symbolic ledger, then a meta loop. It stays off
|
|
394
|
+
until the user arms it with a `taste-on` file in `~/.config/tezgah/` (`enabled`,
|
|
395
|
+
`hooks/tezgah_taste.py::enabled`). The off path costs two stats. Armed, it
|
|
396
|
+
writes only into a repository that already has a `.tezgah/` directory. A
|
|
397
|
+
`.no-taste` mark turns it off for one repository
|
|
398
|
+
(`hooks/tezgah_context.py::repo_marks`). A workspace of repo-provided data gets
|
|
399
|
+
nothing.
|
|
397
400
|
|
|
398
401
|
Rows go to `<repo>/.tezgah/taste/signals.jsonl` through the ledger's own writer
|
|
399
402
|
(`_write`, `hooks/tezgah_taste.py::_write`). A user turn adds a `prompt` row,
|
|
@@ -409,22 +412,69 @@ ledger row. A rollback therefore never reads it as a pre-state (`capture_after`,
|
|
|
409
412
|
`hooks/tezgah_snapshot.py::capture_after`). A file outside the repository gets no
|
|
410
413
|
after blob.
|
|
411
414
|
opencode reaches the same call through a process (`noteTaste`,
|
|
412
|
-
`hosts/opencode/plugins/tezgah.js:
|
|
415
|
+
`hosts/opencode/plugins/tezgah.js:2380-2390`).
|
|
413
416
|
|
|
414
417
|
```sh
|
|
415
418
|
touch ~/.config/tezgah/taste-on # arm; rm it to disarm
|
|
419
|
+
bin/tezgah-taste learn # decide the new capture signals, update the ledger
|
|
420
|
+
bin/tezgah-taste learn --from-transcripts # bootstrap from this repo's omp/Claude history
|
|
421
|
+
bin/tezgah-taste list # learnings: state, rule/hint, confidence, scope
|
|
422
|
+
bin/tezgah-taste accept|reject|show ID # the user's call on one learning
|
|
423
|
+
bin/tezgah-taste edit ID --text "..." # reword it; an edited line is never rewritten
|
|
424
|
+
bin/tezgah-taste export # accepted learnings into AGENTS.md
|
|
425
|
+
bin/tezgah-taste label; bin/tezgah-taste label ID preference # the calibration sample
|
|
426
|
+
bin/tezgah-taste calibrate # the bound a rule needs
|
|
427
|
+
bin/tezgah-taste gate # the pooled before/after test
|
|
416
428
|
bin/tezgah-taste mine --host omp --stats # sessions, writes, prompts after a writing turn
|
|
417
|
-
bin/tezgah-taste
|
|
418
|
-
bin/tezgah-taste measure --samples S --labels L # judge precision/recall against labels
|
|
419
|
-
bin/tezgah-taste rate --in mined.jsonl # preference corrections per writing turn
|
|
429
|
+
bin/tezgah-taste measure --samples S --labels L [--typed] # judge precision/recall
|
|
420
430
|
```
|
|
421
431
|
|
|
422
|
-
`
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
432
|
+
`learn` turns each prompt that followed a writing turn into one signal, and code
|
|
433
|
+
builds it (`capture_signals`, `bin/tezgah-taste::capture_signals`). A signal
|
|
434
|
+
holds the prompt, the files written, the agent's edits, and the user's own edit
|
|
435
|
+
between two agent writes. That last one compares the after blob with the next
|
|
436
|
+
write's pre-state, or with the file on disk (`user_diff`,
|
|
437
|
+
`bin/tezgah-taste::user_diff`). `learn` asks TypeSafe for one typed decision per
|
|
438
|
+
signal (`decide`, `bin/tezgah-taste::decide`, [judge](judge.md)). The decision
|
|
439
|
+
names preference, defect or none. It picks one of eleven fixed categories and a
|
|
440
|
+
scope. It also says how the signal relates to each learning held: supports,
|
|
441
|
+
contradicts, narrows or unrelated. When another provider answers, `learn` writes
|
|
442
|
+
the decision to `decisions.jsonl` as `unverified` and never applies it. A defect
|
|
443
|
+
goes to `defects.jsonl` and never into the ledger.
|
|
444
|
+
|
|
445
|
+
The ledger is `<repo>/.tezgah/taste/ledger.json`, plus
|
|
446
|
+
`~/.config/tezgah/taste/ledger.json` for user-scope learnings
|
|
447
|
+
(`hooks/tezgah_taste_ledger.py`). Confidence is (r+1)/(r+s+2) over supporting
|
|
448
|
+
and contradicting evidence. Each piece counts at its decision probability, and
|
|
449
|
+
the ledger forgets it per day at its scope's rate (`confidence`,
|
|
450
|
+
`hooks/tezgah_taste_ledger.py::confidence`). A learning turns active in its
|
|
451
|
+
second session. A contradiction flags an active learning `conflicted` and stores
|
|
452
|
+
the contrary claim `quarantined`. Neither overwrites the other. `narrows` adds a
|
|
453
|
+
narrower learning beside the broader one. When the user corrects an injected
|
|
454
|
+
learning's category in the same session, that learning loses confidence
|
|
455
|
+
(`apply`, `hooks/tezgah_taste_ledger.py::apply`). The hooks only read the
|
|
456
|
+
ledgers. `learn`, `accept`, `reject` and `edit` rewrite them one at a time on
|
|
457
|
+
this machine, under a lock on `~/.config/tezgah/taste/ledger.lock` (`locked`,
|
|
458
|
+
`hooks/tezgah_taste_ledger.py::locked`). The lock spans every repository,
|
|
459
|
+
because each command also rewrites the user ledger. A second one exits 2 and
|
|
460
|
+
names the holder. Every taste row goes through the evidence ledger's locked
|
|
461
|
+
writer (`append`, `hooks/tezgah_taste_ledger.py::append`).
|
|
462
|
+
|
|
463
|
+
Session start injects the active learnings (`block`,
|
|
464
|
+
`hooks/tezgah_taste_ledger.py::block`). A rule needs confidence 0.8 and a
|
|
465
|
+
Clopper-Pearson lower bound of 0.8 on preference precision, measured on the
|
|
466
|
+
labelled sample (`calibration`, `hooks/tezgah_taste_ledger.py::calibration`). A
|
|
467
|
+
hint needs 0.6. A learning whose evidence paths are all gone is withheld. A
|
|
468
|
+
write also carries the in-scope learnings it has not yet shown in the session,
|
|
469
|
+
on every host's post-tool channel (`write_note`, `hooks/tezgah_taste.py::write_note`).
|
|
470
|
+
`gate` compares preference corrections per writing turn before and after the
|
|
471
|
+
first injection, by when each turn happened. Once both arms hold 980 turns and
|
|
472
|
+
the after-rate is not lower
|
|
473
|
+
at one-sided p < 0.1, it writes `gate.json` and injection stops (`gate`,
|
|
474
|
+
`hooks/tezgah_taste_ledger.py::gate`). `mine` reads omp's top-level session
|
|
475
|
+
files and Claude's top-level transcripts whose cwd is under `--root`
|
|
476
|
+
(`cmd_mine`, `bin/tezgah-taste::cmd_mine`). `measure` and `rate` use a fixed
|
|
477
|
+
labelling rule (`RULE`, `bin/tezgah-taste::RULE`).
|
|
428
478
|
|
|
429
479
|
## Tidying the lessons ledger
|
|
430
480
|
|
|
@@ -580,8 +630,8 @@ question instead of paying an agent to read a page: the analyze-app loop's snaps
|
|
|
580
630
|
triage (`bin/tezgah-triage`), `bin/tezgah-docs` for a query its keyword index
|
|
581
631
|
cannot place, and the prompt-path skill hint (`hooks/tezgah_skill_pick.py`), the
|
|
582
632
|
one caller no shell row sees, `bin/tezgah-route`, the tier router
|
|
583
|
-
([models](models.md)), and `bin/tezgah-taste`'s `measure` and `
|
|
584
|
-
([above](#opt-in-taste-capture)). All five go through the same stdlib-only seam,
|
|
633
|
+
([models](models.md)), and `bin/tezgah-taste`'s `measure`, `rate` and `learn`
|
|
634
|
+
([above](#opt-in-taste-capture-learn-apply)). All five go through the same stdlib-only seam,
|
|
585
635
|
which returns `None` rather than raising because a hook may import it
|
|
586
636
|
(`available()`, `hooks/tezgah_judge.py::available`); the request is
|
|
587
637
|
one batched call, and the credential resolves per call (`ask()`,
|
|
@@ -26,6 +26,7 @@ try:
|
|
|
26
26
|
from tezgah_integrity import ( # noqa: E402
|
|
27
27
|
SUBAGENT_CHANNEL, note_tool, ran_nothing, report_bytes, untrusted_source)
|
|
28
28
|
from tezgah_paths import root_for # noqa: E402
|
|
29
|
+
from tezgah_taste import write_note # noqa: E402
|
|
29
30
|
from tezgah_untrusted import marks # noqa: E402
|
|
30
31
|
except Exception as exc:
|
|
31
32
|
import_failed(exc)
|
|
@@ -139,6 +140,12 @@ def main():
|
|
|
139
140
|
# empty-run contract (tezgah_integrity.EMPTY_RUN)
|
|
140
141
|
empty_run=ran_nothing(result),
|
|
141
142
|
agent=agent)
|
|
143
|
+
# the opt-in taste note: the learnings in scope for the first write to this
|
|
144
|
+
# file type in the session (hooks/tezgah_taste.write_note), beside the
|
|
145
|
+
# provenance line rather than instead of it
|
|
146
|
+
if event == "PostToolUse" and not failed:
|
|
147
|
+
notice = "\n".join(t for t in (notice, safe(session_id, write_note, session_id,
|
|
148
|
+
inp, cwd, tool)) if t)
|
|
142
149
|
if notice:
|
|
143
150
|
json.dump({"hookSpecificOutput": {
|
|
144
151
|
"hookEventName": event,
|
package/hooks/tezgah_context.py
CHANGED
|
@@ -1412,7 +1412,7 @@ DEFAULT_BUDGET = 13000
|
|
|
1412
1412
|
# spend them would turn bloat into rule loss - which is the failure the budget
|
|
1413
1413
|
# exists to prevent, not one it may cause.
|
|
1414
1414
|
DROP_ORDER = ("knowledge", "worktrees", "lessons", "skill", "lessons_turn",
|
|
1415
|
-
"plans", "subagents", "steer", "orca", "consult", "research",
|
|
1415
|
+
"taste", "plans", "subagents", "steer", "orca", "consult", "research",
|
|
1416
1416
|
"research_broken", "graph", "offnote", "orchestrate", "index",
|
|
1417
1417
|
"resume", "scratch", "task", "delta", "pointer")
|
|
1418
1418
|
|
|
@@ -2087,6 +2087,7 @@ def context_for(event, cwd, payload=None, with_core=True):
|
|
|
2087
2087
|
else [("core", core)]) if with_core else []
|
|
2088
2088
|
_, marks = repo_marks(cwd)
|
|
2089
2089
|
injected = [] # the session block's lessons, when it carries them
|
|
2090
|
+
taste = "" # the session block's taste learnings, when it carries them
|
|
2090
2091
|
if ".no-graph" in marks:
|
|
2091
2092
|
parts.append(("graph", "Graph: disabled for this repo (.no-graph), so use "
|
|
2092
2093
|
"grep/find and say the answer came from text "
|
|
@@ -2188,6 +2189,19 @@ def context_for(event, cwd, payload=None, with_core=True):
|
|
|
2188
2189
|
recent = lesson_lines(root)[-LESSON_LINES:]
|
|
2189
2190
|
injected = list(zip(recent, _lesson_shown(
|
|
2190
2191
|
recent, tainted_lessons(root))))
|
|
2192
|
+
# The opt-in taste learnings (hooks/tezgah_taste_ledger.block): the
|
|
2193
|
+
# active ones as rules or hints, read from the ledger with no call out;
|
|
2194
|
+
# off, `enabled` costs the marker stat. The injection is recorded below,
|
|
2195
|
+
# only when the budget kept the block.
|
|
2196
|
+
taste_root = tezgah_taste.enabled(cwd) if tezgah_taste else None
|
|
2197
|
+
if taste_root:
|
|
2198
|
+
try:
|
|
2199
|
+
import tezgah_taste_ledger
|
|
2200
|
+
taste, taste_ids = tezgah_taste_ledger.block(taste_root)
|
|
2201
|
+
except Exception:
|
|
2202
|
+
taste = ""
|
|
2203
|
+
if taste:
|
|
2204
|
+
parts.append(("taste", taste))
|
|
2191
2205
|
broken = tezgah_research.failing(root) if not off("research-off") else []
|
|
2192
2206
|
if broken:
|
|
2193
2207
|
line_slug, err = broken[0]
|
|
@@ -2219,6 +2233,11 @@ def context_for(event, cwd, payload=None, with_core=True):
|
|
|
2219
2233
|
for ln, line in injected:
|
|
2220
2234
|
if "\n- " + line + "\n" in text:
|
|
2221
2235
|
note_lesson(session_of(payload), lesson_key(ln), "session")
|
|
2236
|
+
if taste and taste.split("\n", 1)[0] in text:
|
|
2237
|
+
try:
|
|
2238
|
+
tezgah_taste_ledger.record_injection(taste_root, session_of(payload), taste_ids)
|
|
2239
|
+
except Exception:
|
|
2240
|
+
pass
|
|
2222
2241
|
return text
|
|
2223
2242
|
|
|
2224
2243
|
|
package/hooks/tezgah_judge.py
CHANGED
|
@@ -68,9 +68,10 @@ FALLBACK_MODEL = "deepseek/deepseek-v4-flash"
|
|
|
68
68
|
# The contract the fallback asks a chat model to answer in, one shape per
|
|
69
69
|
# question type this repository's callers send (`bin/tezgah-triage` and
|
|
70
70
|
# `hooks/tezgah_skill_pick.py` ask `noul`, `bin/tezgah-docs` and the triage's
|
|
71
|
-
# pair rows ask `choice`
|
|
72
|
-
#
|
|
73
|
-
#
|
|
71
|
+
# pair rows ask `choice`, `bin/tezgah-taste` asks `text` for a learning's
|
|
72
|
+
# readable line). It is prose because a chat endpoint has no schema field to
|
|
73
|
+
# carry it; every field it names is validated on the way back, and a question
|
|
74
|
+
# of any other type reads as unanswered rather than as a guess.
|
|
74
75
|
CHAT_SYSTEM = (
|
|
75
76
|
"You answer questions about the state that follows. Reply with one JSON "
|
|
76
77
|
"object and nothing else: {\"answers\": {\"<id>\": {...}}}, one entry per "
|
|
@@ -78,6 +79,7 @@ CHAT_SYSTEM = (
|
|
|
78
79
|
"noul -> {\"noul\": <probability 0..1 that the statement is true>}\n"
|
|
79
80
|
"choice -> {\"choice\": \"<the chosen label>\", \"probabilities\": "
|
|
80
81
|
"{\"<label>\": <probability>, ...}, \"confidence\": <0..1>}\n"
|
|
82
|
+
"text -> {\"text\": \"<the answer as plain prose>\"}\n"
|
|
81
83
|
"Every probability is a number in 0..1 and the ones you list for one "
|
|
82
84
|
"question sum to 1. Answer every id you were given.")
|
|
83
85
|
|
|
@@ -188,6 +190,20 @@ def providers():
|
|
|
188
190
|
(("typesafe", key()), ("openrouter", openrouter_key())) if secret]
|
|
189
191
|
|
|
190
192
|
|
|
193
|
+
def named(only):
|
|
194
|
+
"""`providers()` for a caller that requires particular ones: every provider
|
|
195
|
+
in `only` that resolves, in `only`'s order. The `vendor` policy's
|
|
196
|
+
session-first order does not apply - the caller named the provider, so
|
|
197
|
+
nothing is a silent fallback - but `none` still keeps every third party
|
|
198
|
+
out."""
|
|
199
|
+
session = tp.session_cli()
|
|
200
|
+
third = tp.fallback_policy() != "none"
|
|
201
|
+
found = {session: getattr(tp, session + "_bin")()} if session else {}
|
|
202
|
+
if third:
|
|
203
|
+
found.update(typesafe=key(), openrouter=openrouter_key())
|
|
204
|
+
return [(name, found[name]) for name in only if found.get(name)]
|
|
205
|
+
|
|
206
|
+
|
|
191
207
|
def credential():
|
|
192
208
|
"""`(provider, secret)` for the first provider `ask()` will try, else
|
|
193
209
|
`(None, None)` - so `available()` and `ask()` cannot disagree."""
|
|
@@ -200,7 +216,8 @@ def available():
|
|
|
200
216
|
return bool(credential()[1]) and not tp.off("judge-off")
|
|
201
217
|
|
|
202
218
|
|
|
203
|
-
def ask(state, questions, *, model=MODEL, timeout=30, attempts=2, deadline=None
|
|
219
|
+
def ask(state, questions, *, model=MODEL, timeout=30, attempts=2, deadline=None,
|
|
220
|
+
only=None):
|
|
204
221
|
"""One batched call over `questions`; `{"answers", "usage", "latency_ms",
|
|
205
222
|
"model", "provider", "fallback"}`.
|
|
206
223
|
|
|
@@ -233,18 +250,29 @@ def ask(state, questions, *, model=MODEL, timeout=30, attempts=2, deadline=None)
|
|
|
233
250
|
|
|
234
251
|
Total by design: no provider, an unreadable state, a refused request, a
|
|
235
252
|
timeout, a reply that is not the documented shape - all `None`, never an
|
|
236
|
-
exception.
|
|
237
|
-
|
|
253
|
+
exception.
|
|
254
|
+
|
|
255
|
+
`only` names the providers the caller requires (`named()`); a caller whose
|
|
256
|
+
decision must come from a typed model passes `only=("typesafe",)` and gets
|
|
257
|
+
None rather than another provider's answer."""
|
|
258
|
+
tried = providers() if only is None else named(only)
|
|
259
|
+
if any(isinstance(q, dict) and q.get("type") == "text" for q in questions.values()):
|
|
260
|
+
# TypeSafe answers no prose: left out up front, not logged as a failure
|
|
261
|
+
tried = [(p, s) for p, s in tried if p != "typesafe"]
|
|
262
|
+
if not tried or not tried[0][1]:
|
|
238
263
|
return None
|
|
239
264
|
stop = None if deadline is None else time.monotonic() + deadline
|
|
240
265
|
failed = []
|
|
241
|
-
for provider, secret in
|
|
266
|
+
for provider, secret in tried:
|
|
242
267
|
result, why = _ask_one(provider, secret, state, questions, model,
|
|
243
268
|
timeout, attempts, stop)
|
|
244
269
|
if result is None:
|
|
245
270
|
failed.append("%s: %s" % (provider, why))
|
|
246
271
|
continue
|
|
247
|
-
|
|
272
|
+
# a provider the caller named is not a stand-in: no note, unless an
|
|
273
|
+
# earlier one it named failed
|
|
274
|
+
note = "; ".join(failed) or (None if only is not None or provider in SESSION_ARGV
|
|
275
|
+
else NO_SESSION)
|
|
248
276
|
result["fallback"] = note
|
|
249
277
|
_record(result["provider"], result["model"], fallback=note)
|
|
250
278
|
return result
|
|
@@ -328,6 +356,14 @@ def choice(result, id):
|
|
|
328
356
|
return value if isinstance(value, str) else None
|
|
329
357
|
|
|
330
358
|
|
|
359
|
+
def text(result, id):
|
|
360
|
+
"""The prose a `text` question came back with, stripped, or None. Only a
|
|
361
|
+
generative provider answers one; TypeSafe is never sent one."""
|
|
362
|
+
answer = _answer(result, id)
|
|
363
|
+
value = answer.get("text") if answer else None
|
|
364
|
+
return (value.strip() or None) if isinstance(value, str) else None
|
|
365
|
+
|
|
366
|
+
|
|
331
367
|
def noul(result, id, option=None):
|
|
332
368
|
"""The probability a Noul question came back with, or None.
|
|
333
369
|
|
|
@@ -448,17 +484,20 @@ def _chat_answers(parsed, questions):
|
|
|
448
484
|
def _clean_answer(raw, kind):
|
|
449
485
|
"""One answer in the shape its question type names, or None.
|
|
450
486
|
|
|
451
|
-
The
|
|
452
|
-
statement holds,
|
|
453
|
-
`choice()`/`noul()` already read, so a fallback answer
|
|
454
|
-
are indistinguishable downstream. Any other type reads
|
|
455
|
-
than as a guess. Probabilities are kept only when they
|
|
456
|
-
`noul()` does the arithmetic on them."""
|
|
487
|
+
The shapes this repository's callers send - `noul`, the probability a
|
|
488
|
+
statement holds, `choice`, the label taken, and `text`, prose - map onto
|
|
489
|
+
exactly what `choice()`/`noul()`/`text()` already read, so a fallback answer
|
|
490
|
+
and a TypeSafe answer are indistinguishable downstream. Any other type reads
|
|
491
|
+
as no answer rather than as a guess. Probabilities are kept only when they
|
|
492
|
+
are numbers, because `noul()` does the arithmetic on them."""
|
|
457
493
|
if not isinstance(raw, dict):
|
|
458
494
|
return None
|
|
459
495
|
if kind == "noul":
|
|
460
496
|
value = raw.get("noul")
|
|
461
497
|
return {"noul": float(value)} if isinstance(value, (int, float)) else None
|
|
498
|
+
if kind == "text":
|
|
499
|
+
value = raw.get("text")
|
|
500
|
+
return {"text": value} if isinstance(value, str) and value.strip() else None
|
|
462
501
|
if kind != "choice":
|
|
463
502
|
return None
|
|
464
503
|
if not isinstance(raw.get("choice"), str):
|
package/hooks/tezgah_taste.py
CHANGED
|
@@ -9,11 +9,12 @@ workspace that came with the clone is data, and nothing of the user's is
|
|
|
9
9
|
written into it). The store is `<git root>/.tezgah/taste/signals.jsonl`, one
|
|
10
10
|
JSON object per line, owner-only.
|
|
11
11
|
|
|
12
|
-
Every public function is total: it never raises
|
|
13
|
-
|
|
14
|
-
the switch is read before anything else is.
|
|
12
|
+
Every public function is total: it never raises and makes no network call.
|
|
13
|
+
The capture functions append at most one row each. They run on the prompt and
|
|
14
|
+
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
|
+
thing fed back from here is `write_note`, the learnings in scope for a write.
|
|
17
18
|
"""
|
|
18
19
|
import datetime
|
|
19
20
|
import json
|
|
@@ -181,16 +182,41 @@ def note_write(session_id, row_id, inp, cwd, host=None):
|
|
|
181
182
|
pass
|
|
182
183
|
|
|
183
184
|
|
|
185
|
+
def write_note(session_id, inp, cwd, tool=None):
|
|
186
|
+
"""The in-scope taste learnings this write has not yet shown in the session
|
|
187
|
+
(`tezgah_taste_ledger.write_note`), or "". Every host's post-tool
|
|
188
|
+
channel carries it beside its other notices; off, it costs the marker stat.
|
|
189
|
+
`tool` is the call's tool name; a call that is not a write gets nothing."""
|
|
190
|
+
try:
|
|
191
|
+
if not armed(ARM) or not isinstance(inp, dict) or inp.get("command") == "view" \
|
|
192
|
+
or (tool is not None and str(tool).lower() not in ti.WRITE_TOOLS):
|
|
193
|
+
return ""
|
|
194
|
+
from tezgah_gate import write_paths
|
|
195
|
+
paths = write_paths(inp)
|
|
196
|
+
root = enabled(cwd)
|
|
197
|
+
rel = _rel(root, paths[0], cwd) if root and paths else None
|
|
198
|
+
if rel is None:
|
|
199
|
+
return ""
|
|
200
|
+
import tezgah_taste_ledger
|
|
201
|
+
return tezgah_taste_ledger.write_note(root, session_id, rel)
|
|
202
|
+
except Exception:
|
|
203
|
+
return ""
|
|
204
|
+
|
|
205
|
+
|
|
184
206
|
def main(argv):
|
|
185
|
-
"""`tezgah_taste.py '<json>'`: `note_write` for a host
|
|
186
|
-
in process (opencode's plugin is JavaScript). The one
|
|
187
|
-
{"session_id", "id", "input", "cwd", "host"}.
|
|
207
|
+
"""`tezgah_taste.py '<json>'`: `note_write`, then `write_note`, for a host
|
|
208
|
+
that cannot call them in process (opencode's plugin is JavaScript). The one
|
|
209
|
+
argument is {"session_id", "id", "input", "cwd", "host"}. Prints the note
|
|
210
|
+
(nothing when there is none); exit 0 always."""
|
|
188
211
|
try:
|
|
189
212
|
payload = json.loads(argv[0]) if argv else {}
|
|
190
213
|
if isinstance(payload, dict):
|
|
214
|
+
cwd = payload.get("cwd") or os.getcwd()
|
|
191
215
|
note_write(payload.get("session_id"), payload.get("id"),
|
|
192
|
-
payload.get("input"), payload.get("
|
|
193
|
-
|
|
216
|
+
payload.get("input"), cwd, payload.get("host"))
|
|
217
|
+
note = write_note(payload.get("session_id"), payload.get("input"), cwd)
|
|
218
|
+
if note:
|
|
219
|
+
print(note)
|
|
194
220
|
except Exception:
|
|
195
221
|
pass
|
|
196
222
|
return 0
|