jevq 0.1.0__tar.gz

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.
jevq-0.1.0/.gitignore ADDED
@@ -0,0 +1,60 @@
1
+
2
+ # Beads / Dolt files (added by bd init)
3
+ .dolt/
4
+ *.db
5
+ .beads-credential-key
6
+ .beads/proxieddb/
7
+ *.gate.lock*
8
+
9
+ # BEGIN ortus block=gitignore schema=1 generated-by=ortus@0.4.1.dev103+ga5d6da2
10
+ # IDE
11
+ .idea/
12
+ .vscode/
13
+ *.swp
14
+ *.swo
15
+ *~
16
+
17
+ # Environment files
18
+ .env
19
+ .env.*
20
+ !.env.example
21
+
22
+ # Credentials and secrets
23
+ credentials.json
24
+ *_secret*
25
+ *.pem
26
+ *.key
27
+
28
+ # OS
29
+ .DS_Store
30
+ Thumbs.db
31
+
32
+ # Beads viewer local config
33
+ .bv/
34
+
35
+ # bd runtime lock at the repo root (bd >= 1.2) — tool state, never source
36
+ .beads.gate.lock
37
+
38
+ # Human-decision report (regenerated by `ortus human`)
39
+ HUMAN-TODO.md
40
+
41
+ # Build artifacts and logs
42
+ *.log
43
+ logs/
44
+
45
+ # Cache directories
46
+ .cache/
47
+
48
+ # CodeGraph index — local, machine-specific, and large. Every fresh clone
49
+ # rebuilds it with `codegraph init`; `ortus check` reports it when missing.
50
+ .codegraph/
51
+
52
+ # Python / Node / Go / Rust common build outputs
53
+ __pycache__/
54
+ *.py[cod]
55
+ .venv/
56
+ node_modules/
57
+ dist/
58
+ build/
59
+ target/
60
+ # END ortus block=gitignore
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes to jevq are recorded here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and jevq uses
5
+ [semantic versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-09-26
10
+
11
+ ### Added
12
+
13
+ - The jq sidecar filter: read JSONL on stdin and keep each value whose System
14
+ One noul is at or above the threshold, emitting the original bytes.
15
+ - `--score`, which wraps every value as `{"score":<noul>,"value":<original>}`.
16
+ - `--pass`, a byte passthrough with no API call and no key.
17
+ - `-t/--threshold` and `$JEV_THRESHOLD`.
18
+ - `-f/--fields`, which sends only the named keys as state.
19
+ - `--model`, with the default pinned to `jev-1.13.0`, and `$JEV_MODEL`.
20
+ - `-v/--verbose` for end-of-run counts and the answering model; stderr stays
21
+ quiet on success otherwise.
22
+ - Retries with backoff on HTTP 429 and 5xx responses.
23
+ - `samples/` fixtures and offline sample pipe tests against a fake System One.
24
+ - GitHub Actions CI.
25
+ - `scripts/reinstall-cli.sh`.
26
+ - PyPI packaging with a tag-triggered release workflow.
27
+
28
+ [Unreleased]: https://github.com/who/jevq/compare/v0.1.0...HEAD
29
+ [0.1.0]: https://github.com/who/jevq/releases/tag/v0.1.0
jevq-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 who
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.
jevq-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.5
2
+ Name: jevq
3
+ Version: 0.1.0
4
+ Summary: jq-compatible System One noul filter
5
+ Project-URL: Homepage, https://github.com/who/jevq
6
+ Project-URL: Repository, https://github.com/who/jevq
7
+ Project-URL: Issues, https://github.com/who/jevq/issues
8
+ Author-email: who <githubwho@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,filter,jq,json,jsonl,llm,typesafe
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Text Processing :: Filters
20
+ Classifier: Topic :: Utilities
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: httpx>=0.27
23
+ Description-Content-Type: text/markdown
24
+
25
+ # jevq
26
+
27
+ [![CI](https://github.com/who/jevq/actions/workflows/ci.yml/badge.svg)](https://github.com/who/jevq/actions/workflows/ci.yml)
28
+
29
+ `jq | jevq | jq`. jq handles structure; jevq reads one JSON value per line from stdin, asks
30
+ Jev (a TypeSafe System One model) a yes/no question about each, and passes through the yeses.
31
+ QUESTION is a yes/no claim about the current value, not a search query.
32
+
33
+ ![jevq demo: filtering support tickets to open refund requests](https://raw.githubusercontent.com/who/jevq/main/docs/assets/jevq-demo.gif)
34
+
35
+ Video: [docs/assets/jevq-demo.mp4](https://github.com/who/jevq/blob/main/docs/assets/jevq-demo.mp4)
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ uv tool install jevq # from PyPI (or: pipx install jevq)
41
+ uv tool install --editable . # from a source checkout
42
+ export TYPESAFE_API_KEY=...
43
+ ```
44
+
45
+ `scripts/reinstall-cli.sh` forces a rebuild (`--dry-run`, `--no-editable`, `--skip-smoke`).
46
+
47
+ ## Options
48
+
49
+ `jevq [options] QUESTION` reads JSONL on stdin. QUESTION is required unless `--pass`.
50
+
51
+ | Flag | Default | Description |
52
+ | --- | --- | --- |
53
+ | `QUESTION` | none | Yes/no claim about each value |
54
+ | `-t N`, `--threshold N` | `0.5` (`$JEV_THRESHOLD`) | Keep values scoring at least N |
55
+ | `-f a,b`, `--fields a,b` | whole value | Send only these keys; output stays the full value |
56
+ | `--score` | off | Emit every value as `{"score":<noul>,"value":<original>}` |
57
+ | `--pass` | off | Emit every value unchanged; no API call, no key |
58
+ | `--model NAME` | `jev-1.13.0` (`$JEV_MODEL`) | Model to ask |
59
+ | `-v`, `--verbose` | off | Print counts and the answering model to stderr |
60
+ | `-h`, `--help` | | Show help and exit |
61
+
62
+ `--score` and `--pass` are mutually exclusive. A flag beats its env var.
63
+
64
+ ## Environment
65
+
66
+ | Variable | Default | Description |
67
+ | --- | --- | --- |
68
+ | `TYPESAFE_API_KEY` | none | API key; required except with `--pass` |
69
+ | `JEV_MODEL` | `jev-1.13.0` | Default model |
70
+ | `JEV_BASE_URL` | `https://api.typesafe.ai/v1/systemone` | API base URL |
71
+ | `JEV_THRESHOLD` | `0.5` | Default threshold |
72
+
73
+ ## Use cases
74
+
75
+ ```bash
76
+ jq -c '.[] | select(.status == "open")' samples/tickets.json |
77
+ jevq "the customer is asking for a refund" |
78
+ jq -c '{id, subject}'
79
+ ```
80
+
81
+ Open tickets asking for money back, printed as `{id, subject}`.
82
+
83
+ ```bash
84
+ jq -c 'select(.level == "error")' samples/app.ndjson |
85
+ jevq "this error is caused by a network timeout, not a bug in our code"
86
+ ```
87
+
88
+ Error log lines that look like a timeout rather than a code bug.
89
+
90
+ ```bash
91
+ gh api 'repos/itchyny/gojq/issues?state=open&per_page=100' |
92
+ jq -c '.[] | select(.pull_request | not) | {number, title, body}' |
93
+ jevq --score "reports a crash or wrong output, not a feature request" |
94
+ jq -rs 'sort_by(-.score) | .[:5][] | .value | "\(.number)\t\(.title)"'
95
+ ```
96
+
97
+ The five most bug-like open issues, as `number<TAB>title`.
98
+
99
+ More examples: [docs/examples.md](https://github.com/who/jevq/blob/main/docs/examples.md)
100
+
101
+ Full contract (output rules, exit codes, HTTP, retries): [docs/jevq.md](https://github.com/who/jevq/blob/main/docs/jevq.md)
102
+
103
+ ## Testing
104
+
105
+ ```bash
106
+ uv run pytest -q
107
+ bash tests/samples/run.sh
108
+ ```
109
+
110
+ `JEVQ_LIVE=1 TYPESAFE_API_KEY=... bash tests/samples/run.sh` runs the sample pipes live.
111
+
112
+ Not supported in v1: `--path`, a `jev()` builtin, concurrency, caching.
jevq-0.1.0/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # jevq
2
+
3
+ [![CI](https://github.com/who/jevq/actions/workflows/ci.yml/badge.svg)](https://github.com/who/jevq/actions/workflows/ci.yml)
4
+
5
+ `jq | jevq | jq`. jq handles structure; jevq reads one JSON value per line from stdin, asks
6
+ Jev (a TypeSafe System One model) a yes/no question about each, and passes through the yeses.
7
+ QUESTION is a yes/no claim about the current value, not a search query.
8
+
9
+ ![jevq demo: filtering support tickets to open refund requests](https://raw.githubusercontent.com/who/jevq/main/docs/assets/jevq-demo.gif)
10
+
11
+ Video: [docs/assets/jevq-demo.mp4](https://github.com/who/jevq/blob/main/docs/assets/jevq-demo.mp4)
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ uv tool install jevq # from PyPI (or: pipx install jevq)
17
+ uv tool install --editable . # from a source checkout
18
+ export TYPESAFE_API_KEY=...
19
+ ```
20
+
21
+ `scripts/reinstall-cli.sh` forces a rebuild (`--dry-run`, `--no-editable`, `--skip-smoke`).
22
+
23
+ ## Options
24
+
25
+ `jevq [options] QUESTION` reads JSONL on stdin. QUESTION is required unless `--pass`.
26
+
27
+ | Flag | Default | Description |
28
+ | --- | --- | --- |
29
+ | `QUESTION` | none | Yes/no claim about each value |
30
+ | `-t N`, `--threshold N` | `0.5` (`$JEV_THRESHOLD`) | Keep values scoring at least N |
31
+ | `-f a,b`, `--fields a,b` | whole value | Send only these keys; output stays the full value |
32
+ | `--score` | off | Emit every value as `{"score":<noul>,"value":<original>}` |
33
+ | `--pass` | off | Emit every value unchanged; no API call, no key |
34
+ | `--model NAME` | `jev-1.13.0` (`$JEV_MODEL`) | Model to ask |
35
+ | `-v`, `--verbose` | off | Print counts and the answering model to stderr |
36
+ | `-h`, `--help` | | Show help and exit |
37
+
38
+ `--score` and `--pass` are mutually exclusive. A flag beats its env var.
39
+
40
+ ## Environment
41
+
42
+ | Variable | Default | Description |
43
+ | --- | --- | --- |
44
+ | `TYPESAFE_API_KEY` | none | API key; required except with `--pass` |
45
+ | `JEV_MODEL` | `jev-1.13.0` | Default model |
46
+ | `JEV_BASE_URL` | `https://api.typesafe.ai/v1/systemone` | API base URL |
47
+ | `JEV_THRESHOLD` | `0.5` | Default threshold |
48
+
49
+ ## Use cases
50
+
51
+ ```bash
52
+ jq -c '.[] | select(.status == "open")' samples/tickets.json |
53
+ jevq "the customer is asking for a refund" |
54
+ jq -c '{id, subject}'
55
+ ```
56
+
57
+ Open tickets asking for money back, printed as `{id, subject}`.
58
+
59
+ ```bash
60
+ jq -c 'select(.level == "error")' samples/app.ndjson |
61
+ jevq "this error is caused by a network timeout, not a bug in our code"
62
+ ```
63
+
64
+ Error log lines that look like a timeout rather than a code bug.
65
+
66
+ ```bash
67
+ gh api 'repos/itchyny/gojq/issues?state=open&per_page=100' |
68
+ jq -c '.[] | select(.pull_request | not) | {number, title, body}' |
69
+ jevq --score "reports a crash or wrong output, not a feature request" |
70
+ jq -rs 'sort_by(-.score) | .[:5][] | .value | "\(.number)\t\(.title)"'
71
+ ```
72
+
73
+ The five most bug-like open issues, as `number<TAB>title`.
74
+
75
+ More examples: [docs/examples.md](https://github.com/who/jevq/blob/main/docs/examples.md)
76
+
77
+ Full contract (output rules, exit codes, HTTP, retries): [docs/jevq.md](https://github.com/who/jevq/blob/main/docs/jevq.md)
78
+
79
+ ## Testing
80
+
81
+ ```bash
82
+ uv run pytest -q
83
+ bash tests/samples/run.sh
84
+ ```
85
+
86
+ `JEVQ_LIVE=1 TYPESAFE_API_KEY=... bash tests/samples/run.sh` runs the sample pipes live.
87
+
88
+ Not supported in v1: `--path`, a `jev()` builtin, concurrency, caching.
@@ -0,0 +1,105 @@
1
+ # jevq examples
2
+
3
+ More pipes beyond the three in the [README](../README.md). They use the files
4
+ in `samples/`, so run them from the repo root. Output rules and exit codes
5
+ are in [jevq.md](jevq.md).
6
+
7
+ ## Dev tools in runtime dependencies
8
+
9
+ ```bash
10
+ jq -c '.dependencies | to_entries[]' samples/package.json |
11
+ jevq "this npm package is a test, lint or build tool, not a runtime library" |
12
+ jq -r .key
13
+ ```
14
+
15
+ Prints the dependencies that belong in `devDependencies`, one name per line.
16
+
17
+ ## Failed logins, cut at 0.7
18
+
19
+ ```bash
20
+ printf '%s\n' \
21
+ '{"level":"warn","msg":"failed login for alice from 10.0.0.5"}' \
22
+ '{"level":"info","msg":"user bob logged in"}' \
23
+ '{"level":"warn","msg":"invalid password for root from 203.0.113.9"}' \
24
+ '{"level":"info","msg":"cache warmed in 120ms"}' |
25
+ jevq --score "this log line records a failed login" |
26
+ jq -c 'select(.score >= 0.7) | .value'
27
+ ```
28
+
29
+ `--score` wraps each line with its score and jq applies the cutoff.
30
+
31
+ ## Send only some fields with `-f`
32
+
33
+ ```bash
34
+ jq -c '.[] | select(.status == "open")' samples/tickets.json |
35
+ jevq -f subject,body "the customer is asking for a refund"
36
+ ```
37
+
38
+ Only `subject` and `body` go to Jev; stdout still gets the full ticket.
39
+
40
+ ## Tune the threshold with `-t`
41
+
42
+ ```bash
43
+ jq -c '.[]' samples/tickets.json |
44
+ jevq -t 0.8 "the customer is asking for a refund"
45
+ ```
46
+
47
+ `-t 0.8` keeps confident matches only. To pick a value, look at the scores
48
+ first:
49
+
50
+ ```bash
51
+ jq -c '.[]' samples/tickets.json |
52
+ jevq --score "the customer is asking for a refund" |
53
+ jq -r .score |
54
+ sort -n
55
+ ```
56
+
57
+ ## Dry run with `--pass`
58
+
59
+ ```bash
60
+ jq -c '.[] | select(.status == "open")' samples/tickets.json |
61
+ jevq --pass |
62
+ wc -l
63
+ ```
64
+
65
+ Counts the rows a real run would ask about, with no API call and no key.
66
+ `--pass` is also an identity:
67
+
68
+ ```bash
69
+ f=$(mktemp) && jq -c '.[]' samples/tickets.json > "$f" &&
70
+ jevq --pass < "$f" |
71
+ cmp - "$f" &&
72
+ echo identical
73
+ ```
74
+
75
+ ## Rank GitHub issues offline
76
+
77
+ ```bash
78
+ jq -c '.[] | select(.pull_request | not) | {number, title, body}' samples/gh-issues.json |
79
+ jevq --score "reports a crash or wrong output, not a feature request" |
80
+ jq -rs 'sort_by(-.score) | .[:5][] | .value | "\(.number)\t\(.title)"'
81
+ ```
82
+
83
+ The README's GitHub pipe with `gh api` swapped for a saved copy.
84
+
85
+ ## Counts and model with `-v`
86
+
87
+ ```bash
88
+ jq -c '.[] | select(.status == "open")' samples/tickets.json |
89
+ jevq -v "the customer is asking for a refund" > refunds.jsonl
90
+ ```
91
+
92
+ At the end of the run stderr gets:
93
+
94
+ ```
95
+ jevq: read 14, emitted 5
96
+ jevq: model: jev-1.13.0
97
+ ```
98
+
99
+ ## Tips
100
+
101
+ - Phrase QUESTION so a high score means yes to what you want to keep.
102
+ - To invert, filter on the score: `jevq --score "..." | jq -c 'select(.score < 0.5) | .value'`.
103
+ - Prefilter with jq and slim with `-f` so each row stays small (about 32k tokens max).
104
+ - Scores are not calibrated probabilities; tune `-t` on real data.
105
+ - One API call per row, sequential; the account limit is about 1,200 requests per minute.
@@ -0,0 +1,175 @@
1
+ # jevq
2
+
3
+ jevq filters JSON values with a TypeSafe System One `noul` score. It reads
4
+ JSONL on stdin (the output of `jq -c`), makes one System One call per value,
5
+ and writes JSONL on stdout.
6
+
7
+ ## Usage
8
+
9
+ ```
10
+ jevq [options] QUESTION
11
+ ```
12
+
13
+ QUESTION is a yes/no claim about the current value, for example
14
+ `"the customer is asking for a refund"`. It is not a search query. It is
15
+ required unless `--pass` is given.
16
+
17
+ ## Flags
18
+
19
+ | Flag | Meaning |
20
+ | --- | --- |
21
+ | `QUESTION` | Yes/no claim about the current value. |
22
+ | `--score` | Emit every value as `{"score":<noul>,"value":<original>}`. No threshold is applied. |
23
+ | `--pass` | Emit every input value unchanged. No API call and no key required. Cannot be combined with `--score`. |
24
+ | `-t N`, `--threshold N` | Cutoff in [0, 1]. A value is kept when its score is at least N. Default 0.5 or `$JEV_THRESHOLD`. |
25
+ | `-f a,b`, `--fields a,b` | Send only these top-level keys to the model. Output is still the full original value. |
26
+ | `--model NAME` | Model to ask. Default `jev-1.13.0` (pinned) or `$JEV_MODEL`. |
27
+ | `-v`, `--verbose` | Print the end-of-run counts and the answering model to stderr. Combines with every mode. |
28
+ | `-h`, `--help` | Print help and exit. |
29
+
30
+ ## Environment
31
+
32
+ | Variable | Meaning |
33
+ | --- | --- |
34
+ | `TYPESAFE_API_KEY` | API key, sent as `Authorization: Bearer <key>`. Required except with `--pass`. |
35
+ | `JEV_MODEL` | Default model when `--model` is not given. Default `jev-1.13.0`. |
36
+ | `JEV_BASE_URL` | Endpoint URL. Default `https://api.typesafe.ai/v1/systemone`. |
37
+ | `JEV_THRESHOLD` | Default threshold when `-t` is not given. Default `0.5`. |
38
+
39
+ ## State sent to the model
40
+
41
+ - An object is sent as it is.
42
+ - With `--fields`, an object is cut down to the listed keys that it has. A
43
+ missing key is skipped, not an error.
44
+ - Anything that is not an object (a string, number, array, `true`, `false` or
45
+ `null`) is sent wrapped as `{"value": <value>}`. `--fields` does not apply
46
+ to it.
47
+
48
+ `--fields` only changes what the model sees. stdout always carries the input
49
+ line, so `jq -c '.dependencies | to_entries[]' package.json | jevq -f key ...`
50
+ asks about the package name alone and still emits `{"key":...,"value":...}`.
51
+
52
+ ## HTTP request
53
+
54
+ One `POST` to `$JEV_BASE_URL` (default
55
+ `https://api.typesafe.ai/v1/systemone`) per input value, with a 30 second
56
+ timeout and a `User-Agent: jevq/<version>` header. The body is:
57
+
58
+ ```json
59
+ {
60
+ "model": "jev-1.13.0",
61
+ "state": {"id": 7, "subject": "Refund please"},
62
+ "questions": {"q": {"type": "noul", "instructions": "the customer is asking for a refund"}}
63
+ }
64
+ ```
65
+
66
+ The score is read from `answers.q.noul` in the response and must be a finite
67
+ number. A response that is not JSON, lacks `answers.q.noul`, or holds a
68
+ non-number there is an API error. The top-level `model` field of the response,
69
+ when present, is recorded for the `-v` report.
70
+
71
+ Calls run one at a time, in input order. There is no caching.
72
+
73
+ ## Retries
74
+
75
+ Transport errors, HTTP 429 and HTTP 5xx are retried, up to 4 attempts in all.
76
+ The wait before each retry is the `Retry-After` header in seconds, capped at
77
+ 10 s, or when that header is missing or unusable, 0.5 s, 1 s, then 2 s. Any
78
+ other non-2xx status fails at once. An API failure is never treated as a "no".
79
+
80
+ ## Output
81
+
82
+ - Default mode: each value whose score is at least the threshold is written
83
+ exactly as it was read (whitespace around the line trimmed), one per line.
84
+ - `--score`: every value is written as the compact line
85
+ `{"score":<noul>,"value":<original>}`. This is the only way jevq wraps a
86
+ value.
87
+ - `--pass`: every value is written unchanged.
88
+
89
+ Blank input lines are skipped. Output is flushed after every line, so jevq
90
+ works in a streaming pipe, and a downstream reader that closes early (such as
91
+ `| head -1`) ends jevq quietly with status 0.
92
+
93
+ On success jevq writes nothing to stderr. With `-v`/`--verbose` it writes, at
94
+ the end of the run (also after an error):
95
+
96
+ ```
97
+ jevq: read N, emitted M
98
+ ```
99
+
100
+ After at least one successful API response it also writes the model or models
101
+ that answered, taken from the responses:
102
+
103
+ ```
104
+ jevq: model: jev-1.13.0
105
+ ```
106
+
107
+ ## Exit codes
108
+
109
+ | Code | Meaning |
110
+ | --- | --- |
111
+ | 0 | Success. |
112
+ | 1 | Runtime failure: an API error (after retries) or an input line that is not valid JSON. jevq stops at that line; values already emitted stay emitted. |
113
+ | 2 | Usage error: bad flags, missing QUESTION, an invalid threshold or `--fields` value, or `TYPESAFE_API_KEY` not set. Reported before stdin is read. |
114
+
115
+ Errors go to stderr prefixed `jevq:`, with or without `-v`, for example
116
+ `jevq: line 3: invalid JSON: ...` or `jevq: line 3: API error: HTTP 401: ...`.
117
+
118
+ ## Reinstalling the local tool
119
+
120
+ `uv tool install --editable .` can reuse a cached build or an existing tool
121
+ environment, so the global `jevq` may not match this checkout. After pulling
122
+ or changing code, run:
123
+
124
+ ```bash
125
+ scripts/reinstall-cli.sh
126
+ ```
127
+
128
+ It works from any directory. It force-reinstalls only the `jevq` uv tool from
129
+ the repo root (`uv tool install --editable --force --reinstall`), then prints
130
+ the version before and after, the source path and install mode, the HEAD short
131
+ sha (with ` (dirty)` when the tree has uncommitted or untracked files), and
132
+ where `jevq` resolves on PATH. It finishes with a smoke test that needs no API
133
+ key: `printf '{"a":1}\n' | jevq --pass` must reproduce its input byte for byte.
134
+
135
+ Flags:
136
+
137
+ - `--dry-run` prints the exact uv command and changes nothing.
138
+ - `--no-editable` installs a snapshot instead of an editable install.
139
+ - `--skip-smoke` skips the `--pass` smoke test.
140
+ - `-h`, `--help` prints usage.
141
+
142
+ The script honours `UV_TOOL_DIR` and `UV_TOOL_BIN_DIR`, so you can install
143
+ into a scratch location without touching your real global tools.
144
+
145
+ ## Sample pipes
146
+
147
+ `samples/` holds invented data for four realistic `jq | jevq | jq` pipes: stock
148
+ jq picks or reshapes values, jevq judges each one, and jq post-processes the
149
+ survivors. Run them from the repo root:
150
+
151
+ ```bash
152
+ jq -c '.[] | select(.status == "open")' samples/tickets.json | jevq "the customer is asking for a refund" | jq -c '{id, subject}'
153
+ jq -c 'select(.level == "error")' samples/app.ndjson | jevq "this error is caused by a network timeout, not a bug in our code"
154
+ jq -c '.dependencies | to_entries[]' samples/package.json | jevq "this npm package is a test, lint or build tool, not a runtime library" | jq -r '.key'
155
+ jq -c '.[] | select(.pull_request | not) | {number, title, body}' samples/gh-issues.json | jevq --score "reports a crash or wrong output, not a feature request" | jq -rs 'sort_by(-.score) | .[:5][] | .value | "\(.number)\t\(.title)"'
156
+ ```
157
+
158
+ `tests/samples/run.sh` runs these pipes end to end, plus checks for `--fields`,
159
+ `--threshold`, `--pass`, one request per value, and a missing key. It starts a
160
+ deterministic fake System One (`tests/samples/fake_systemone.py`, scored by
161
+ `tests/samples/rules.json`) on 127.0.0.1, so it needs no API key and makes no
162
+ network calls. It works from any directory:
163
+
164
+ ```bash
165
+ bash tests/samples/run.sh
166
+ ```
167
+
168
+ It prints `PASS`, `FAIL` or `SKIP` per case and a final
169
+ `samples: N passed, M failed, K skipped` line, and exits nonzero if any case
170
+ fails. To also run the four pipes against the real endpoint, with looser shape
171
+ checks, set:
172
+
173
+ ```bash
174
+ JEVQ_LIVE=1 TYPESAFE_API_KEY=... bash tests/samples/run.sh
175
+ ```
@@ -0,0 +1,29 @@
1
+ # Releasing jevq
2
+
3
+ The version comes from the git tag (hatch-vcs), so there is nothing to bump in
4
+ `pyproject.toml`.
5
+
6
+ ## Cut a release
7
+
8
+ 1. In `CHANGELOG.md`, move the `[Unreleased]` notes under
9
+ `## [X.Y.Z] - YYYY-MM-DD` and update the compare links at the bottom.
10
+ 2. Commit and push to `main`.
11
+ 3. Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z`.
12
+ 4. `.github/workflows/release.yml` builds the wheel and sdist, publishes them
13
+ to PyPI, and creates a GitHub release whose notes are that version's
14
+ CHANGELOG section.
15
+
16
+ A tag containing `-test` (for example `v0.2.0-test1`) builds and creates the
17
+ GitHub release but skips PyPI. A manual `workflow_dispatch` run only builds.
18
+
19
+ ## One-time setup
20
+
21
+ Publishing uses PyPI trusted publishing; there are no tokens or secrets.
22
+
23
+ 1. On pypi.org, add a pending publisher with these values:
24
+ - PyPI project name: `jevq`
25
+ - Owner: `who`
26
+ - Repository: `jevq`
27
+ - Workflow: `release.yml`
28
+ - Environment: `pypi`
29
+ 2. In the GitHub repository settings, create an environment named `pypi`.
@@ -0,0 +1,63 @@
1
+ [project]
2
+ name = "jevq"
3
+ dynamic = ["version"]
4
+ description = "jq-compatible System One noul filter"
5
+ readme = { file = "README.md", content-type = "text/markdown" }
6
+ license = { text = "MIT" }
7
+ authors = [{ name = "who", email = "githubwho@gmail.com" }]
8
+ keywords = ["jq", "json", "jsonl", "cli", "filter", "llm", "typesafe"]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Environment :: Console",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Topic :: Utilities",
18
+ "Topic :: Text Processing :: Filters",
19
+ ]
20
+ requires-python = ">=3.12"
21
+ dependencies = ["httpx>=0.27"]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/who/jevq"
25
+ Repository = "https://github.com/who/jevq"
26
+ Issues = "https://github.com/who/jevq/issues"
27
+
28
+ [project.scripts]
29
+ jevq = "jevq.cli:main"
30
+
31
+ [dependency-groups]
32
+ dev = ["pytest>=8"]
33
+
34
+ [build-system]
35
+ requires = ["hatchling", "hatch-vcs"]
36
+ build-backend = "hatchling.build"
37
+
38
+ [tool.hatch.version]
39
+ source = "vcs"
40
+
41
+ [tool.hatch.version.raw-options]
42
+ fallback_version = "0.0.0"
43
+
44
+ [tool.hatch.build.targets.sdist]
45
+ include = ["src/jevq", "README.md", "LICENSE", "CHANGELOG.md", "docs", "pyproject.toml"]
46
+ exclude = [
47
+ ".beads",
48
+ "prd",
49
+ ".claude",
50
+ ".grok",
51
+ ".codex",
52
+ ".cursor",
53
+ ".ortusrc",
54
+ "AGENTS.md",
55
+ "CLAUDE.md",
56
+ "docs/assets",
57
+ ]
58
+
59
+ [tool.hatch.build.targets.wheel]
60
+ packages = ["src/jevq"]
61
+
62
+ [tool.pytest.ini_options]
63
+ testpaths = ["tests"]
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("jevq")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,297 @@
1
+ """Command-line entry point for jevq."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import math
8
+ import os
9
+ import sys
10
+ from collections.abc import Callable, Iterator, Mapping
11
+ from typing import Any, BinaryIO, Protocol, TextIO
12
+
13
+ from jevq.client import DEFAULT_MODEL, DEFAULT_URL, JevqAPIError, SystemOneClient
14
+
15
+ DESCRIPTION = """\
16
+ Filter JSON values from stdin (JSONL from `jq -c`) with a System One noul.
17
+ One System One call per value. QUESTION is a yes/no claim about the current
18
+ object, not a search query."""
19
+
20
+ EPILOG = """\
21
+ environment:
22
+ TYPESAFE_API_KEY API key (required except --pass)
23
+ JEV_MODEL default model (default jev-1.13.0)
24
+ JEV_BASE_URL API base URL (default https://api.typesafe.ai/v1/systemone)
25
+ JEV_THRESHOLD default threshold (default 0.5)
26
+
27
+ examples:
28
+ jq -c '.[] | select(.status == "open")' tickets.json | jevq "the customer is asking for a refund" | jq -c '{id, subject}'
29
+ jq -c '.[]' tickets.json | jevq --score "the customer is angry" | jq -c 'select(.score >= 0.8) | .value.id'
30
+ jq -c '.[]' tickets.json | jevq --pass"""
31
+
32
+
33
+ def build_parser() -> argparse.ArgumentParser:
34
+ parser = argparse.ArgumentParser(
35
+ prog="jevq",
36
+ usage="jevq [options] QUESTION",
37
+ description=DESCRIPTION,
38
+ epilog=EPILOG,
39
+ formatter_class=argparse.RawDescriptionHelpFormatter,
40
+ )
41
+ parser.add_argument(
42
+ "question",
43
+ nargs="?",
44
+ metavar="QUESTION",
45
+ help="yes/no claim about the current object",
46
+ )
47
+ mode = parser.add_mutually_exclusive_group()
48
+ mode.add_argument(
49
+ "--score",
50
+ action="store_true",
51
+ help='emit every row as {"score": <noul>, "value": <original>}',
52
+ )
53
+ mode.add_argument(
54
+ "--pass",
55
+ dest="pass_",
56
+ action="store_true",
57
+ help="emit every input value unchanged. No API call. No key required",
58
+ )
59
+ parser.add_argument(
60
+ "-t",
61
+ "--threshold",
62
+ metavar="N",
63
+ default=None,
64
+ help="cutoff in [0,1]. Default 0.5 or $JEV_THRESHOLD",
65
+ )
66
+ parser.add_argument(
67
+ "-f",
68
+ "--fields",
69
+ metavar="a,b",
70
+ default=None,
71
+ help="slim state only. Output is still the full original value",
72
+ )
73
+ parser.add_argument(
74
+ "--model",
75
+ metavar="NAME",
76
+ default=None,
77
+ help="default jev-1.13.0 (pinned) or $JEV_MODEL",
78
+ )
79
+ parser.add_argument(
80
+ "-v",
81
+ "--verbose",
82
+ action="store_true",
83
+ help="print end-of-run counts (read/emitted) and the answering model to stderr",
84
+ )
85
+ return parser
86
+
87
+
88
+ class InputError(Exception):
89
+ """A non-blank input line that is not exactly one JSON value."""
90
+
91
+ def __init__(self, line_no: int, message: str) -> None:
92
+ super().__init__(f"line {line_no}: invalid JSON: {message}")
93
+ self.line_no = line_no
94
+ self.message = message
95
+
96
+
97
+ def iter_values(stream: BinaryIO) -> Iterator[tuple[int, bytes, Any]]:
98
+ """Yield (line_no, raw_bytes, value) per non-blank JSONL line, lazily."""
99
+ for line_no, line in enumerate(stream, start=1):
100
+ raw = line.strip()
101
+ if not raw:
102
+ continue
103
+ try:
104
+ value = json.loads(raw)
105
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
106
+ raise InputError(line_no, str(exc)) from None
107
+ yield line_no, raw, value
108
+
109
+
110
+ def emit(stdout: BinaryIO, raw: bytes) -> None:
111
+ stdout.write(raw + b"\n")
112
+ stdout.flush()
113
+
114
+
115
+ def format_score_line(score: float, raw: bytes) -> bytes:
116
+ """The only wrap: compact ``{"score":<noul>,"value":<raw>}`` plus newline."""
117
+ return b'{"score":' + json.dumps(float(score)).encode() + b',"value":' + raw + b"}\n"
118
+
119
+
120
+ def report(stderr: TextIO, read: int, emitted: int) -> None:
121
+ stderr.write(f"jevq: read {read}, emitted {emitted}\n")
122
+
123
+
124
+ def report_model(stderr: TextIO, client: object) -> None:
125
+ """Name the model(s) that answered, once any System One response succeeded."""
126
+ models = getattr(client, "answered_models", None)
127
+ if models is None or not getattr(client, "responses", 0):
128
+ return
129
+ stderr.write(f"jevq: model: {', '.join(models) if models else 'not reported'}\n")
130
+
131
+
132
+ class UsageError(Exception):
133
+ """Bad flags, environment or missing key; exit 2 before reading stdin."""
134
+
135
+
136
+ class NoulClient(Protocol):
137
+ def noul(self, state: Any, question: str) -> float: ...
138
+
139
+
140
+ ClientFactory = Callable[[str, str, str], NoulClient]
141
+
142
+
143
+ def resolve_threshold(arg: str | None, env: Mapping[str, str]) -> float:
144
+ raw = arg if arg is not None else (env.get("JEV_THRESHOLD") or None)
145
+ if raw is None:
146
+ return 0.5
147
+ try:
148
+ value = float(raw)
149
+ except ValueError:
150
+ value = math.nan
151
+ if not math.isfinite(value) or not 0.0 <= value <= 1.0:
152
+ raise UsageError("threshold must be a number in [0, 1]")
153
+ return value
154
+
155
+
156
+ def parse_fields(arg: str | None) -> list[str] | None:
157
+ if arg is None:
158
+ return None
159
+ names = [name.strip() for name in arg.split(",")]
160
+ names = [name for name in names if name]
161
+ if not names:
162
+ raise UsageError("--fields needs at least one key name")
163
+ return names
164
+
165
+
166
+ def build_state(value: Any, fields: list[str] | None) -> Any:
167
+ """The state sent to System One; emitted output is always the raw line."""
168
+ if not isinstance(value, dict):
169
+ return {"value": value}
170
+ if fields is None:
171
+ return value
172
+ return {k: value[k] for k in fields if k in value}
173
+
174
+
175
+ def _default_client_factory(api_key: str, model: str, url: str) -> NoulClient:
176
+ return SystemOneClient(api_key, model, url)
177
+
178
+
179
+ def run(
180
+ argv: list[str] | None,
181
+ stdin: BinaryIO,
182
+ stdout: BinaryIO,
183
+ stderr: TextIO,
184
+ env: Mapping[str, str],
185
+ *,
186
+ client_factory: ClientFactory | None = None,
187
+ ) -> int:
188
+ parser = build_parser()
189
+ try:
190
+ args = parser.parse_args(argv)
191
+ except SystemExit as exc:
192
+ code = exc.code
193
+ return code if isinstance(code, int) else (0 if code is None else 2)
194
+ if args.pass_:
195
+ return _run_pass(stdin, stdout, stderr, verbose=args.verbose)
196
+ if not (args.question or "").strip():
197
+ stderr.write("jevq: QUESTION is required unless --pass\n")
198
+ return 2
199
+ try:
200
+ threshold = resolve_threshold(args.threshold, env)
201
+ fields = parse_fields(args.fields)
202
+ api_key = (env.get("TYPESAFE_API_KEY") or "").strip()
203
+ if not api_key:
204
+ raise UsageError("TYPESAFE_API_KEY is not set")
205
+ except UsageError as exc:
206
+ stderr.write(f"jevq: {exc}\n")
207
+ return 2
208
+ model = args.model or env.get("JEV_MODEL") or DEFAULT_MODEL
209
+ url = env.get("JEV_BASE_URL") or DEFAULT_URL
210
+ client = (client_factory or _default_client_factory)(api_key, model, url)
211
+ try:
212
+ return _run_filter(
213
+ client,
214
+ args.question,
215
+ threshold,
216
+ fields,
217
+ stdin,
218
+ stdout,
219
+ stderr,
220
+ score_mode=args.score,
221
+ verbose=args.verbose,
222
+ )
223
+ finally:
224
+ close = getattr(client, "close", None)
225
+ if close is not None:
226
+ close()
227
+
228
+
229
+ def _run_filter(
230
+ client: NoulClient,
231
+ question: str,
232
+ threshold: float,
233
+ fields: list[str] | None,
234
+ stdin: BinaryIO,
235
+ stdout: BinaryIO,
236
+ stderr: TextIO,
237
+ *,
238
+ score_mode: bool = False,
239
+ verbose: bool = False,
240
+ ) -> int:
241
+ read = emitted = 0
242
+ try:
243
+ for line_no, raw, value in iter_values(stdin):
244
+ read += 1
245
+ try:
246
+ score = client.noul(build_state(value, fields), question)
247
+ except JevqAPIError as exc:
248
+ stderr.write(f"jevq: line {line_no}: API error: {exc}\n")
249
+ if verbose:
250
+ report(stderr, read, emitted)
251
+ report_model(stderr, client)
252
+ return 1
253
+ if score_mode:
254
+ stdout.write(format_score_line(score, raw))
255
+ stdout.flush()
256
+ emitted += 1
257
+ elif score >= threshold:
258
+ emit(stdout, raw)
259
+ emitted += 1
260
+ except InputError as exc:
261
+ stderr.write(f"jevq: {exc}\n")
262
+ if verbose:
263
+ report(stderr, read, emitted)
264
+ report_model(stderr, client)
265
+ return 1
266
+ if verbose:
267
+ report(stderr, read, emitted)
268
+ report_model(stderr, client)
269
+ return 0
270
+
271
+
272
+ def _run_pass(stdin: BinaryIO, stdout: BinaryIO, stderr: TextIO, *, verbose: bool = False) -> int:
273
+ read = emitted = 0
274
+ try:
275
+ for _line_no, raw, _value in iter_values(stdin):
276
+ read += 1
277
+ emit(stdout, raw)
278
+ emitted += 1
279
+ except InputError as exc:
280
+ stderr.write(f"jevq: {exc}\n")
281
+ if verbose:
282
+ report(stderr, read, emitted)
283
+ return 1
284
+ if verbose:
285
+ report(stderr, read, emitted)
286
+ return 0
287
+
288
+
289
+ def main() -> None:
290
+ try:
291
+ code = run(sys.argv[1:], sys.stdin.buffer, sys.stdout.buffer, sys.stderr, os.environ)
292
+ except BrokenPipeError:
293
+ # Downstream closed early (e.g. `| head -1`): silence the flush at exit.
294
+ devnull = os.open(os.devnull, os.O_WRONLY)
295
+ os.dup2(devnull, sys.stdout.fileno())
296
+ sys.exit(0)
297
+ sys.exit(code)
@@ -0,0 +1,137 @@
1
+ """httpx client for the TypeSafe System One ``noul`` endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import time
7
+ from collections.abc import Callable
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from jevq import __version__
13
+
14
+ DEFAULT_URL = "https://api.typesafe.ai/v1/systemone"
15
+ DEFAULT_MODEL = "jev-1.13.0"
16
+
17
+ _MAX_RETRY_AFTER = 10.0
18
+ _BASE_BACKOFF = 0.5
19
+
20
+
21
+ class JevqAPIError(Exception):
22
+ """System One call failed; never a "no" answer."""
23
+
24
+
25
+ def _retry_after(resp: httpx.Response | None) -> float | None:
26
+ if resp is None:
27
+ return None
28
+ raw = resp.headers.get("Retry-After")
29
+ if raw is None:
30
+ return None
31
+ try:
32
+ value = float(raw)
33
+ except ValueError:
34
+ return None
35
+ if not math.isfinite(value) or value < 0:
36
+ return None
37
+ return min(value, _MAX_RETRY_AFTER)
38
+
39
+
40
+ def _is_retryable(status: int) -> bool:
41
+ return status == 429 or 500 <= status <= 599
42
+
43
+
44
+ class SystemOneClient:
45
+ """Ask System One one ``noul`` question about one state per call."""
46
+
47
+ def __init__(
48
+ self,
49
+ api_key: str,
50
+ model: str = DEFAULT_MODEL,
51
+ url: str = DEFAULT_URL,
52
+ *,
53
+ timeout: float = 30.0,
54
+ max_retries: int = 3,
55
+ transport: httpx.BaseTransport | None = None,
56
+ sleep: Callable[[float], None] = time.sleep,
57
+ ) -> None:
58
+ self.model = model
59
+ self.url = url
60
+ self.max_retries = max_retries
61
+ self._sleep = sleep
62
+ self.responses = 0
63
+ self.answered_models: list[str] = []
64
+ self._http = httpx.Client(
65
+ timeout=timeout,
66
+ transport=transport,
67
+ headers={
68
+ "Authorization": f"Bearer {api_key}",
69
+ "User-Agent": f"jevq/{__version__}",
70
+ },
71
+ )
72
+
73
+ def build_body(self, state: Any, question: str) -> dict:
74
+ return {
75
+ "model": self.model,
76
+ "state": state,
77
+ "questions": {"q": {"type": "noul", "instructions": question}},
78
+ }
79
+
80
+ def noul(self, state: Any, question: str) -> float:
81
+ body = self.build_body(state, question)
82
+ attempts = self.max_retries + 1
83
+ last_error = ""
84
+ for attempt in range(attempts):
85
+ resp: httpx.Response | None = None
86
+ try:
87
+ resp = self._http.post(self.url, json=body)
88
+ except httpx.TransportError as exc:
89
+ last_error = f"transport error: {type(exc).__name__}"
90
+ else:
91
+ if resp.is_success:
92
+ score = _parse_noul(resp)
93
+ self._record_model(resp)
94
+ return score
95
+ if not _is_retryable(resp.status_code):
96
+ raise JevqAPIError(f"HTTP {resp.status_code}: {resp.text[:200]}")
97
+ last_error = f"HTTP {resp.status_code}"
98
+ if attempt < self.max_retries:
99
+ delay = _retry_after(resp)
100
+ if delay is None:
101
+ delay = _BASE_BACKOFF * 2**attempt
102
+ self._sleep(delay)
103
+ raise JevqAPIError(f"{last_error} after {attempts} attempts")
104
+
105
+ def _record_model(self, resp: httpx.Response) -> None:
106
+ """Note the answering model from the response's top-level ``model``."""
107
+ self.responses += 1
108
+ model = resp.json().get("model")
109
+ if isinstance(model, str) and model and model not in self.answered_models:
110
+ self.answered_models.append(model)
111
+
112
+ def close(self) -> None:
113
+ self._http.close()
114
+
115
+ def __enter__(self) -> SystemOneClient:
116
+ return self
117
+
118
+ def __exit__(self, *exc_info: object) -> None:
119
+ self.close()
120
+
121
+
122
+ def _parse_noul(resp: httpx.Response) -> float:
123
+ try:
124
+ value = resp.json()["answers"]["q"]["noul"]
125
+ except ValueError as exc:
126
+ raise JevqAPIError(f"invalid response: not JSON ({exc})") from None
127
+ except (KeyError, TypeError, IndexError):
128
+ raise JevqAPIError("invalid response: missing answers.q.noul") from None
129
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
130
+ raise JevqAPIError(f"invalid response: noul is {type(value).__name__}, not a number")
131
+ try:
132
+ score = float(value)
133
+ except OverflowError:
134
+ score = math.inf
135
+ if not math.isfinite(score):
136
+ raise JevqAPIError("invalid response: noul is not finite")
137
+ return score