@byokit/decide 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ## 0.3.0 (2026-09-29)
6
+
7
+ - FIX: Jev answers no longer drop the backend's token counts: every answer now carries `usage`
8
+ (`input_tokens`/`output_tokens` when the backend sends them) and the raw backend response (`raw`), even
9
+ when abstaining on a malformed answer. Any backend can report the same pair through its `Raw`.
10
+ - `decide()` takes an optional pluggable answer cache (`get`/`set`, sync or async): it computes a stable
11
+ key (sha256 of the canonical request body, exported as `cacheKey`), reports `source: 'cache' | 'api'` on
12
+ every answer, and serves a cached answer with the same `usage`/`raw` it was stored with. Ships the
13
+ in-memory `MemoryCache` reference; no on-disk cache in the library.
14
+ - `jev()` retries 429s with backoff, honouring `Retry-After` when present (options `maxRetries` default 2,
15
+ `retryBaseMs`, `retryMaxMs`; the total wait stays under `maxRetries` x `retryMaxMs`), respects the caller's
16
+ timeout/`AbortSignal` including aborts mid-backoff, and never retries other statuses. Each retry is
17
+ API key (billed per use) like the first call.
18
+
19
+ ## 0.2.0
20
+
21
+ - `answerer({ name, leaves, ask })` makes a decision backend of any `(prompt, signal) => text`, such as the ChatGPT the person signed in to; any reply that is not the requested JSON is an abstain.
22
+ - The main entry is plain TypeScript with `fetch`, so it bundles for React Native and the web.
23
+
24
+ ## 0.1.1
25
+
26
+ - The Apache-2.0 LICENSE ships in the tarball.
27
+
28
+ ## 0.1.0
29
+
30
+ - Typed decisions with rules and Jev backends, abstaining below a confidence floor, and the `byokit-eval` runner.
package/README.md CHANGED
@@ -1,8 +1,69 @@
1
- # @byokit/decide
1
+ <h1 align="center">@byokit/decide</h1>
2
2
 
