@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 +30 -0
- package/README.md +193 -21
- package/dist/cli.js +0 -0
- package/dist/index.d.ts +50 -3
- package/dist/index.js +161 -5
- package/dist/jev.d.ts +6 -0
- package/dist/jev.js +84 -11
- package/package.json +2 -2
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
|
-
|
|
1
|
+
<h1 align="center">@byokit/decide</h1>
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
+
[](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)
|
|
26
|
-
own probability
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
11
|
-
|
|
12
|
-
signal
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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.
|
|
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
|
}
|