From dbe46f356cb4bd80d094c9ff1db50bdb362b864a Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 18 Sep 2026 08:42:39 +0000 Subject: [PATCH 1/4] feat: add ask.switch --- AGENTS.md | 3 +- README.md | 23 ++++++++++++-- skills/advocaat/SKILL.md | 1 + src/ask.ts | 67 ++++++++++++++++++++++++++++++++-------- src/index.ts | 3 +- test/ask.test.ts | 60 ++++++++++++++++++++++++++++++++++- 6 files changed, 139 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 75da48a..a763304 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,5 +13,6 @@ - `src/api.ts`: minimal fetch client for the TypeSafe System One API (`typesafe({ apiKey })` → `systemOne`, `models`), plus `noul`/`choice`/`score` builders and typed answers. Reads `TYPESAFE_API_KEY`, `TYPESAFE_BASE_URL`, `TYPESAFE_DEFAULT_MODEL` from `globalThis.process?.env` when options are omitted. With only `AI_GATEWAY_API_KEY` or `VERCEL_OIDC_TOKEN` set (or `provider: "vercel"`), it speaks the Vercel AI Gateway evaluation protocol instead and maps answers back to the System One shape (`confidence` computed from the top probability, `legend` built from criteria, `models()` throws). Validates API limits (choice 2–255 options, score 2–10 levels). No retries or logging. - `src/ask.ts`: `ask(state, questions, options?)` from `IDEA.md`. Bare strings, `ask.choice`/`ask.score`/`ask.chance` tags (also callable as `ask.choice(instructions, criteria, options?)`, where instructions may be a JSON object or array), and plain client question objects go out as one request; answers come back under the same keys with `chance` (noul) and `ratio` (score scaled to 0–1) added. Every tag returns an `Askable`: the plain question object plus a hidden (non-enumerable) `then` that sends it alone under the key `input` and resolves to its answer, so anything that unwraps promises sends a request. Interpolated objects and arrays are kept in a `WeakMap` off the wire; when sent, the distinct objects of all tags in the request go into `input` (one directly, several as an array) and each slot is rendered as its path there. Inside `ask` they are added to an object state (which must not have `input`; empty or `null` becomes `{}`), and a text or array state goes first in that `input` array. The key stays `input` on purpose: on the real API, question-derived keys primed answers toward yes, and a named key for the text did worse than text-first. Options for a standalone send go in the criteria call. - `askIf` (also `ask.if`) is a tag with no criteria call for one yes/no question resolved to a boolean (`askIf(options)` binds options; `threshold` defaults to 0.5, strict `>`); inside `ask` it goes out as a noul question and resolves to a boolean under its key. -- `src/index.ts` exports `ask`, `askIf`, and the tags; the client's `noul`/`choice`/`score` builders stay internal. +- `askSwitch` (also `ask.switch`) takes the same forms as `ask.choice`, goes out as a choice question, and resolves to the selected label alone. Named exports because `if` and `switch` are reserved words. +- `src/index.ts` exports `ask`, `askIf`, `askSwitch`, and the tags; the client's `noul`/`choice`/`score` builders stay internal. - `playground/`: private workspace package with a GitHub issue triage CLI (`ask` over `octokit`). `pnpm-workspace.yaml` overrides `advocaat` to `link:.`, and `package.json` `exports` points at `src/` (swapped to `dist/` by `publishConfig` on publish). Node runs the source directly, so `src/` must stay erasable-syntax only (enforced by tsconfig). diff --git a/README.md b/README.md index 6f730e8..86d9c8a 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Answers use the same keys as your questions. Mix any of these forms in one `ask` | Boolean | `ask.if` | Answer directly | | Probability | String or `ask.chance` | `.chance` | | Category | `ask.choice` | `.choice` | +| Label | `ask.switch` | Answer directly | | Rating | `ask.score` | `.ratio` | The examples below reuse `ask` and `issue` from the quick start. @@ -103,6 +104,21 @@ console.log(kind.choice); // "bug" | "other" Returns `{ type: "choice", choice, confidence, probabilities }`. `choice` is the selected label, typed as `"bug" | "other"` here. `probabilities` contains a probability for each label. `confidence` (0...1) is high when one label stands out and low when they are close. +Use `ask.switch` with the same options when you only need the label: + +```ts +const { kind } = await ask(issue, { + kind: ask.switch`What kind of issue is this?`({ bug: "Something is broken", other: null }), +}); + +switch (kind) { + case "bug": + return label(issue, "bug"); + case "other": + return triage(issue); +} +``` + ### Scores Use `ask.score` with 2–10 levels, ordered from lowest to highest: @@ -127,6 +143,7 @@ For a single question, interpolate the data into a tag and await it. You get the ```ts const kind = await ask.choice`What kind of issue is ${issue}?`({ bug: null, other: null }); +const label = await ask.switch`What kind of issue is ${issue}?`({ bug: null, other: null }); const severity = await ask.score`How severe is ${issue}?`(["Cosmetic", "Blocks production"]); const { chance } = await ask.chance`Does ${issue} need immediate attention?`(); const security = await ask.if`Does ${issue} describe a security vulnerability?`; @@ -238,6 +255,7 @@ For standalone requests, pass client options after the criteria. `ask.chance` ac const options = { model: "jev-latest" }; await ask.choice`What kind of issue is ${issue}?`({ bug: null, other: null }, options); +await ask.switch`What kind of issue is ${issue}?`({ bug: null, other: null }, options); await ask.score`How severe is ${issue}?`(["Cosmetic", "Blocks production"], options); await ask.chance`Does ${issue} need immediate attention?`(undefined, options); ``` @@ -249,7 +267,7 @@ const strictIf = ask.if({ threshold: 0.8 }); const security = await strictIf`Does ${issue} describe a security vulnerability?`; ``` -Inside `ask`, set client options on `ask` itself; tags use the batch's settings. A threshold set with `ask.if({ threshold })` still applies to that question. `ask.if` is also exported as `askIf`. +Inside `ask`, set client options on `ask` itself; tags use the batch's settings. A threshold set with `ask.if({ threshold })` still applies to that question. `ask.if` and `ask.switch` are also exported as `askIf` and `askSwitch`. Tags are promise-like: awaiting them, passing them to `Promise.all`, or returning them from an async function sends a request. Awaiting the same tag again sends another request; it does not reuse an earlier answer. @@ -258,6 +276,7 @@ Tags are promise-like: awaiting them, passing them to `Promise.all`, or returnin When you build questions without template strings, use: - `ask.choice(instructions, criteria, options?)` +- `ask.switch(instructions, criteria, options?)` - `ask.score(instructions, levels, options?)` - `ask.chance(instructions, criteria?, options?)` @@ -291,7 +310,7 @@ const { urgent } = await ask(issue, { console.log(urgent.chance); // 0–1 ``` -Optional `instructions` accept text, a JSON object or array, or `null`. `criteria` follows the corresponding helper's shape and is required for choices and scores. Answers have the same shape as with the helpers, including `{ type: "chance", chance }` for `"noul"` questions. +Optional `instructions` accept text, a JSON object or array, or `null`. `criteria` follows the corresponding helper's shape and is required for choices and scores. Answers have the same shape as with the helpers, including `{ type: "chance", chance }` for `"noul"` questions. The tag shapes work too: `{ type: "if", instructions, threshold }` resolves to a boolean and `{ type: "switch", instructions, criteria }` to the selected label. ## Vercel AI Gateway diff --git a/skills/advocaat/SKILL.md b/skills/advocaat/SKILL.md index 70203fc..bf73ac0 100644 --- a/skills/advocaat/SKILL.md +++ b/skills/advocaat/SKILL.md @@ -121,6 +121,7 @@ Choose by what the answer means, then read the relevant primitive page: | One of a defined set | ``ask.choice`…`({ a: "…", b: null })`` ([Choice](https://docs.typesafe.ai/primitives/choice.md)) | `{ choice, confidence, probabilities }` | Picks one option (2–255); `probabilities` compares competing options | | Whether a condition holds | `"Is it …?"`, ``ask.chance`…`({ true: "…", false: "…" })`` ([Noul](https://docs.typesafe.ai/primitives/noul.md)) | `{ chance }` | Probability of yes; no separate confidence; use one per label when several may apply | | A yes/no you act on directly | `` ask.if`…` `` | `boolean` | `chance` above `threshold` (default `0.5`); pass `ask.if({ threshold })` to bind another | +| A label you branch on directly | ``ask.switch`…`({ a: "…", b: null })`` | `"a" \| "b"` | Same options as `ask.choice`; resolves to the selected label alone, without `confidence` or `probabilities` | | Degree along a described dimension | ``ask.score`…`(["low …", "mid …", "high …"])`` ([Score](https://docs.typesafe.ai/primitives/score.md)) | `{ score, ratio, confidence, legend, probabilities }` | Probability-weighted position on 2–10 ordered levels; `ratio` scales it to 0–1; use comparable per-item Scores for graded ranking | Every tag also takes plain arguments when the question is built elsewhere, for diff --git a/src/ask.ts b/src/ask.ts index 120f688..fcfd56b 100644 --- a/src/ask.ts +++ b/src/ask.ts @@ -20,7 +20,7 @@ import { } from "./api.ts"; /** A bare string is a yes/no question. */ -export type AskQuestion = string | Question | IfQuestion; +export type AskQuestion = string | Question | IfQuestion | SwitchQuestion; export type AskQuestions = { [name: string]: AskQuestion }; @@ -32,20 +32,24 @@ export interface ChanceAnswer { export type Answer = Q extends IfQuestion ? boolean - : Q extends string | NoulQuestion - ? ChanceAnswer - : Q extends ScoreQuestion - ? ScoreAnswer & { /** Score scaled to 0–1. */ readonly ratio: number } - : Q extends ChoiceQuestion - ? ChoiceAnswer - : never; + : Q extends SwitchQuestion + ? keyof C & string + : Q extends string | NoulQuestion + ? ChanceAnswer + : Q extends ScoreQuestion + ? ScoreAnswer & { /** Score scaled to 0–1. */ readonly ratio: number } + : Q extends ChoiceQuestion + ? ChoiceAnswer + : never; export type Answers = { readonly [K in keyof Q]: Answer }; export type AskOptions = TypeSafeOptions & RequestOptions; +type Tagged = Question | IfQuestion | SwitchQuestion; + /** A question from a tag: a key in `ask`, or awaited on its own to send it with its interpolated state. */ -export type Askable = Q & PromiseLike>; +export type Askable = Q & PromiseLike>; /** A yes/no question from `ask.if` that resolves to a boolean. */ export interface IfQuestion { @@ -54,6 +58,13 @@ export interface IfQuestion { readonly threshold: number; } +/** A choice from `ask.switch` that resolves to the selected label. */ +export interface SwitchQuestion { + readonly type: "switch"; + readonly instructions: Entry; + readonly criteria: T; +} + // Interpolated objects of tagged questions, kept off the wire until sent. const states = new WeakMap(); @@ -72,7 +83,12 @@ export async function ask( } const own = states.get(q); if (own !== undefined) tagged.push([name, own]); - wire[name] = q.type === "if" ? noul(q.instructions) : q; + wire[name] = + q.type === "if" + ? noul(q.instructions) + : q.type === "switch" + ? choiceQuestion(q.instructions, q.criteria) + : q; } // Interpolated objects of all tags go once each into `input`, and their slots become its paths. // A text or array state goes first among them. @@ -103,7 +119,9 @@ export async function ask( : { type: "chance", chance: a.noul } : a.type === "score" ? { ...a, ratio: a.score / ((wire[name] as ScoreQuestion).criteria.length - 1) } - : a; + : typeof q === "object" && q.type === "switch" + ? a.choice + : a; } return out as Answers; } @@ -142,7 +160,7 @@ function parse(strings: Strings, values: unknown[]): Parsed { } // Adds a hidden `then` that sends the question alone under `input`, so the object stays a plain question. -function askable(q: Q, parsed?: Parsed, options?: AskOptions) { +function askable(q: Q, parsed?: Parsed, options?: AskOptions) { if (parsed?.parts.length) states.set(q, parsed); const then: PromiseLike>["then"] = (ok, fail) => ask("", { input: q }, options) @@ -154,7 +172,7 @@ function askable(q: Q, parsed?: Parsed, options // Each tag works both as ask.choice`...`(criteria, options?) and ask.choice(instructions, criteria, options?), // where plain-call instructions may be a JSON object or array (see .agents/typesafe.md). -function tag(build: (instructions: Entry, criteria: C) => Q) { +function tag(build: (instructions: Entry, criteria: C) => Q) { return (first: Entry | Strings, ...rest: unknown[]) => { if (!isTag(first)) return askable(build(first, rest[0] as C), undefined, rest[1] as AskOptions); const parsed = parse(first, rest); @@ -193,6 +211,28 @@ export const score = tag(scoreQuestion) as { ) => Askable>; }; +/** Like `ask.choice`, but resolves to the selected label alone. */ +export const askSwitch = tag( + (instructions: Entry, criteria: T): SwitchQuestion => ({ + type: "switch", + instructions, + criteria, + }), +) as { + ( + instructions: Entry, + criteria: T, + options?: AskOptions, + ): Askable>; + ( + strings: Strings, + ...values: unknown[] + ): ( + criteria: T, + options?: AskOptions, + ) => Askable>; +}; + export const chance = tag(noul) as { ( instructions: Entry, @@ -236,3 +276,4 @@ ask.choice = choice; ask.score = score; ask.chance = chance; ask.if = askIf; +ask.switch = askSwitch; diff --git a/src/index.ts b/src/index.ts index e0be950..09cd1e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -export { ask, askIf, chance, choice, score } from "./ask.ts"; +export { ask, askIf, askSwitch, chance, choice, score } from "./ask.ts"; export type { Answer, Answers, @@ -9,6 +9,7 @@ export type { ChanceAnswer, Askable, IfQuestion, + SwitchQuestion, } from "./ask.ts"; export { APIError, typesafe } from "./api.ts"; export type * from "./api.ts"; diff --git a/test/ask.test.ts b/test/ask.test.ts index cb41e9c..4ae57f8 100644 --- a/test/ask.test.ts +++ b/test/ask.test.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it } from "vitest"; -import { ask, chance, choice, score } from "../src/index.ts"; +import { ask, askSwitch, chance, choice, score } from "../src/index.ts"; // With TYPESAFE_API_KEY set, requests go to the real API and only answer shapes are checked. const env = (globalThis as { process?: { env?: Record } }).process?.env; @@ -221,6 +221,64 @@ describe("ask", () => { expectTypeOf(result.security).toEqualTypeOf(); }); + it("ask.switch resolves to the selected label", async () => { + const issue = { title: "Checkout is down", body: "No one can pay." }; + const kind = await ask.switch`What kind of issue is ${issue}?`( + { bug: "Something is broken", other: null }, + options, + ); + + expect(seen.body.state).toEqual({ input: issue }); + expect(seen.body.questions).toEqual({ + input: { + type: "choice", + instructions: "What kind of issue is `input`?", + criteria: { bug: "Something is broken", other: null }, + }, + }); + expect(kind).toBe("bug"); + expectTypeOf(kind).toEqualTypeOf<"bug" | "other">(); + }, 20_000); + + it.skipIf(live)("ask.switch inside ask resolves to the label", async () => { + const result = await ask( + { title: "Crash on login" }, + { + kind: ask.switch`What kind of issue is this?`({ bug: null, other: null }), + plain: askSwitch("Kind?", { bug: null, other: null }), + }, + options, + ); + expect(seen.body.questions).toEqual({ + kind: { + type: "choice", + instructions: "What kind of issue is this?", + criteria: { bug: null, other: null }, + }, + plain: { type: "choice", instructions: "Kind?", criteria: { bug: null, other: null } }, + }); + expect(result).toEqual({ kind: "bug", plain: "bug" }); + expectTypeOf(result.kind).toEqualTypeOf<"bug" | "other">(); + expectTypeOf(result.plain).toEqualTypeOf<"bug" | "other">(); + + expect(ask.switch`Kind?`({ a: null })).toEqual({ + type: "switch", + instructions: "Kind?", + criteria: { a: null }, + }); + + const literal = await ask( + { title: "Crash on login" }, + { + kind: { type: "switch", instructions: "Kind?", criteria: { bug: null, other: null } }, + urgent: { type: "if", instructions: "Urgent?", threshold: 0.95 }, + }, + options, + ); + expect(literal).toEqual({ kind: "bug", urgent: false }); + expectTypeOf(literal.kind).toEqualTypeOf<"bug" | "other">(); + }); + it.skipIf(live)( "tags inside ask send their interpolated objects once each as input", async () => { From 9be6b6c05245c05060adb676aea9089531c49a4c Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 18 Sep 2026 08:55:28 +0000 Subject: [PATCH 2/4] docs: await ask.switch inside switch --- README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/README.md b/README.md index 86d9c8a..7d81807 100644 --- a/README.md +++ b/README.md @@ -107,11 +107,7 @@ Returns `{ type: "choice", choice, confidence, probabilities }`. `choice` is the Use `ask.switch` with the same options when you only need the label: ```ts -const { kind } = await ask(issue, { - kind: ask.switch`What kind of issue is this?`({ bug: "Something is broken", other: null }), -}); - -switch (kind) { +switch (await ask.switch`What kind of issue is ${issue}?`({ bug: null, other: null })) { case "bug": return label(issue, "bug"); case "other": From c57ab1b6da464077a891b602fc977afeeecffd6c Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 18 Sep 2026 09:09:02 +0000 Subject: [PATCH 3/4] feat: accept label arrays in ask.choice and ask.switch --- AGENTS.md | 2 +- README.md | 14 ++++++++------ skills/advocaat/SKILL.md | 5 +++-- src/ask.ts | 35 ++++++++++++++++++++++++----------- src/index.ts | 2 ++ test/ask.test.ts | 32 ++++++++++++++++++++++++++++++++ 6 files changed, 70 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a763304..2a2de4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,6 @@ - `src/api.ts`: minimal fetch client for the TypeSafe System One API (`typesafe({ apiKey })` → `systemOne`, `models`), plus `noul`/`choice`/`score` builders and typed answers. Reads `TYPESAFE_API_KEY`, `TYPESAFE_BASE_URL`, `TYPESAFE_DEFAULT_MODEL` from `globalThis.process?.env` when options are omitted. With only `AI_GATEWAY_API_KEY` or `VERCEL_OIDC_TOKEN` set (or `provider: "vercel"`), it speaks the Vercel AI Gateway evaluation protocol instead and maps answers back to the System One shape (`confidence` computed from the top probability, `legend` built from criteria, `models()` throws). Validates API limits (choice 2–255 options, score 2–10 levels). No retries or logging. - `src/ask.ts`: `ask(state, questions, options?)` from `IDEA.md`. Bare strings, `ask.choice`/`ask.score`/`ask.chance` tags (also callable as `ask.choice(instructions, criteria, options?)`, where instructions may be a JSON object or array), and plain client question objects go out as one request; answers come back under the same keys with `chance` (noul) and `ratio` (score scaled to 0–1) added. Every tag returns an `Askable`: the plain question object plus a hidden (non-enumerable) `then` that sends it alone under the key `input` and resolves to its answer, so anything that unwraps promises sends a request. Interpolated objects and arrays are kept in a `WeakMap` off the wire; when sent, the distinct objects of all tags in the request go into `input` (one directly, several as an array) and each slot is rendered as its path there. Inside `ask` they are added to an object state (which must not have `input`; empty or `null` becomes `{}`), and a text or array state goes first in that `input` array. The key stays `input` on purpose: on the real API, question-derived keys primed answers toward yes, and a named key for the text did worse than text-first. Options for a standalone send go in the criteria call. - `askIf` (also `ask.if`) is a tag with no criteria call for one yes/no question resolved to a boolean (`askIf(options)` binds options; `threshold` defaults to 0.5, strict `>`); inside `ask` it goes out as a noul question and resolves to a boolean under its key. -- `askSwitch` (also `ask.switch`) takes the same forms as `ask.choice`, goes out as a choice question, and resolves to the selected label alone. Named exports because `if` and `switch` are reserved words. +- `askSwitch` (also `ask.switch`) takes the same forms as `ask.choice`, goes out as a choice question, and resolves to the selected label alone. Named exports because `if` and `switch` are reserved words. Both tags accept a label array as criteria, normalized to `{ label: null }` before the wire so the answer type keeps the label union. - `src/index.ts` exports `ask`, `askIf`, `askSwitch`, and the tags; the client's `noul`/`choice`/`score` builders stay internal. - `playground/`: private workspace package with a GitHub issue triage CLI (`ask` over `octokit`). `pnpm-workspace.yaml` overrides `advocaat` to `link:.`, and `package.json` `exports` points at `src/` (swapped to `dist/` by `publishConfig` on publish). Node runs the source directly, so `src/` must stay erasable-syntax only (enforced by tsconfig). diff --git a/README.md b/README.md index 7d81807..a1172e1 100644 --- a/README.md +++ b/README.md @@ -104,10 +104,12 @@ console.log(kind.choice); // "bug" | "other" Returns `{ type: "choice", choice, confidence, probabilities }`. `choice` is the selected label, typed as `"bug" | "other"` here. `probabilities` contains a probability for each label. `confidence` (0...1) is high when one label stands out and low when they are close. +When the labels speak for themselves, pass them as an array instead of an object with `null` descriptions: ``ask.choice`What kind of issue is this?`(["bug", "other"])``. + Use `ask.switch` with the same options when you only need the label: ```ts -switch (await ask.switch`What kind of issue is ${issue}?`({ bug: null, other: null })) { +switch (await ask.switch`What kind of issue is ${issue}?`(["bug", "other"])) { case "bug": return label(issue, "bug"); case "other": @@ -138,8 +140,8 @@ Returns `{ type: "score", score, ratio, confidence, legend, probabilities }`. `s For a single question, interpolate the data into a tag and await it. You get the same answer as you would under its key in `ask`: ```ts -const kind = await ask.choice`What kind of issue is ${issue}?`({ bug: null, other: null }); -const label = await ask.switch`What kind of issue is ${issue}?`({ bug: null, other: null }); +const kind = await ask.choice`What kind of issue is ${issue}?`(["bug", "other"]); +const label = await ask.switch`What kind of issue is ${issue}?`(["bug", "other"]); const severity = await ask.score`How severe is ${issue}?`(["Cosmetic", "Blocks production"]); const { chance } = await ask.chance`Does ${issue} need immediate attention?`(); const security = await ask.if`Does ${issue} describe a security vulnerability?`; @@ -250,8 +252,8 @@ For standalone requests, pass client options after the criteria. `ask.chance` ac ```ts const options = { model: "jev-latest" }; -await ask.choice`What kind of issue is ${issue}?`({ bug: null, other: null }, options); -await ask.switch`What kind of issue is ${issue}?`({ bug: null, other: null }, options); +await ask.choice`What kind of issue is ${issue}?`(["bug", "other"], options); +await ask.switch`What kind of issue is ${issue}?`(["bug", "other"], options); await ask.score`How severe is ${issue}?`(["Cosmetic", "Blocks production"], options); await ask.chance`Does ${issue} need immediate attention?`(undefined, options); ``` @@ -276,7 +278,7 @@ When you build questions without template strings, use: - `ask.score(instructions, levels, options?)` - `ask.chance(instructions, criteria?, options?)` -These return the same awaitable questions as the tags. Instructions and criteria descriptions accept text, JSON objects or arrays, or `null`: +These return the same awaitable questions as the tags. Instructions and criteria descriptions accept text, JSON objects or arrays, or `null`; choice and switch criteria may also be an array of labels: ```ts const { kind } = await ask(issue, { diff --git a/skills/advocaat/SKILL.md b/skills/advocaat/SKILL.md index bf73ac0..f7c086f 100644 --- a/skills/advocaat/SKILL.md +++ b/skills/advocaat/SKILL.md @@ -121,13 +121,14 @@ Choose by what the answer means, then read the relevant primitive page: | One of a defined set | ``ask.choice`…`({ a: "…", b: null })`` ([Choice](https://docs.typesafe.ai/primitives/choice.md)) | `{ choice, confidence, probabilities }` | Picks one option (2–255); `probabilities` compares competing options | | Whether a condition holds | `"Is it …?"`, ``ask.chance`…`({ true: "…", false: "…" })`` ([Noul](https://docs.typesafe.ai/primitives/noul.md)) | `{ chance }` | Probability of yes; no separate confidence; use one per label when several may apply | | A yes/no you act on directly | `` ask.if`…` `` | `boolean` | `chance` above `threshold` (default `0.5`); pass `ask.if({ threshold })` to bind another | -| A label you branch on directly | ``ask.switch`…`({ a: "…", b: null })`` | `"a" \| "b"` | Same options as `ask.choice`; resolves to the selected label alone, without `confidence` or `probabilities` | +| A label you branch on directly | ``ask.switch`…`(["a", "b"])`` | `"a" \| "b"` | Same options as `ask.choice`; resolves to the selected label alone, without `confidence` or `probabilities` | | Degree along a described dimension | ``ask.score`…`(["low …", "mid …", "high …"])`` ([Score](https://docs.typesafe.ai/primitives/score.md)) | `{ score, ratio, confidence, legend, probabilities }` | Probability-weighted position on 2–10 ordered levels; `ratio` scales it to 0–1; use comparable per-item Scores for graded ranking | Every tag also takes plain arguments when the question is built elsewhere, for example `ask.choice(instructions, criteria)`, and `ask` accepts plain question objects (`type: "noul" | "choice" | "score"`). Instructions and criteria values -can be strings, JSON objects, or arrays. +can be strings, JSON objects, or arrays. Choice and switch criteria can also be +an array of labels when no descriptions are needed. Give each question enough relevant **state** to answer: source text, identities, relationships, policies, and current facts. Pass a named JSON object as the diff --git a/src/ask.ts b/src/ask.ts index fcfd56b..a513c49 100644 --- a/src/ask.ts +++ b/src/ask.ts @@ -65,6 +65,17 @@ export interface SwitchQuestion { readonly criteria: T; } +/** Two or more option labels without descriptions. */ +export type ChoiceLabels = readonly [string, string, ...string[]]; + +/** Labels as criteria with `null` descriptions; an object stays as it is. */ +export type Named = T extends ChoiceLabels ? { [K in T[number]]: null } : T; + +const named = (criteria: T) => + (Array.isArray(criteria) + ? Object.fromEntries(criteria.map((label) => [label, null])) + : criteria) as Named; + // Interpolated objects of tagged questions, kept off the wire until sent. const states = new WeakMap(); @@ -181,19 +192,21 @@ function tag(build: (instructions: Entry, criteria: C) => Q }; } -export const choice = tag(choiceQuestion) as { - ( +export const choice = tag((instructions: Entry, criteria: ChoiceCriteria | ChoiceLabels) => + choiceQuestion(instructions, named(criteria)), +) as { + ( instructions: Entry, criteria: T, options?: AskOptions, - ): Askable>; + ): Askable>>; ( strings: Strings, ...values: unknown[] - ): ( + ): ( criteria: T, options?: AskOptions, - ) => Askable>; + ) => Askable>>; }; export const score = tag(scoreQuestion) as { @@ -213,24 +226,24 @@ export const score = tag(scoreQuestion) as { /** Like `ask.choice`, but resolves to the selected label alone. */ export const askSwitch = tag( - (instructions: Entry, criteria: T): SwitchQuestion => ({ + (instructions: Entry, criteria: ChoiceCriteria | ChoiceLabels): SwitchQuestion => ({ type: "switch", instructions, - criteria, + criteria: named(criteria), }), ) as { - ( + ( instructions: Entry, criteria: T, options?: AskOptions, - ): Askable>; + ): Askable>>; ( strings: Strings, ...values: unknown[] - ): ( + ): ( criteria: T, options?: AskOptions, - ) => Askable>; + ) => Askable>>; }; export const chance = tag(noul) as { diff --git a/src/index.ts b/src/index.ts index 09cd1e3..3203d72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,8 +7,10 @@ export type { AskQuestion, AskQuestions, ChanceAnswer, + ChoiceLabels, Askable, IfQuestion, + Named, SwitchQuestion, } from "./ask.ts"; export { APIError, typesafe } from "./api.ts"; diff --git a/test/ask.test.ts b/test/ask.test.ts index 4ae57f8..b0fe71b 100644 --- a/test/ask.test.ts +++ b/test/ask.test.ts @@ -128,6 +128,38 @@ describe("ask", () => { expect(Object.keys(choice`Kind?`({ a: null }))).toEqual(["type", "instructions", "criteria"]); }); + it("choice and switch take labels without descriptions", async () => { + const issue = { title: "Add dark mode" }; + const q = ask.switch`What kind of issue is ${issue}?`(["bug", "feature"]); + expect(q).toEqual({ + type: "switch", + instructions: "What kind of issue is `input`?", + criteria: { bug: null, feature: null }, + }); + expect(choice("Kind?", ["bug", "feature"])).toEqual( + choice`Kind?`({ bug: null, feature: null }), + ); + expectTypeOf(choice("Kind?", ["bug", "feature"]).criteria).toEqualTypeOf<{ + bug: null; + feature: null; + }>(); + + const { kind, label } = await ask( + issue, + { kind: choice`Kind?`(["bug", "other"]), label: askSwitch("Kind?", ["bug", "other"]) }, + options, + ); + expect(seen.body.questions.kind.criteria).toEqual({ bug: null, other: null }); + expect(kind.choice).toBe("bug"); + expect(label).toBe("bug"); + expectTypeOf(kind.choice).toEqualTypeOf<"bug" | "other">(); + expectTypeOf(kind.probabilities).toEqualTypeOf<{ + readonly bug: number; + readonly other: number; + }>(); + expectTypeOf(label).toEqualTypeOf<"bug" | "other">(); + }, 20_000); + it("tags also accept a plain string", () => { expect(choice("Kind?", { a: null })).toEqual(choice`Kind?`({ a: null })); expect(score("Level?", ["low", "high"])).toEqual(score`Level?`(["low", "high"])); From f12cc1d9ffb6cec9204be24f22653622b722a0c4 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 18 Sep 2026 09:11:11 +0000 Subject: [PATCH 4/4] docs: pair if and switch with chance and choice in the types table --- README.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a1172e1..ae68221 100644 --- a/README.md +++ b/README.md @@ -54,13 +54,11 @@ console.log(severity.ratio); // 0...1 Answers use the same keys as your questions. Mix any of these forms in one `ask` call: -| Want | Use | Read | -| ----------- | ---------------------- | --------------- | -| Boolean | `ask.if` | Answer directly | -| Probability | String or `ask.chance` | `.chance` | -| Category | `ask.choice` | `.choice` | -| Label | `ask.switch` | Answer directly | -| Rating | `ask.score` | `.ratio` | +| Want | Use | Read | Or, for the value alone | +| ------ | ---------------------- | --------- | ----------------------- | +| Yes/no | String or `ask.chance` | `.chance` | `ask.if` → `boolean` | +| Option | `ask.choice` | `.choice` | `ask.switch` → label | +| Rating | `ask.score` | `.ratio` | | The examples below reuse `ask` and `issue` from the quick start.