3
- Typed questions in, a typed answer with confidence out, and an abstain below a floor, so your app takes its safe
4
- default (ask the person) instead of guessing. Backends: your own `rules`, and [Jev](https://openrouter.ai/docs/guides/community/jev)
5
- over TypeSafe's API or OpenRouter.
3
+ <p align="center">
4
+ <a href="https://www.npmjs.com/package/@byokit/decide"><img alt="npm" src="https://img.shields.io/npm/v/@byokit/decide?style=flat&label=npm" /></a>
5
+ <a href="https://github.com/umeranjum17/byokit/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/umeranjum17/byokit/ci.yml?style=flat&branch=main" /></a>
6
+ <a href="LICENSE"><img alt="Apache 2.0" src="https://img.shields.io/badge/license-Apache--2.0-666?style=flat" /></a>
7
+ <img alt="Node | browsers | React Native" src="https://img.shields.io/badge/platform-Node%20%7C%20browsers%20%7C%20React%20Native-666?style=flat" />
8
+ </p>
9
+
10
+ <p align="center"><strong>Typed questions in, a typed answer with confidence out.</strong><br/>
11
+ Below a floor it abstains, so your app takes its safe default (ask the person) instead of guessing. Backends are your
12
+ own <code>rules</code>, any model you can send a prompt to (on a phone, the person's own ChatGPT), and
13
+ <a href="https://openrouter.ai/docs/guides/community/jev">Jev</a> (API-billed) over TypeSafe's API or OpenRouter. Labelled eval files
14
+ set the floors.</p>
15
+
16
+ ## Install
17
+
18
+ ```sh
19
+ npm install @byokit/decide
20
+ ```
21
+
22
+ [![npm](https://img.shields.io/npm/v/@byokit/decide?style=flat&label=)](https://www.npmjs.com/package/@byokit/decide) · [Latest release](https://github.com/umeranjum17/byokit/releases?q=decide-v) · [All releases](https://github.com/umeranjum17/byokit/releases)
23
+
24
+ ## Quickstart
25
+
26
+ ```sh
27
+ npm install @byokit/decide
28
+ ```
29
+
30
+ A rules-only backend: nothing leaves the device and no key is needed. The obvious case gets an answer; the rest abstains.
31
+
32
+ ```ts
33
+ import { decide, rules, type Question } from '@byokit/decide';
34
+
35
+ const questions: Record<string, Question> = {
36
+ intent: { kind: 'choice', options: { task: 'Something new to do', followup: 'About an earlier job', chat: 'Just talking' } },
37
+ };
38
+ const backends = [rules((s) => (/^(thanks|thank you)\b/i.test(s.text) ? 'chat' : undefined))];
39
+
40
+ for (const text of ['thanks, that worked!', 'can you check if the plumber replied?']) {
41
+ const { intent } = await decide({ text }, questions, { privacy: 'stays-here', backends });
42
+ console.log(text, '->', intent);
43
+ }
44
+ ```
45
+
46
+ ```text
47
+ thanks, that worked! -> {
48
+ answer: 'chat',
49
+ confidence: 1,
50
+ probabilities: { task: 0, followup: 0, chat: 1 },
51
+ abstained: false,
52
+ by: 'rules',
53
+ ms: 0
54
+ }
55
+ can you check if the plumber replied? -> {
56
+ answer: null,
57
+ confidence: 0,
58
+ abstained: true,
59
+ reason: 'no answer',
60
+ by: 'rules',
61
+ ms: 0
62
+ }
63
+ ```
64
+
65
+ Add a model after the rules for the cases they leave open. Jev is API-billed: it needs a TypeSafe or OpenRouter key
66
+ that your host holds.
6
67
 
7
68
  ```ts
8
69
  import { decide, jev, rules } from '@byokit/decide';
@@ -16,28 +77,97 @@ const { intent } = await decide({ text: 'can you check if the plumber replied?'
16
77
  }, { privacy: 'may-leave', backends });
17
78
 
18
79
  if (intent.abstained) askThePerson(); else route(intent.answer);
19
- // { answer: 'followup', confidence: 0.91, probabilities: {...}, abstained: false, by: 'jev', ms: 214 }
20
80
  ```
21
81
 
82
+ ## API at a glance
83
+
84
+ | Export | What it does |
85
+ |---|---|
86
+ | `decide(state, questions, { privacy, backends, timeoutMs?, cache? })` | Asks each backend in order for the questions still unanswered; returns an `Answer` per question |
87
+ | `rules(fn)` | Your own function as a backend: return the answer for an obvious case, `undefined` otherwise. Stays on the device |
88
+ | `answerer({ name, leaves, ask })` | Any `(prompt, signal) => text` model as a backend |
89
+ | `jev({ key, via?, fetch?, maxRetries?, retryBaseMs?, retryMaxMs? })` | Jev as a backend, over TypeSafe's API (default) or OpenRouter (`via: 'openrouter'`). API-billed; retries 429s with backoff |
90
+ | `MemoryCache`, `cacheKey(state, questions)` | In-memory reference cache for `decide({ cache })`, and the stable request key it uses |
91
+ | `resolve(question, raw)` | The floors on one raw answer, for an app that holds a recorded answer |
92
+ | `FLOOR` | The default floor, 0.6 |
93
+ | `Question`, `Answer`, `Raw`, `Usage`, `Backend`, `DecideCache`, `Options` | The types |
94
+ | `@byokit/decide/eval`: `evaluate`, `replay`, `parse`, `format`, `summary` | Run and print an eval report over any backends |
95
+ | `byokit-eval` (bin) | Replay or refresh an eval file from the command line |
96
+
97
+ ## Questions and floors
98
+
22
99
  - **Questions**: `choice` (options with a one-line description each), `yesno`, and `score` (an ordered rubric, lowest
23
100
  first; the answer is the level's index).
24
101
  - **The floors are code, not a prompt** (ported from firstmate's dispatch resolver): a 0.6 floor on the answer's
25
- confidence by default (`floor` per question); a choice option can declare its own floor (`floors`), checked against its
26
- own probability, and a pick under it falls to the most probable other option that clears its own; a tie abstains.
27
- An answer whose probabilities are missing an option, out of range or don't sum to 1 is an abstain, never an error.
28
- - **Backends** are tried in order for the questions still unanswered. A backend that fails or takes longer than
29
- `timeoutMs` (default 5 s) answers nothing. `privacy: 'stays-here'` skips every backend the state would leave the
30
- device for (Jev), so private text never goes to one.
31
- - **Keys**: `jev()` takes the key your host read from its own environment or config. The kit never reads an environment
32
- variable, and the key goes only into the one request header. Never ship a key inside an app: keep it on the home
33
- computer and let paired devices ask it.
102
+ confidence by default (`floor` per question). A choice option can declare its own floor (`floors`), checked against
103
+ its own probability; a pick under it falls to the most probable other option that clears its own. A tie abstains.
104
+ - An answer whose probabilities are missing an option, out of range or don't sum to 1 is an abstain, never an error.
105
+
106
+ ## Backends
107
+
108
+ - Backends are tried in order for the questions still unanswered. A backend that fails or takes longer than
109
+ `timeoutMs` (default 5 s) answers nothing.
110
+ - `privacy: 'stays-here'` skips every backend the state would leave the device for (Jev, or any answerer with
111
+ `leaves: true`), so private text never goes to one.
112
+ - **Any model**: `answerer({ name, leaves, ask })` makes a backend of any `(prompt, signal) => text`. On a phone, that
113
+ is the ChatGPT the person signed in to with [`@byokit/accounts`](../accounts), on their own plan:
114
+
115
+ ```ts
116
+ import { answerer } from '@byokit/decide';
117
+
118
+ const chatgpt = answerer({
119
+ name: 'chatgpt',
120
+ leaves: true,
121
+ ask: (p, signal) => accounts.respond(me, { instructions: 'Reply with JSON only.', input: p, signal }),
122
+ });
123
+ ```
124
+
125
+ It asks for each answer's probability as JSON; any other reply is an abstain.
126
+ - **Billing**: `rules` costs nothing. `answerer` with the person's ChatGPT uses their subscription. `jev()` is billed
127
+ to the TypeSafe or OpenRouter key you pass.
128
+
129
+ ## Usage, cache and retries
130
+
131
+ Every `Answer` carries what its backend reported: `usage` (`input_tokens`/`output_tokens` when sent) and the raw
132
+ backend response (`raw`), on answered and abstained answers alike, so cost accounting never loses a count. `source`
133
+ tells whether the answer was decided live (`'api'`) or served from cache (`'cache'`).
134
+
135
+ ```ts
136
+ import { decide, jev, MemoryCache } from '@byokit/decide';
137
+
138
+ const cache = new MemoryCache(); // reference implementation; bring your own get/set (sync or async) to persist
139
+ const backend = jev({ key: hostConfig.jevKey, maxRetries: 2, retryMaxMs: 2000 });
140
+
141
+ const first = await decide({ text }, questions, { privacy: 'may-leave', backends: [backend], cache });
142
+ console.log(first.intent.source, first.intent.usage); // 'api' { input_tokens: 42, output_tokens: 7 }
143
+ const second = await decide({ text }, questions, { privacy: 'may-leave', backends: [backend], cache });
144
+ console.log(second.intent.source); // 'cache': same usage/raw, no backend call
145
+ ```
146
+
147
+ - **Cache**: the key is the sha256 of the canonical `{ state, questions }` body (`cacheKey(state, questions)`), stable
148
+ across key order. The library ships no on-disk cache; a `get`/`set` pair over your own store is enough.
149
+ - **Retries**: only 429s retry, never other statuses. A 429 waits for `Retry-After` when present (seconds or HTTP
150
+ date), else an exponential backoff from `retryBaseMs`; each wait is capped at `retryMaxMs`, so the total stays
151
+ under `maxRetries` x `retryMaxMs`. Backoff respects the caller's `timeoutMs`/`AbortSignal`, including an abort
152
+ mid-wait, and each retry is API-billed like the first call.
153
+
154
+ ## Phones and browsers
155
+
156
+ The main entry is plain TypeScript with `fetch` (the eval CLI is its own entry), so it bundles for React Native and the
157
+ web; `test/react-native.test.ts` runs it where there is no Node.
158
+
159
+ ## Keys
160
+
161
+ `jev()` takes the key your host read from its own environment or config. The kit never reads an environment variable,
162
+ and the key goes only into the one request header. Never ship a key inside an app: keep it on the home computer and let
163
+ paired devices ask it.
34
164
 
35
165
  ## Evals
36
166
 
37
167
  Each decision gets a labelled file, `evals/<decision>.jsonl`: a header `{ decision, question, note }`, then one case per
38
168
  line, `{ state, expect, jev, ms }`. `expect` is the right answer, a list of right answers, or `null` when only an abstain
39
- is right; `jev` is a Jev-shaped answer, replayed offline so CI never calls a model. The included example is hand-made,
40
- not a live recording.
169
+ is right; `jev` is a Jev-shaped answer, replayed offline so CI never calls a model. The included example,
170
+ `evals/example-urgent.jsonl`, shows the format; it is hand-made, not a live recording.
41
171
 
42
172
  ```sh
43
173
  npx --package=@byokit/decide byokit-eval evals/intent.jsonl # replay stored answers
@@ -45,8 +175,50 @@ npx --package=@byokit/decide byokit-eval evals/intent.jsonl --floor 0.7 #
45
175
  TYPESAFE_API_KEY=… npx --package=@byokit/decide byokit-eval evals/intent.jsonl --live typesafe --record
46
176
  ```
47
177
 
48
- Agreement counts right answers and correctly expected abstentions; abstentions are also reported separately. Clear-but-wrong
49
- (answered, and wrong) is the number that must stay near 0; the command exits 1 when its rate is above
50
- `--max-clear-wrong` (default 0). Set floors from the eval, not by guessing. `evaluate()` in `@byokit/decide/eval` runs the
51
- same report over any backends, including your rules. `--record` keeps previous answers when a live refresh fails and
52
- marks a partially refreshed file as such. `evals/example-urgent.jsonl` shows the format.
178
+ Replaying the included example from this repo, at the default floor and then at 0.95:
179
+
180
+ ```text
181
+ $ npx byokit-eval packages/decide/evals/example-urgent.jsonl
182
+ packages/decide/evals/example-urgent.jsonl: urgent (jev, recorded): 5 cases
183
+ agree 4/5 clear-but-wrong 0 (0%) abstained 1 (20%) ms min/median/max 165/180/201
184
+ $ npx byokit-eval packages/decide/evals/example-urgent.jsonl --floor 0.95
185
+ packages/decide/evals/example-urgent.jsonl: urgent (jev, recorded): 5 cases
186
+ agree 2/5 clear-but-wrong 0 (0%) abstained 3 (60%) ms min/median/max 165/180/201
187
+ ```
188
+
189
+ - Agreement counts right answers and correctly expected abstentions; abstentions are also reported separately.
190
+ - Clear-but-wrong (answered, and wrong) is the number that must stay near 0; the command exits 1 when its rate is above
191
+ `--max-clear-wrong` (default 0).
192
+ - Set floors from the eval, not by guessing.
193
+ - Only `--live` reads a key (`TYPESAFE_API_KEY`, or `OPENROUTER_API_KEY` with `--live openrouter`), and each live call
194
+ is API-billed. `--record` keeps previous answers when a live refresh fails and marks a partially refreshed file as
195
+ such.
196
+
197
+ `evaluate()` in `@byokit/decide/eval` runs the same report over any backends, including your rules. Run
198
+ from a byokit checkout, it replays the included example:
199
+
200
+ ```ts
201
+ import { readFileSync } from 'node:fs';
202
+ import { decide, rules } from '@byokit/decide';
203
+ import { evaluate, parse, summary } from '@byokit/decide/eval';
204
+
205
+ const f = parse(readFileSync('packages/decide/evals/example-urgent.jsonl', 'utf8'));
206
+ const backends = [rules((s: string) => (/\b(now|today|before \d)/i.test(s) ? true : undefined))];
207
+ const report = await evaluate(f.cases, async (c) => (await decide(c.state, { urgent: f.question }, { privacy: 'stays-here', backends })).urgent);
208
+ console.log(summary(f.decision, 'rules', report));
209
+ ```
210
+
211
+ ```text
212
+ urgent (rules): 5 cases
213
+ agree 2/5 clear-but-wrong 0 (0%) abstained 3 (60%) ms min/median/max 0/0/1
214
+ ```
215
+
216
+ ## Links
217
+
218
+ - [byokit](../../README.md): the other packages
219
+ - [`examples/expo`](../../examples/expo): uses `@byokit/decide` in a React Native app
220
+ - [CHANGELOG.md](CHANGELOG.md)
221
+
222
+ ## License
223
+
224
+ Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](https://github.com/umeranjum17/byokit/blob/main/NOTICE).
package/dist/cli.js CHANGED
File without changes
package/dist/index.d.ts CHANGED
@@ -31,13 +31,27 @@ export type Answer = {
31
31
  reason?: string;
32
32
  by: string;
33
33
  ms: number;
34
+ /** Token counts the backend reported for this answer, when it did. Never dropped when present. */
35
+ usage?: Usage;
36
+ /** The backend's response behind this answer, when there was one (even a malformed one). */
37
+ raw?: unknown;
38
+ /** Whether this answer was decided live or served from the `cache` in `Options`. Always set by `decide()`. */
39
+ source?: 'api' | 'cache';
40
+ };
41
+ /** Token counts a backend reports for its answer. Each count is present only when the backend sent a valid one. */
42
+ export type Usage = {
43
+ input_tokens?: number;
44
+ output_tokens?: number;
34
45
  };
35
46
  /** A backend's answer before the floors: every option's probability, keyed as options (choice), 'true'/'false'
36
- * (yesno) or level indexes (score). A missing or malformed one is an abstain. */
47
+ * (yesno) or level indexes (score). A missing or malformed one is an abstain. `usage`/`raw` ride along through
48
+ * the floors onto the `Answer`, so any backend can report cost accounting and the raw response, not only Jev. */
37
49
  export type Raw = {
38
50
  probabilities: Record<string, number>;
39
51
  confidence?: number;
40
52
  pick?: string;
53
+ usage?: Usage;
54
+ raw?: unknown;
41
55
  };
42
56
  export type Backend = {
43
57
  name: string;
@@ -49,11 +63,44 @@ export type Options = {
49
63
  privacy: 'stays-here' | 'may-leave';
50
64
  backends: Backend[];
51
65
  timeoutMs?: number;
66
+ /** Optional pluggable answer cache. `decide()` computes a stable key (sha256 of the canonical
67
+ * `{ state, questions }` body, see `cacheKey`) and reports `source: 'cache' | 'api'` on every
68
+ * answer. A cached answer returns the same `usage`/`raw` it was stored with. No default on-disk
69
+ * cache ships with the kit; `MemoryCache` is the in-memory reference. Cache errors never fail a decision. */
70
+ cache?: DecideCache;
71
+ };
72
+ /** Pluggable answer cache for `decide()`: `get` returns the stored answers for a key, `set` stores them.
73
+ * Either may be sync or async. */
74
+ export type DecideCache = {
75
+ get(key: string): Record<string, Answer> | undefined | Promise<Record<string, Answer> | undefined>;
76
+ set(key: string, value: Record<string, Answer>): void | Promise<void>;
52
77
  };
78
+ /** In-memory reference cache: the shape a `DecideCache` takes. Copies answers on the way in and out. */
79
+ export declare class MemoryCache implements DecideCache {
80
+ private map;
81
+ get(key: string): Record<string, Answer> | undefined;
82
+ set(key: string, value: Record<string, Answer>): void;
83
+ get size(): number;
84
+ }
85
+ /** Stable cache key for a decision: the sha256 of the canonical `{ state, questions }` body, so the same
86
+ * question about the same state hits whatever the key order. Pure TypeScript: no Node imports, safe on phones. */
87
+ export declare function cacheKey(state: unknown, questions: Record<string, Question>): string;
53
88
  export declare const FLOOR = 0.6;
54
- /** Asks each backend in order for the questions still unanswered; a failed or slow backend answers nothing. */
89
+ /** Asks each backend in order for the questions still unanswered; a failed or slow backend answers nothing.
90
+ * With `opts.cache`, a stored answer is served as `source: 'cache'` without calling any backend; fresh answers
91
+ * are stored as `source: 'api'` with the same `usage`/`raw` they carry. */
55
92
  export declare function decide(state: unknown, questions: Record<string, Question>, opts: Options): Promise<Record<string, Answer>>;
56
- /** The floors on one raw answer. Exported for apps that hold a recorded answer. */
93
+ /** The floors on one raw answer. Exported for apps that hold a recorded answer.
94
+ * `usage`/`raw` on the raw ride through onto the answer, answered or abstained. */
57
95
  export declare function resolve(q: Question, raw: Raw | undefined): Omit<Answer, 'by' | 'ms'>;
58
96
  /** The app's own function as a backend: return the answer when the case is obvious, undefined otherwise. Stays here. */
59
97
  export declare function rules(fn: (state: any, name: string, q: Question) => string | boolean | number | undefined): Backend;
98
+ /** Any model as a backend: `ask` gets one prompt and returns the model's text (on a phone, the signed-in ChatGPT:
99
+ * `(p, signal) => accounts.respond(me, { instructions: '', input: p, signal })`). The model is asked for each option's
100
+ * probability as JSON; an answer that isn't that JSON is no answer, so the question abstains. `leaves`: whether the
101
+ * state goes off this device (true for any hosted model). */
102
+ export declare function answerer(o: {
103
+ name: string;
104
+ leaves: boolean;
105
+ ask: (prompt: string, signal: AbortSignal) => Promise<string>;
106
+ }): Backend;
package/dist/index.js CHANGED
@@ -1,9 +1,121 @@
1
1
  // Typed questions in, a typed answer with confidence out, abstaining below a floor. The floor, the per-option floors,
2
2
  // the runner-up and the tie are code, never a prompt: ported from firstmate's bin/fm-dispatch-resolve.sh.
3
3
  export { jev } from "./jev.js";
4
+ /** In-memory reference cache: the shape a `DecideCache` takes. Copies answers on the way in and out. */
5
+ export class MemoryCache {
6
+ map = new Map();
7
+ get(key) {
8
+ const hit = this.map.get(key);
9
+ if (!hit)
10
+ return undefined;
11
+ return Object.fromEntries(Object.entries(hit).map(([k, a]) => [k, { ...a }]));
12
+ }
13
+ set(key, value) {
14
+ this.map.set(key, Object.fromEntries(Object.entries(value).map(([k, a]) => [k, { ...a }])));
15
+ }
16
+ get size() {
17
+ return this.map.size;
18
+ }
19
+ }
20
+ /** Stable cache key for a decision: the sha256 of the canonical `{ state, questions }` body, so the same
21
+ * question about the same state hits whatever the key order. Pure TypeScript: no Node imports, safe on phones. */
22
+ export function cacheKey(state, questions) {
23
+ return sha256Hex(stableStringify({ state, questions }));
24
+ }
25
+ function stableStringify(v) {
26
+ if (v === null || typeof v !== 'object')
27
+ return JSON.stringify(v) ?? 'null';
28
+ if (Array.isArray(v))
29
+ return `[${v.map((e) => stableStringify(e)).join(',')}]`;
30
+ const o = v;
31
+ return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(o[k])}`).join(',')}}`;
32
+ }
33
+ function utf8Bytes(s) {
34
+ const out = [];
35
+ for (let i = 0; i < s.length; i++) {
36
+ let c = s.charCodeAt(i);
37
+ if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) {
38
+ const lo = s.charCodeAt(i + 1);
39
+ if (lo >= 0xdc00 && lo <= 0xdfff) {
40
+ c = 0x10000 + ((c - 0xd800) << 10) + (lo - 0xdc00);
41
+ i++;
42
+ }
43
+ }
44
+ if (c < 0x80)
45
+ out.push(c);
46
+ else if (c < 0x800)
47
+ out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
48
+ else if (c < 0x10000)
49
+ out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
50
+ else
51
+ out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
52
+ }
53
+ return out;
54
+ }
55
+ /** SHA-256 as hex, self-contained so the main entry stays free of Node imports on phones and browsers. */
56
+ function sha256Hex(s) {
57
+ const k = [
58
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
59
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
60
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
61
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
62
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
63
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
64
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
65
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
66
+ ];
67
+ let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a;
68
+ let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19;
69
+ const bytes = utf8Bytes(s);
70
+ const bitLen = bytes.length * 8;
71
+ bytes.push(0x80);
72
+ while (bytes.length % 64 !== 56)
73
+ bytes.push(0);
74
+ bytes.push(0, 0, 0, 0, (bitLen >>> 24) & 0xff, (bitLen >>> 16) & 0xff, (bitLen >>> 8) & 0xff, bitLen & 0xff);
75
+ const w = new Array(64);
76
+ const rotr = (x, n) => (x >>> n) | (x << (32 - n));
77
+ for (let off = 0; off < bytes.length; off += 64) {
78
+ for (let i = 0; i < 16; i++)
79
+ w[i] = ((bytes[off + i * 4] << 24) | (bytes[off + i * 4 + 1] << 16) | (bytes[off + i * 4 + 2] << 8) | bytes[off + i * 4 + 3]) | 0;
80
+ for (let i = 16; i < 64; i++) {
81
+ const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
82
+ const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
83
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
84
+ }
85
+ let [a, b, c, d, e, f, g, h] = [h0, h1, h2, h3, h4, h5, h6, h7];
86
+ for (let i = 0; i < 64; i++) {
87
+ const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
88
+ const ch = (e & f) ^ (~e & g);
89
+ const t1 = (h + s1 + ch + k[i] + w[i]) | 0;
90
+ const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
91
+ const maj = (a & b) ^ (a & c) ^ (b & c);
92
+ const t2 = (s0 + maj) | 0;
93
+ [h, g, f, e, d, c, b, a] = [g, f, e, (d + t1) | 0, c, b, a, (t1 + t2) | 0];
94
+ }
95
+ [h0, h1, h2, h3, h4, h5, h6, h7] = [(h0 + a) | 0, (h1 + b) | 0, (h2 + c) | 0, (h3 + d) | 0, (h4 + e) | 0, (h5 + f) | 0, (h6 + g) | 0, (h7 + h) | 0];
96
+ }
97
+ return [h0, h1, h2, h3, h4, h5, h6, h7].map((x) => (x >>> 0).toString(16).padStart(8, '0')).join('');
98
+ }
4
99
  export const FLOOR = 0.6;
5
- /** Asks each backend in order for the questions still unanswered; a failed or slow backend answers nothing. */
100
+ /** Asks each backend in order for the questions still unanswered; a failed or slow backend answers nothing.
101
+ * With `opts.cache`, a stored answer is served as `source: 'cache'` without calling any backend; fresh answers
102
+ * are stored as `source: 'api'` with the same `usage`/`raw` they carry. */
6
103
  export async function decide(state, questions, opts) {
104
+ const key = opts.cache ? cacheKey(state, questions) : undefined;
105
+ if (opts.cache && key) {
106
+ try {
107
+ const hit = await opts.cache.get(key);
108
+ if (hit && typeof hit === 'object' && Object.keys(questions).every((k) => Object.hasOwn(hit, k) && hit[k])) {
109
+ const out = Object.create(null);
110
+ for (const k of Object.keys(questions))
111
+ out[k] = { ...hit[k], source: 'cache' };
112
+ return out;
113
+ }
114
+ }
115
+ catch {
116
+ // A broken cache never fails a decision; fall through and ask live.
117
+ }
118
+ }
7
119
  const out = Object.create(null);
8
120
  const open = () => Object.fromEntries(Object.entries(questions).filter(([k]) => !Object.hasOwn(out, k) || out[k].abstained));
9
121
  for (const b of opts.backends) {
@@ -42,10 +154,22 @@ export async function decide(state, questions, opts) {
42
154
  for (const k of Object.keys(questions))
43
155
  if (!Object.hasOwn(out, k))
44
156
  out[k] = { answer: null, confidence: 0, abstained: true, reason: 'no backend answered', by: 'none', ms: 0 };
157
+ for (const k of Object.keys(out))
158
+ out[k].source = 'api';
159
+ if (opts.cache && key) {
160
+ try {
161
+ await opts.cache.set(key, Object.fromEntries(Object.entries(out).map(([k, a]) => [k, { ...a }])));
162
+ }
163
+ catch {
164
+ // Storing must not fail the answer just decided.
165
+ }
166
+ }
45
167
  return out;
46
168
  }
47
- /** The floors on one raw answer. Exported for apps that hold a recorded answer. */
169
+ /** The floors on one raw answer. Exported for apps that hold a recorded answer.
170
+ * `usage`/`raw` on the raw ride through onto the answer, answered or abstained. */
48
171
  export function resolve(q, raw) {
172
+ const carried = { ...(raw?.usage !== undefined && { usage: raw.usage }), ...(raw?.raw !== undefined && { raw: raw.raw }) };
49
173
  const keys = q.kind === 'choice' ? Object.keys(q.options) : q.kind === 'yesno' ? ['true', 'false'] : q.levels.map((_, i) => String(i));
50
174
  const p = raw?.probabilities;
51
175
  const ok = p && Object.keys(p).length === keys.length && keys.every((k) => Object.hasOwn(p, k) && typeof p[k] === 'number' && p[k] >= 0 && p[k] <= 1)
@@ -53,15 +177,15 @@ export function resolve(q, raw) {
53
177
  && (raw.confidence === undefined || (raw.confidence >= 0 && raw.confidence <= 1))
54
178
  && (raw.pick === undefined || keys.includes(raw.pick));
55
179
  if (!ok)
56
- return { answer: null, confidence: 0, abstained: true, reason: raw ? 'malformed answer' : 'no answer' };
180
+ return { answer: null, confidence: 0, abstained: true, reason: raw ? 'malformed answer' : 'no answer', ...carried };
57
181
  const ranked = [...keys].sort((a, b) => p[b] - p[a]);
58
182
  const picked = raw.pick ?? ranked[0];
59
183
  const confidence = raw.confidence ?? p[picked];
60
184
  const floor = q.floor ?? FLOOR;
61
185
  const own = (k) => (q.kind === 'choice' && q.floors && Object.hasOwn(q.floors, k) ? q.floors[k] : undefined) ?? floor;
62
186
  const typed = (k) => (q.kind === 'choice' ? k : q.kind === 'yesno' ? k === 'true' : Number(k));
63
- const done = (k, reason) => ({ answer: typed(k), confidence: k === picked ? confidence : p[k], probabilities: p, abstained: false, ...(reason && { reason }) });
64
- const abstain = (reason) => ({ answer: null, confidence, probabilities: p, abstained: true, reason });
187
+ const done = (k, reason) => ({ answer: typed(k), confidence: k === picked ? confidence : p[k], probabilities: p, abstained: false, ...(reason && { reason }), ...carried });
188
+ const abstain = (reason) => ({ answer: null, confidence, probabilities: p, abstained: true, reason, ...carried });
65
189
  if (p[ranked[0]] === p[ranked[1]])
66
190
  return abstain('tie');
67
191
  // Without a declared floor on the pick, the one floor applies to the answer's confidence, exactly as firstmate's.
@@ -95,3 +219,35 @@ export function rules(fn) {
95
219
  },
96
220
  };
97
221
  }
222
+ /** Any model as a backend: `ask` gets one prompt and returns the model's text (on a phone, the signed-in ChatGPT:
223
+ * `(p, signal) => accounts.respond(me, { instructions: '', input: p, signal })`). The model is asked for each option's
224
+ * probability as JSON; an answer that isn't that JSON is no answer, so the question abstains. `leaves`: whether the
225
+ * state goes off this device (true for any hosted model). */
226
+ export function answerer(o) {
227
+ return {
228
+ name: o.name,
229
+ leaves: o.leaves,
230
+ async ask(state, questions, signal) {
231
+ const described = Object.fromEntries(Object.entries(questions).map(([k, q]) => [k,
232
+ q.kind === 'choice' ? { pick_one_of: q.options, instructions: q.instructions }
233
+ : q.kind === 'yesno' ? { yes_or_no: q.question, yes: q.yes, no: q.no, answer_keys: ['true', 'false'] }
234
+ : { rate_on: Object.fromEntries(q.levels.map((l, i) => [String(i), l])), instructions: q.instructions }]));
235
+ const prompt = 'Answer each question about the state below. For each question give every answer key a probability ' +
236
+ 'between 0 and 1, summing to 1. Reply with JSON only, shaped {"<question>": {"<answer key>": <probability>}}.\n\n' +
237
+ `State: ${JSON.stringify(state)}\n\nQuestions: ${JSON.stringify(described)}`;
238
+ const text = await o.ask(prompt, signal);
239
+ let parsed;
240
+ try {
241
+ parsed = JSON.parse(text.trim());
242
+ }
243
+ catch {
244
+ return {};
245
+ }
246
+ const out = Object.create(null);
247
+ for (const k of Object.keys(questions))
248
+ if (parsed?.[k] && typeof parsed[k] === 'object')
249
+ out[k] = { probabilities: parsed[k] };
250
+ return out;
251
+ },
252
+ };
253
+ }
package/dist/jev.d.ts CHANGED
@@ -3,6 +3,12 @@ export declare function jev(opts: {
3
3
  key: string;
4
4
  via?: 'typesafe' | 'openrouter';
5
5
  fetch?: typeof fetch;
6
+ /** 429 retries after the first attempt (default 2). Only 429 retries, never other 4xx. */
7
+ maxRetries?: number;
8
+ /** Wait when a 429 carries no usable Retry-After (default 1000 ms); attempt n waits baseMs * 2^n. */
9
+ retryBaseMs?: number;
10
+ /** Each backoff wait is capped here (default 2000 ms), so the total wait stays under maxRetries * retryMaxMs. */
11
+ retryMaxMs?: number;
6
12
  }): Backend;
7
13
  /** Jev's answer as a Raw; anything off-shape is undefined, which the floors treat as an abstain. */
8
14
  export declare function raw(q: Question, a: any): Raw | undefined;
package/dist/jev.js CHANGED
@@ -1,25 +1,98 @@
1
1
  const BASE = { typesafe: 'https://api.typesafe.ai', openrouter: 'https://openrouter.ai/api' };
2
2
  export function jev(opts) {
3
- const { key, via = 'typesafe', fetch: f = globalThis.fetch } = opts;
3
+ const { key, via = 'typesafe', fetch: f = globalThis.fetch, maxRetries = 2, retryBaseMs = 1000, retryMaxMs = 2000 } = opts;
4
4
  if (!key)
5
5
  throw new Error('jev needs a key');
6
+ if (!Number.isSafeInteger(maxRetries) || maxRetries < 0)
7
+ throw new Error('jev maxRetries must be a safe non-negative integer');
8
+ for (const [name, ms] of [['retryBaseMs', retryBaseMs], ['retryMaxMs', retryMaxMs]]) {
9
+ if (!Number.isFinite(ms) || ms < 0)
10
+ throw new Error(`jev ${name} must be a finite number >= 0`);
11
+ }
6
12
  return {
7
13
  name: 'jev',
8
14
  leaves: true,
9
15
  async ask(state, questions, signal) {
10
- const res = await f(`${BASE[via]}/v1/systemone`, {
11
- method: 'POST',
12
- signal,
13
- headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
14
- body: JSON.stringify({ model: 'jev-latest', state, questions: Object.fromEntries(Object.entries(questions).map(([k, q]) => [k, wire(q)])) }),
15
- });
16
- if (!res.ok)
17
- throw new Error(`http ${res.status}`);
18
- const answers = (await res.json())?.answers ?? {};
19
- return Object.fromEntries(Object.entries(questions).map(([k, q]) => [k, raw(q, Object.hasOwn(answers, k) ? answers[k] : undefined)]));
16
+ const body = JSON.stringify({ model: 'jev-latest', state, questions: Object.fromEntries(Object.entries(questions).map(([k, q]) => [k, wire(q)])) });
17
+ for (let attempt = 0;; attempt++) {
18
+ if (signal.aborted)
19
+ throw abortError(signal);
20
+ const res = await f(`${BASE[via]}/v1/systemone`, {
21
+ method: 'POST',
22
+ signal,
23
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
24
+ body,
25
+ });
26
+ if (res.status === 429 && attempt < maxRetries) {
27
+ const wait = Math.min(Math.max(0, parseRetryAfter(res.headers.get('retry-after')) ?? retryBaseMs * 2 ** attempt), retryMaxMs);
28
+ await pause(wait, signal);
29
+ continue;
30
+ }
31
+ if (!res.ok)
32
+ throw new Error(`http ${res.status}`);
33
+ const json = await res.json();
34
+ const answers = json !== null && typeof json === 'object' ? json.answers : undefined;
35
+ const usage = parseUsage(json !== null && typeof json === 'object' ? json.usage : undefined);
36
+ const map = answers !== null && typeof answers === 'object' ? answers : {};
37
+ return Object.fromEntries(Object.entries(questions).map(([k, q]) => {
38
+ if (!Object.hasOwn(map, k))
39
+ return [k, undefined];
40
+ const r = raw(q, map[k]);
41
+ // A per-question response that is off-shape still carries usage/raw: the floors abstain on it.
42
+ if (r)
43
+ return [k, { ...r, ...(usage && { usage }), raw: json }];
44
+ return [k, { probabilities: {}, ...(usage && { usage }), raw: json }];
45
+ }));
46
+ }
20
47
  },
21
48
  };
22
49
  }
50
+ /** Seconds ("120", "1.5") or an HTTP date; undefined when absent or unparsable. */
51
+ function parseRetryAfter(header) {
52
+ const h = header?.trim();
53
+ if (!h)
54
+ return undefined;
55
+ if (/^\d+(?:\.\d+)?$/.test(h))
56
+ return Number(h) * 1000;
57
+ const date = Date.parse(h);
58
+ if (!Number.isNaN(date))
59
+ return date - Date.now();
60
+ return undefined;
61
+ }
62
+ /** The counts the backend reported, when it sent valid ones; anything else is absent, never an error. */
63
+ function parseUsage(u) {
64
+ if (u === null || typeof u !== 'object')
65
+ return undefined;
66
+ const out = {};
67
+ for (const k of ['input_tokens', 'output_tokens']) {
68
+ const v = u[k];
69
+ if (v === undefined)
70
+ continue;
71
+ if (Number.isSafeInteger(v) && v >= 0)
72
+ out[k] = v;
73
+ }
74
+ return out.input_tokens !== undefined || out.output_tokens !== undefined ? out : undefined;
75
+ }
76
+ /** Sleep that settles promptly when the caller's signal fires, so a decide() timeout is never outwaited. */
77
+ function pause(ms, signal) {
78
+ if (!(ms > 0))
79
+ return Promise.resolve();
80
+ return new Promise((resolve, reject) => {
81
+ const timer = setTimeout(() => {
82
+ signal.removeEventListener('abort', onAbort);
83
+ resolve();
84
+ }, ms);
85
+ const onAbort = () => {
86
+ clearTimeout(timer);
87
+ reject(abortError(signal));
88
+ };
89
+ signal.addEventListener('abort', onAbort, { once: true });
90
+ });
91
+ }
92
+ /** The caller's abort reason, or a plain error when it carries none. */
93
+ function abortError(signal) {
94
+ return signal.reason instanceof Error ? signal.reason : new Error(typeof signal.reason === 'string' && signal.reason ? signal.reason : 'aborted');
95
+ }
23
96
  function wire(q) {
24
97
  if (q.kind === 'choice')
25
98
  return { type: 'choice', instructions: q.instructions ?? 'Which option fits the state?', criteria: q.options };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/decide",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Typed questions in, a typed answer with confidence out, abstaining below a floor. Rules and Jev backends, and an eval runner.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -11,7 +11,7 @@
11
11
  "./eval": { "types": "./dist/eval.d.ts", "default": "./dist/eval.js" }
12
12
  },
13
13
  "bin": { "byokit-eval": "dist/cli.js" },
14
- "files": ["dist", "evals"],
14
+ "files": ["dist", "evals", "CHANGELOG.md"],
15
15
  "scripts": { "prepack": "tsc -b" },
16
16
  "publishConfig": { "access": "public" }
17
17
  }