jev-grep 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.
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.5
2
+ Name: jev-grep
3
+ Version: 0.1.0
4
+ Summary: grep by meaning: filter lines with a plain-English description, judged by TypeSafe's Jev model
5
+ Project-URL: Repository, https://github.com/keltokhy/jgrep
6
+ Author: Khaled Eltokhy
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: cli,grep,jev,openrouter,semantic search,typesafe
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Topic :: Text Processing :: Filters
13
+ Classifier: Topic :: Utilities
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: httpx>=0.27
16
+ Description-Content-Type: text/markdown
17
+
18
+ # jgrep
19
+
20
+ grep, but the pattern is a description.
21
+
22
+ ```console
23
+ $ tail -f app.log | jgrep "a user is getting frustrated"
24
+ user 12: this is the third time checkout has failed, I am done with this app
25
+ user 77: WHY does it log me out every five minutes??
26
+
27
+ $ jgrep -o "announces or releases a new AI model" titles.txt | sort -rn | head -3
28
+ 0.980 PrismML Launches Bonsai 2 27B, Its Most Capable Model Yet
29
+ 0.970 Alibaba Releases Qwen3.8-Omni-Flash
30
+ 0.940 Google announces new experimental "CC" AI agent for families
31
+ ```
32
+
33
+ Each line becomes one yes/no question to [Jev](https://docs.typesafe.ai), TypeSafe's decision
34
+ model. Jev does not generate text. It returns a probability in about 200 ms for about a
35
+ thousandth of a cent, which is fast and cheap enough to sit in a pipe. jgrep reads lines as
36
+ they arrive, judges them concurrently and prints matches in input order, so it works on
37
+ `tail -f` as well as on files.
38
+
39
+ Measured on 994 Hacker News titles: 4.6 seconds and $0.012 for one description, and the same
40
+ time for three descriptions at once.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ uv tool install git+https://github.com/keltokhy/jgrep
46
+ ```
47
+
48
+ jgrep needs a key for one of two APIs. With keys for both, it uses TypeSafe's.
49
+
50
+ | API | Key | Get one |
51
+ |---|---|---|
52
+ | TypeSafe | `TYPESAFE_API_KEY` | [console.typesafe.ai](https://console.typesafe.ai/settings/keys) |
53
+ | OpenRouter | `OPENROUTER_API_KEY` | [openrouter.ai/keys](https://openrouter.ai/keys) |
54
+
55
+ Set the environment variable, or put the key in `~/.config/jev/typesafe.key` or
56
+ `~/.config/jev/openrouter.key`. Force a choice with `--api` or `JEV_API`.
57
+
58
+ ## Use
59
+
60
+ ```bash
61
+ jgrep "a complaint about noise" complaints.txt # lines that fit
62
+ jgrep -v "spam" inbox.txt # lines that do not
63
+ jgrep -c "asks a question" *.txt # counts per file
64
+ jgrep -p 0.9 "mentions a specific dollar amount" f.txt # only confident matches
65
+ jgrep -o -p 0 "the writer is losing sleep" f.txt | sort -rn # rank every line
66
+ jgrep -e "about economics" -e "about New York" f.txt # either; add --all for both
67
+ jgrep --para "describes an identification strategy" paper.txt
68
+ jgrep --whole "uses a bunching estimator" abstracts/*.txt # prints matching file names
69
+ jgrep -q "a stack trace" build.log && notify "build broke"
70
+ ```
71
+
72
+ | Option | Meaning |
73
+ |---|---|
74
+ | `-p P` | Match when the probability is at least P. Default 0.5. |
75
+ | `-o` | Put the probability in a first, tab-separated column. |
76
+ | `-v`, `-c`, `-n`, `-H`, `-m NUM`, `-q` | As in grep. |
77
+ | `-e DESC` | Another description. All of them go in one call per line. A line matches if any fits, or all with `--all`. |
78
+ | `--para`, `--whole` | Judge paragraphs or whole files in place of lines. |
79
+ | `--json` | One JSON object per match, with the probability. |
80
+ | `--unordered` | Print matches as answers arrive. |
81
+ | `-j N` | Calls in flight. Default 32. |
82
+ | `--budget DOLLARS` | Stop once this much is spent. Default 1.00, or `$JGREP_BUDGET`; 0 for no limit. |
83
+ | `--timeout SECONDS` | Give up on a line after this long, retries included. Default 15. |
84
+ | `--no-cache`, `--api`, `--model`, `--stats` | See `jgrep --help`. |
85
+
86
+ Exit status follows grep: 0 if anything matched, 1 if nothing did, 2 on error.
87
+
88
+ ## Cost
89
+
90
+ A call bills roughly 270 tokens of fixed overhead plus the line and the description, so a
91
+ typical line costs about 300 tokens, or $0.0000126 at $0.042 per million. A million lines is
92
+ about $13. Blank lines, repeated lines and anything answered before are free: answers are
93
+ cached in `~/.cache/jev/answers.sqlite`, keyed on the exact model, line and description.
94
+ Extra `-e` descriptions add about 27 tokens each and no time.
95
+
96
+ jgrep stops at `--budget`, one dollar by default, so a stray `jgrep pattern huge.log` cannot
97
+ run up a bill. A dollar is about 80,000 lines. A stopped run loses nothing: rerun with a higher
98
+ budget and everything already judged comes from the cache. For a long-lived `tail -f` monitor,
99
+ set your own default once with `export JGREP_BUDGET=20`, or `0` for no limit. With `--stats`, or whenever stderr is a terminal, it prints what the run cost:
100
+
101
+ ```
102
+ jgrep: 994 records, 33 matched; 994 calls, 0 cached; 292,839 tokens; $0.0123; 4.6s
103
+ ```
104
+
105
+ ## How well does it work
106
+
107
+ Three benchmarks on public labeled text, run on 2026-09-18 with Jev 1.13 through OpenRouter.
108
+ Each one runs the installed `jgrep` command itself, uncached, at its default threshold of 0.5.
109
+ Reproduce them with `bench/accuracy.py`.
110
+
111
+ **Against a keyword grep.** The UCI SMS Spam Collection: 5,574 text messages, 747 of them spam.
112
+
113
+ | Filter | Precision | Recall | F1 | Time | Cost |
114
+ |---|---:|---:|---:|---:|---:|
115
+ | `jgrep "an unsolicited spam, scam or marketing text message"` | 0.87 | 0.95 | **0.91** | 27 s | $0.07 |
116
+ | the same with `-p 0.9` | 0.98 | 0.84 | 0.90 | | |
117
+ | `grep -iE "free\|win\|prize\|claim\|urgent\|cash\|txt\|call now\|..."` (17 terms) | 0.64 | 0.81 | 0.72 | 0.03 s | free |
118
+
119
+ The regular expression was written before looking at any results and is in the script.
120
+
121
+ **Against asking a chat model.** The do-it-yourself alternative is a loop that asks an LLM the
122
+ same yes/no question about each line. On 300 of those messages, 32 requests in flight, all
123
+ through OpenRouter:
124
+
125
+ | Judge | F1 | Wall time | Cost | Median latency |
126
+ |---|---:|---:|---:|---:|
127
+ | **jgrep (Jev 1.13)** | 0.90 | **2.7 s** | $0.0039 | about 210 ms |
128
+ | GPT Luna | 0.88 | 9.3 s | $0.0060 | 802 ms |
129
+ | GPT Terra | 0.92 | 10.8 s | $0.0571 | 988 ms |
130
+ | Qwen 3.7 Flash, thinking off | 0.78 | 8.4 s | $0.0007 | 789 ms |
131
+
132
+ jgrep finished three to four times sooner than any of them. Its accuracy sits between the two
133
+ GPT tiers; with 45 spam messages in the sample, those three F1 scores are within noise of each
134
+ other. It is not the cheapest per line: a small open model costs a sixth as much and is
135
+ clearly less accurate. Against the model that matched its accuracy, jgrep cost a fifteenth
136
+ as much.
137
+
138
+ **Several descriptions at once.** AG News test set, 7,600 articles, four descriptions
139
+ (`-e "news about sports" -e "news about business, markets or the economy" ...`) judged in one
140
+ call per article: 37 seconds and $0.13 for all four. Taking the most probable description as the
141
+ label gives 86.6% accuracy with no training. One-vs-rest F1 at 0.5 was 0.97 for sports, 0.82 for
142
+ science and technology, 0.82 for world affairs and 0.72 for business, which over-triggers
143
+ (precision 0.58) because so much technology news is also business news.
144
+
145
+ **Does the wording of a description matter?** `bench/phrasing.py` scores 30 hand-labeled lines
146
+ against five descriptions of different grammatical shapes, including a negation and a question.
147
+ Jev got all 150 right under each of four ways of wording the question; that set is easy on
148
+ purpose. Asking five descriptions in one call changed no decision and moved probabilities by
149
+ 0.001 on average. Latency was flat at about 210 ms from 1 to 64 questions per call.
150
+
151
+ On borderline lines the probabilities land in between, which is what `-p` is for:
152
+
153
+ ```
154
+ 0.65 [a complaint about noise] The music from the church on Sunday mornings is lovely but it does start early.
155
+ 0.46 [does not mention a landlord] The owner of the building never answers the phone.
156
+ ```
157
+
158
+ Things to know:
159
+
160
+ - These are a model's judgments. Check a sample before you rely on a filter.
161
+ - Jev answers the description you wrote, not the one you meant. TypeSafe
162
+ [documents](https://docs.typesafe.ai/model-jaggedness/jev-1.13) weak spots: counting,
163
+ comparing numbers or dates, double negatives, and long inputs full of irrelevant detail.
164
+ - Each line is judged alone. jgrep does not show Jev the lines around it.
165
+ - Jev is close to deterministic, not exactly so. Asking 150 questions three times without the
166
+ cache gave identical probabilities for 128; the rest moved by up to 0.03 and no decision
167
+ flipped. The cache makes reruns exact.
168
+ - The default model ID is an alias for the latest Jev. For results that must reproduce, pin
169
+ one with `--model` (for example `typesafe/jev-1.13` on OpenRouter).
170
+ - Text in the input can try to steer the answer. Do not use jgrep as a security boundary.
171
+
172
+ ## Development
173
+
174
+ ```bash
175
+ uv sync && uv run pytest # 23 tests against a fake API; no key, no network
176
+ uv run python bench/phrasing.py # live; costs about a cent
177
+ uv run python bench/accuracy.py prepare && uv run python bench/accuracy.py spam # also: news, llm
178
+ ```
179
+
180
+ `src/jgrep/core.py` is the client: two backends, retries inside a time budget, the cache,
181
+ in-flight deduplication and the cost meter. It is shared verbatim with
182
+ [jlink](https://github.com/keltokhy/jlink), which links records across datasets with the same
183
+ model.
184
+
185
+ MIT license.
@@ -0,0 +1,9 @@
1
+ jgrep/__init__.py,sha256=VcrTKR06N1C0v00rOdHwACjIILKLCp46bPSxNwdGRto,87
2
+ jgrep/__main__.py,sha256=Huz0dExiaH0XTMLhfe3skkFD9-VH7ZUiWMbDR_J8TIY,28
3
+ jgrep/cli.py,sha256=-2PWnc4-HYckM0s3ySVGZQ6FcE9c0zPDuFQ6lCLVMuo,13539
4
+ jgrep/core.py,sha256=ivXkuSpwww5LbdkAmTpwsd4g-3jVbeejxiXeyu7VZFM,10066
5
+ jev_grep-0.1.0.dist-info/METADATA,sha256=ctTGHVDaFFnceuIwmxShgSmJlqNLhG8hVKA_QJr-Q1Q,9202
6
+ jev_grep-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
7
+ jev_grep-0.1.0.dist-info/entry_points.txt,sha256=JRibfW3HBfTq5K8S7BMbOfPB4135MoBJOPigH1aIig0,40
8
+ jev_grep-0.1.0.dist-info/licenses/LICENSE,sha256=unAu2Ii_6qZZfNfkD44Vj0MNv9C7H6CBRje23cMNx4g,1071
9
+ jev_grep-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jgrep = jgrep.cli:cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Khaled Eltokhy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
jgrep/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """jgrep: grep by meaning, on TypeSafe's Jev decision model."""
2
+
3
+ __version__ = "0.1.0"
jgrep/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import cli
2
+
3
+ cli()
jgrep/cli.py ADDED
@@ -0,0 +1,304 @@
1
+ """jgrep: print lines that fit a description.
2
+
3
+ tail -f app.log | jgrep "a user is getting frustrated"
4
+ jgrep -o "asks for police overtime records" requests.txt | sort -rn | head
5
+ jgrep -c -p 0.8 "uses a bunching estimator" abstracts.txt
6
+
7
+ Each line is one yes/no question to Jev. Lines are read as they arrive, judged concurrently and
8
+ printed in input order. Exit status follows grep: 0 if anything matched, 1 if not, 2 on error.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import asyncio
15
+ import json
16
+ import os
17
+ import sys
18
+ import threading
19
+ import time
20
+ from dataclasses import dataclass
21
+
22
+ from . import __version__
23
+ from .core import BACKENDS, Cache, Jev, JevError, JevFatal, config_dir, resolve_backend
24
+
25
+ STDIN = "(standard input)"
26
+ MAX_ERRORS_SHOWN = 10
27
+ DEFAULT_BUDGET = 1.0 # dollars; a grep-shaped command that bills per line needs a seat belt
28
+
29
+
30
+ @dataclass
31
+ class Record:
32
+ seq: int
33
+ file: str
34
+ lineno: int
35
+ text: str
36
+
37
+
38
+ def question(description: str) -> dict:
39
+ return {"type": "noul", "instructions": f'The text fits this description: "{description}"'}
40
+
41
+
42
+ def parser() -> argparse.ArgumentParser:
43
+ ap = argparse.ArgumentParser(
44
+ prog="jgrep", formatter_class=argparse.RawDescriptionHelpFormatter,
45
+ usage="jgrep [options] DESCRIPTION [FILE ...]",
46
+ description="Print lines that fit a plain-English description, as judged by TypeSafe's Jev model.",
47
+ epilog='examples:\n'
48
+ ' tail -f app.log | jgrep "a user is getting frustrated"\n'
49
+ ' jgrep -o "about heat or hot water" complaints.txt | sort -rn | head\n'
50
+ ' jgrep -v -p 0.2 "spam" inbox.txt\n'
51
+ ' jgrep --whole "uses a bunching estimator" abstracts/*.txt\n\n'
52
+ "Jev is reached through TypeSafe's API (TYPESAFE_API_KEY) or OpenRouter (OPENROUTER_API_KEY).\n"
53
+ f"Keys can also live in {config_dir()}/typesafe.key or openrouter.key.")
54
+ ap.add_argument("args", nargs="*", help=argparse.SUPPRESS)
55
+ ap.add_argument("-e", dest="descriptions", action="append", metavar="DESCRIPTION",
56
+ help="a description; repeat for several, which are judged in one call (a line matches if any fits)")
57
+ ap.add_argument("--all", action="store_true", help="with several -e, a line must fit all of them")
58
+ ap.add_argument("-p", "--threshold", type=float, default=0.5, metavar="P",
59
+ help="match when the probability is at least P (default 0.5)")
60
+ ap.add_argument("-v", "--invert-match", action="store_true", help="print lines that do not match")
61
+ ap.add_argument("-o", "--prob", action="store_true", help="put the probability in a first, tab-separated column")
62
+ ap.add_argument("-n", "--line-number", action="store_true", help="prefix each line with its line number")
63
+ ap.add_argument("-H", "--with-filename", action="store_true", help="prefix each line with its file name")
64
+ ap.add_argument("--no-filename", action="store_true", help="never print file names")
65
+ ap.add_argument("-c", "--count", action="store_true", help="print only a count of matching lines")
66
+ ap.add_argument("-m", "--max-count", type=int, metavar="NUM", help="stop after NUM matches")
67
+ ap.add_argument("-q", "--quiet", action="store_true", help="print nothing; exit 0 at the first match")
68
+ ap.add_argument("--json", action="store_true", help="print one JSON object per match")
69
+ ap.add_argument("--para", action="store_true", help="judge paragraphs (separated by blank lines), not lines")
70
+ ap.add_argument("--whole", action="store_true", help="judge each file as a whole and print matching file names")
71
+ ap.add_argument("--unordered", action="store_true", help="print matches as answers arrive, not in input order")
72
+ ap.add_argument("-j", "--concurrency", type=int, default=32, metavar="N", help="calls in flight (default 32)")
73
+ ap.add_argument("--timeout", type=float, default=15.0, metavar="SECONDS",
74
+ help="give up on a line after this long, retries included (default 15)")
75
+ ap.add_argument("--budget", type=float, default=None, metavar="DOLLARS",
76
+ help="stop once this much has been spent (default 1.00, or $JGREP_BUDGET; 0 for no limit)")
77
+ ap.add_argument("--max-chars", type=int, default=8000, metavar="N",
78
+ help="judge only the first N characters of a record (default 8000)")
79
+ ap.add_argument("--no-cache", action="store_true", help="do not read or write the answer cache")
80
+ ap.add_argument("--api", choices=list(BACKENDS), help="which API to call (default: whichever has a key)")
81
+ ap.add_argument("--model", metavar="ID", help="model ID to request (default: the API's latest Jev)")
82
+ ap.add_argument("--stats", action=argparse.BooleanOptionalAction, default=None,
83
+ help="print calls, tokens and cost to stderr at the end (default: when stderr is a terminal)")
84
+ ap.add_argument("--version", action="version", version=f"jgrep {__version__}")
85
+ return ap
86
+
87
+
88
+ def records(files: list[str], args, stop: threading.Event):
89
+ """Yield Records lazily, so `tail -f` works. Yields a str for a file that cannot be read."""
90
+ seq = 0
91
+ for name in files or ["-"]:
92
+ label = STDIN if name == "-" else name
93
+ try:
94
+ # A private reader on fd 0: sys.stdin's lock can wedge interpreter shutdown.
95
+ f = open(0, "rb", closefd=False) if name == "-" else open(name, "rb")
96
+ except OSError as e:
97
+ yield f"{name}: {e.strerror}"
98
+ continue
99
+ with f:
100
+ if args.whole:
101
+ yield Record(seq, label, 1, f.read(args.max_chars * 4).decode("utf-8", "replace"))
102
+ seq += 1
103
+ continue
104
+ para, start = [], 0
105
+ for lineno, raw in enumerate(iter(f.readline, b""), 1):
106
+ if stop.is_set():
107
+ return
108
+ line = raw.decode("utf-8", "replace").rstrip("\r\n")
109
+ if not args.para:
110
+ yield Record(seq, label, lineno, line)
111
+ seq += 1
112
+ elif line.strip():
113
+ start = start if para else lineno
114
+ para.append(line)
115
+ elif para:
116
+ yield Record(seq, label, start, "\n".join(para))
117
+ seq, para = seq + 1, []
118
+ if para:
119
+ yield Record(seq, label, start, "\n".join(para))
120
+ seq += 1
121
+
122
+
123
+ def render(rec: Record, p: float, ps: list[float], args, show_file: bool) -> str:
124
+ if args.json:
125
+ obj = {"file": rec.file, "line": rec.lineno, "p": round(p, 4)}
126
+ if len(ps) > 1:
127
+ obj["ps"] = [round(x, 4) for x in ps]
128
+ if not args.whole:
129
+ obj["text"] = rec.text
130
+ return json.dumps(obj, ensure_ascii=False)
131
+ if args.whole:
132
+ body = rec.file
133
+ else:
134
+ body = (f"{rec.file}:" if show_file else "") + (f"{rec.lineno}:" if args.line_number else "") + rec.text
135
+ if args.prob:
136
+ body = f"{p:.3f}\t{body}"
137
+ return body + ("\n" if args.para and not args.whole else "")
138
+
139
+
140
+ async def run(args, descriptions: list[str], files: list[str], jev: Jev, out, err) -> int:
141
+ loop = asyncio.get_running_loop()
142
+ questions = {f"d{i}": question(d) for i, d in enumerate(descriptions)}
143
+ show_file = not args.no_filename and (args.with_filename or len(files) > 1)
144
+ queue: asyncio.Queue = asyncio.Queue(maxsize=args.concurrency)
145
+ sem = asyncio.Semaphore(args.concurrency)
146
+ stop, halt = threading.Event(), asyncio.Event()
147
+ finished: dict[int, tuple] = {}
148
+ tasks: set[asyncio.Task] = set()
149
+ counts: dict[str, int] = {}
150
+ s = {"next": 0, "seen": 0, "matched": 0, "errors": 0, "fatal": None, "over_budget": False}
151
+
152
+ def feed() -> None:
153
+ try:
154
+ for item in records(files, args, stop):
155
+ asyncio.run_coroutine_threadsafe(queue.put(item), loop).result()
156
+ asyncio.run_coroutine_threadsafe(queue.put(None), loop).result()
157
+ except BaseException: # the loop is gone because the run halted early
158
+ pass
159
+
160
+ def complain(message: str) -> None:
161
+ s["errors"] += 1
162
+ if s["errors"] <= MAX_ERRORS_SHOWN:
163
+ print(f"jgrep: {message}", file=err)
164
+
165
+ def emit(rec: Record, p: float | None, ps: list[float] | None, error: str | None) -> None:
166
+ s["seen"] += 1
167
+ if error:
168
+ return complain(f"{rec.file}:{rec.lineno}: {error}")
169
+ if (p >= args.threshold) == args.invert_match:
170
+ return
171
+ s["matched"] += 1
172
+ counts[rec.file] = counts.get(rec.file, 0) + 1
173
+ if not (args.quiet or args.count):
174
+ try:
175
+ out.write(render(rec, p, ps, args, show_file) + "\n")
176
+ out.flush()
177
+ except BrokenPipeError:
178
+ halt.set()
179
+ if args.quiet or (args.max_count and s["matched"] >= args.max_count):
180
+ halt.set()
181
+
182
+ def deliver(rec: Record, result: tuple) -> None:
183
+ if args.unordered:
184
+ return None if halt.is_set() else emit(rec, *result)
185
+ finished[rec.seq] = (rec, *result)
186
+ while s["next"] in finished and not halt.is_set():
187
+ emit(*finished.pop(s["next"]))
188
+ s["next"] += 1
189
+
190
+ async def judge(rec: Record) -> None:
191
+ try:
192
+ if not rec.text.strip():
193
+ result = (0.0, [0.0] * len(questions), None)
194
+ else:
195
+ answers = await jev.ask(rec.text[:args.max_chars], questions)
196
+ ps = [float(answers[q]["noul"]) for q in questions]
197
+ result = (min(ps) if args.all else max(ps), ps, None)
198
+ except JevError as e:
199
+ result = (None, None, str(e))
200
+ except JevFatal as e:
201
+ s["fatal"] = s["fatal"] or str(e)
202
+ return halt.set()
203
+ finally:
204
+ sem.release()
205
+ deliver(rec, result)
206
+ if args.budget and jev.meter.cost >= args.budget and not s["over_budget"]:
207
+ s["over_budget"] = True
208
+ halt.set()
209
+
210
+ threading.Thread(target=feed, daemon=True).start()
211
+ halted = asyncio.ensure_future(halt.wait())
212
+ while not halt.is_set():
213
+ get = asyncio.ensure_future(queue.get())
214
+ await asyncio.wait({get, halted}, return_when=asyncio.FIRST_COMPLETED)
215
+ if not get.done():
216
+ get.cancel()
217
+ break
218
+ item = get.result()
219
+ if item is None:
220
+ break
221
+ if isinstance(item, str):
222
+ complain(item)
223
+ continue
224
+ await sem.acquire()
225
+ if halt.is_set():
226
+ break
227
+ task = asyncio.create_task(judge(item))
228
+ tasks.add(task)
229
+ task.add_done_callback(tasks.discard)
230
+
231
+ stop.set()
232
+ halted.cancel()
233
+ if halt.is_set():
234
+ for t in list(tasks):
235
+ t.cancel()
236
+ await asyncio.gather(*tasks, return_exceptions=True)
237
+ await jev.close()
238
+
239
+ if args.count and not args.quiet:
240
+ names = [STDIN if f == "-" else f for f in (files or ["-"])]
241
+ for name in names:
242
+ print(f"{name}:{counts.get(name, 0)}" if show_file else counts.get(name, 0), file=out)
243
+ out.flush()
244
+ if s["errors"] > MAX_ERRORS_SHOWN:
245
+ print(f"jgrep: and {s['errors'] - MAX_ERRORS_SHOWN:,} more errors", file=err)
246
+ if s["fatal"]:
247
+ print(f"jgrep: {s['fatal']}", file=err)
248
+ if s["over_budget"]:
249
+ print(f"jgrep: stopped at the ${args.budget:.2f} budget after {s['seen']:,} records; raise it with --budget",
250
+ file=err)
251
+ args.summary = f"{s['seen']:,} records, {s['matched']:,} matched; {jev.meter.summary()}"
252
+ if s["fatal"] or s["over_budget"] or s["errors"]:
253
+ return 0 if args.quiet and s["matched"] else 2
254
+ return 0 if s["matched"] else 1
255
+
256
+
257
+ def main(argv: list[str] | None = None, *, transport=None, out=None, err=None) -> int:
258
+ out, err = out or sys.stdout, err or sys.stderr
259
+ ap = parser()
260
+ args = ap.parse_args(argv)
261
+ descriptions, files = (args.descriptions, args.args) if args.descriptions else (args.args[:1], args.args[1:])
262
+ if not descriptions:
263
+ ap.print_usage(err)
264
+ return 2
265
+ if args.budget is None:
266
+ try:
267
+ args.budget = float(os.environ.get("JGREP_BUDGET") or DEFAULT_BUDGET)
268
+ except ValueError:
269
+ print(f"jgrep: JGREP_BUDGET must be a number of dollars; got {os.environ['JGREP_BUDGET']!r}", file=err)
270
+ return 2
271
+ if args.whole and args.para:
272
+ print("jgrep: --whole and --para cannot be combined", file=err)
273
+ return 2
274
+ try:
275
+ backend, key = resolve_backend(args.api)
276
+ except JevFatal as e:
277
+ print(f"jgrep: {e}", file=err)
278
+ return 2
279
+
280
+ jev = Jev(key, backend, model=args.model, timeout=args.timeout, concurrency=args.concurrency,
281
+ cache=None if args.no_cache else Cache(), transport=transport)
282
+ t0 = time.perf_counter()
283
+ try:
284
+ code = asyncio.run(run(args, descriptions, files, jev, out, err))
285
+ except KeyboardInterrupt:
286
+ code, args.summary = 130, f"interrupted; {jev.meter.summary()}"
287
+ if args.stats or (args.stats is None and err.isatty()):
288
+ print(f"jgrep: {args.summary}; {time.perf_counter() - t0:.1f}s", file=err)
289
+ return code
290
+
291
+
292
+ def cli() -> None:
293
+ code = main()
294
+ try:
295
+ sys.stdout.flush()
296
+ except BrokenPipeError:
297
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
298
+ sys.stderr.flush()
299
+ # A reader thread may still be blocked on stdin (tail -f); do not wait for it.
300
+ os._exit(code)
301
+
302
+
303
+ if __name__ == "__main__":
304
+ cli()
jgrep/core.py ADDED
@@ -0,0 +1,244 @@
1
+ """Client for TypeSafe's Jev decision model: two backends, an answer cache and a cost meter.
2
+
3
+ Jev can be reached through TypeSafe's own API or through OpenRouter. Both take one state and any
4
+ number of questions per call and return one typed answer per question. Answers are cached per
5
+ (model, state, question), so packing questions into a call and rerunning a command are both cheap.
6
+
7
+ This file is shared verbatim between the jgrep and jlink repositories.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import random
17
+ import sqlite3
18
+ import time
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+
22
+ import httpx
23
+
24
+ RETRYABLE = {408, 429, 500, 502, 503, 504, 529}
25
+ FATAL = {401, 402, 403}
26
+ # TypeSafe's API reports tokens but not cost; OpenRouter reports both.
27
+ PRICE_PER_MTOK = float(os.environ.get("JEV_PRICE_PER_MTOK", 0.042))
28
+
29
+
30
+ class JevError(Exception):
31
+ """One request failed; the rest of the run can continue."""
32
+
33
+
34
+ class JevFatal(Exception):
35
+ """Nothing will work until the user fixes something, such as a bad key or no credits."""
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Backend:
40
+ name: str
41
+ url: str
42
+ model: str
43
+ key_env: str
44
+
45
+ @property
46
+ def key_file(self) -> Path:
47
+ return config_dir() / f"{self.name}.key"
48
+
49
+ def key(self) -> str | None:
50
+ if os.environ.get(self.key_env):
51
+ return os.environ[self.key_env].strip()
52
+ return self.key_file.read_text().strip() if self.key_file.exists() else None
53
+
54
+
55
+ # Order matters: with keys for both, TypeSafe's own API is used.
56
+ BACKENDS = {
57
+ "typesafe": Backend("typesafe", "https://api.typesafe.ai/v1/systemone", "jev-latest", "TYPESAFE_API_KEY"),
58
+ "openrouter": Backend("openrouter", "https://openrouter.ai/api/alpha/decisions", "~typesafe/jev-latest",
59
+ "OPENROUTER_API_KEY"),
60
+ }
61
+
62
+
63
+ def config_dir() -> Path:
64
+ return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "jev"
65
+
66
+
67
+ def cache_path() -> Path:
68
+ return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "jev" / "answers.sqlite"
69
+
70
+
71
+ def resolve_backend(name: str | None = None) -> tuple[Backend, str]:
72
+ """The API to use and its key. A name (or JEV_API) wins; otherwise the first backend with a key."""
73
+ name = name or os.environ.get("JEV_API")
74
+ if name:
75
+ if name not in BACKENDS:
76
+ raise JevFatal(f"unknown API {name!r}; choose from {', '.join(BACKENDS)}")
77
+ backend = BACKENDS[name]
78
+ if not (key := backend.key()):
79
+ raise JevFatal(f"no key for {name}. Set {backend.key_env} or put the key in {backend.key_file}")
80
+ return backend, key
81
+ for backend in BACKENDS.values():
82
+ if key := backend.key():
83
+ return backend, key
84
+ options = " or ".join(b.key_env for b in BACKENDS.values())
85
+ raise JevFatal(f"no API key. Set {options}, or put a key in {config_dir()}/<api>.key")
86
+
87
+
88
+ class Cache:
89
+ """Answers on disk, keyed on the exact model, state and question."""
90
+
91
+ def __init__(self, path: Path | None = None):
92
+ path = path or cache_path()
93
+ path.parent.mkdir(parents=True, exist_ok=True)
94
+ self.db = sqlite3.connect(path, timeout=30, isolation_level=None, check_same_thread=False)
95
+ # WAL lets two tools in one pipeline share the file.
96
+ self.db.execute("PRAGMA journal_mode=WAL")
97
+ self.db.execute("PRAGMA synchronous=NORMAL")
98
+ self.db.execute("CREATE TABLE IF NOT EXISTS answers "
99
+ "(key TEXT PRIMARY KEY, answer TEXT NOT NULL, at REAL NOT NULL) WITHOUT ROWID")
100
+
101
+ @staticmethod
102
+ def key(model: str, state, question: dict) -> str:
103
+ blob = json.dumps([model, state, question], sort_keys=True, ensure_ascii=False)
104
+ return hashlib.sha256(blob.encode()).hexdigest()
105
+
106
+ def get(self, key: str) -> dict | None:
107
+ row = self.db.execute("SELECT answer FROM answers WHERE key = ?", (key,)).fetchone()
108
+ return json.loads(row[0]) if row else None
109
+
110
+ def put(self, key: str, answer: dict) -> None:
111
+ self.db.execute("INSERT OR REPLACE INTO answers VALUES (?, ?, ?)", (key, json.dumps(answer), time.time()))
112
+
113
+
114
+ @dataclass
115
+ class Meter:
116
+ calls: int = 0
117
+ cached: int = 0
118
+ retries: int = 0
119
+ input_tokens: int = 0
120
+ cost: float = 0.0
121
+ model: str = "" # the model the API says answered, which resolves aliases like jev-latest
122
+ latencies: list[float] = field(default_factory=list)
123
+
124
+ def summary(self) -> str:
125
+ parts = [f"{self.calls:,} calls, {self.cached:,} cached"]
126
+ if self.retries:
127
+ parts.append(f"{self.retries:,} retries")
128
+ if self.calls:
129
+ parts.append(f"{self.input_tokens:,} tokens")
130
+ parts.append(f"${self.cost:.4f}")
131
+ return "; ".join(parts)
132
+
133
+
134
+ class Jev:
135
+ def __init__(self, key: str, backend: Backend | str = "openrouter", *, model: str | None = None,
136
+ timeout: float = 15.0, attempts: int = 4, concurrency: int = 32, cache: Cache | None = None,
137
+ transport=None):
138
+ self.backend = BACKENDS[backend] if isinstance(backend, str) else backend
139
+ self.model = model or os.environ.get("JEV_MODEL") or self.backend.model
140
+ self.url = os.environ.get("JEV_URL") or self.backend.url
141
+ self.timeout, self.attempts, self.cache = timeout, attempts, cache
142
+ self.meter = Meter()
143
+ self._flights: dict[str, asyncio.Task] = {}
144
+ self.http = httpx.AsyncClient(
145
+ headers={"Authorization": f"Bearer {key}", "X-Title": "jev tools"},
146
+ limits=httpx.Limits(max_connections=concurrency + 4, max_keepalive_connections=concurrency + 4),
147
+ transport=transport,
148
+ )
149
+
150
+ async def close(self) -> None:
151
+ await self.http.aclose()
152
+
153
+ async def ask(self, state, questions: dict[str, dict]) -> dict[str, dict]:
154
+ """Answer every question about one state. Only questions missing from the cache are sent."""
155
+ keys = {qid: Cache.key(self.model, state, q) for qid, q in questions.items()}
156
+ answers = {}
157
+ if self.cache:
158
+ for qid, k in keys.items():
159
+ if (hit := self.cache.get(k)) is not None:
160
+ answers[qid] = hit
161
+ misses = {qid: q for qid, q in questions.items() if qid not in answers}
162
+ if not misses:
163
+ self.meter.cached += 1
164
+ return answers
165
+
166
+ # Identical requests already in the air share one call; logs repeat themselves a lot.
167
+ flight = "|".join(sorted(keys[qid] for qid in misses))
168
+ task = self._flights.get(flight)
169
+ if task is None:
170
+ task = asyncio.ensure_future(self._call(state, misses))
171
+ self._flights[flight] = task
172
+ task.add_done_callback(lambda _: self._flights.pop(flight, None))
173
+ else:
174
+ self.meter.cached += 1
175
+ by_key = await task
176
+ return answers | {qid: by_key[keys[qid]] for qid in misses}
177
+
178
+ async def _call(self, state, questions: dict[str, dict]) -> dict[str, dict]:
179
+ """One request, retried inside a total time budget. Returns answers by cache key."""
180
+ body = {"model": self.model, "state": state, "questions": questions}
181
+ deadline = time.monotonic() + self.timeout
182
+ last = "no attempt made"
183
+ for attempt in range(self.attempts):
184
+ remaining = deadline - time.monotonic()
185
+ if remaining <= 0:
186
+ break
187
+ t0 = time.perf_counter()
188
+ try:
189
+ r = await self.http.post(self.url, json=body, timeout=remaining)
190
+ except httpx.TransportError as e:
191
+ last = type(e).__name__
192
+ else:
193
+ data = _json(r)
194
+ if r.status_code == 200 and "answers" in data:
195
+ return self._record(state, questions, data, time.perf_counter() - t0)
196
+ detail = _detail(data) or r.text[:200]
197
+ if r.status_code in FATAL:
198
+ raise JevFatal(f"{self.backend.name} said {r.status_code}: {detail}")
199
+ if r.status_code != 200 and r.status_code not in RETRYABLE:
200
+ raise JevError(f"HTTP {r.status_code}: {detail}")
201
+ last = f"HTTP {r.status_code}"
202
+ if attempt + 1 < self.attempts:
203
+ self.meter.retries += 1
204
+ pause = 0.2 * 2 ** attempt + random.random() * 0.1
205
+ await asyncio.sleep(max(0.0, min(pause, deadline - time.monotonic())))
206
+ raise JevError(f"gave up after {self.timeout:g}s ({last})")
207
+
208
+ def _record(self, state, questions: dict, data: dict, seconds: float) -> dict[str, dict]:
209
+ usage = data.get("usage") or {}
210
+ tokens = usage.get("input_tokens") or 0
211
+ cost = usage.get("cost")
212
+ self.meter.calls += 1
213
+ self.meter.input_tokens += tokens
214
+ self.meter.cost += tokens * PRICE_PER_MTOK / 1e6 if cost is None else cost
215
+ self.meter.latencies.append(seconds)
216
+ self.meter.model = data.get("model") or self.model
217
+ out = {}
218
+ for qid, q in questions.items():
219
+ if qid not in data["answers"]:
220
+ raise JevError(f"no answer returned for question {qid!r}")
221
+ k = Cache.key(self.model, state, q)
222
+ out[k] = data["answers"][qid]
223
+ if self.cache:
224
+ self.cache.put(k, out[k])
225
+ return out
226
+
227
+
228
+ def _json(r: httpx.Response) -> dict:
229
+ try:
230
+ data = r.json()
231
+ except ValueError:
232
+ return {}
233
+ return data if isinstance(data, dict) else {}
234
+
235
+
236
+ def _detail(data: dict) -> str:
237
+ """The human-readable part of an error body. OpenRouter nests it under `error`, TypeSafe under `detail`,
238
+ and `detail` may itself be a string, an object or a list of validation problems."""
239
+ found = data.get("error", data.get("detail"))
240
+ if isinstance(found, list):
241
+ found = "; ".join(_detail({"detail": item}) for item in found)
242
+ elif isinstance(found, dict):
243
+ found = found.get("message") or found.get("msg") or json.dumps(found)
244
+ return " ".join(str(found or "").split())[:200]