From f4fd88dc22783eb095d0a01937df51aef20e22a9 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 21 Aug 2026 13:54:25 +0000 Subject: [PATCH] Add ask-web and tts: the two AI tools we already pay for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Working from a list of 131 AI tools, the question was which of them are actually ours. The team vault answers it: of the thirteen keys in profullstack-sharable-keys--prod, five appear on that list, and two of those five had no command here. ask-web Perplexity — a question answered from the live web, with the pages it came from tts ElevenLabs — text read aloud in a named voice, audio kept Not named `ask` or `say`: both already resolve to something else on PATH, and a command that shadows another one silently is worse than a longer name. No pit alias for the same reason. `ask-web` numbers its source list from the response's positional `citations` field, whose order *is* the [n] numbering, and joins titles on by URL from `search_results`, which arrives in its own order — numbering from the titles would mislabel every source, quietly. A marker the answer cites with no source behind it is a warning on stderr rather than a line dropped. `tts` resolves a voice by ID, full label, or the human part of it, since the account's are called things like "River - Relaxed, Neutral, Informative". An ambiguous prefix is an error naming the candidates: a silent pick would change narrator the day the account gains a voice. Voice settings are omitted unless asked for, so a call cannot overwrite what a shared account tuned in the dashboard. KNOWN_KEYS gains perplexity and elevenlabs. The rule that kept it at two still holds — a key earns its place by being read by a command here, not by being one the team owns — and the vault's remaining nine stay in the vault. Gemini was the third candidate and is deliberately absent: the vault's GEMINI_API_KEY returns 400 "User location is not supported for the API use" from here, so a provider built on it would ship broken. Both verified against the live APIs. Suite is 224 tests. Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/marketplace.json | 19 ++ README.md | 79 +++++++- bin/ask-web.ts | 124 ++++++++++++ bin/tts.ts | 163 +++++++++++++++ plugins/ai/.claude-plugin/plugin.json | 20 ++ plugins/ai/README.md | 45 +++++ plugins/ai/commands/ask.md | 56 ++++++ plugins/ai/commands/tts.md | 54 +++++ plugins/tools/commands/config.md | 7 +- src/ask-web.ts | 236 ++++++++++++++++++++++ src/credentials.ts | 12 +- src/registry.ts | 2 + src/tts.ts | 280 ++++++++++++++++++++++++++ test/ask-web.test.ts | 154 ++++++++++++++ test/tts.test.ts | 193 ++++++++++++++++++ 15 files changed, 1438 insertions(+), 6 deletions(-) create mode 100755 bin/ask-web.ts create mode 100755 bin/tts.ts create mode 100644 plugins/ai/.claude-plugin/plugin.json create mode 100644 plugins/ai/README.md create mode 100644 plugins/ai/commands/ask.md create mode 100644 plugins/ai/commands/tts.md create mode 100644 src/ask-web.ts create mode 100644 src/tts.ts create mode 100644 test/ask-web.test.ts create mode 100644 test/tts.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index abadc46..9298723 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -63,6 +63,25 @@ "llm" ] }, + { + "name": "ai", + "description": "Ask a question and get an answer from the live web with the pages it came from, numbered to match the markers in the text, and read text aloud in a named voice. The paid APIs worth a command: a grounded answer you can check, and speech you can keep.", + "source": "./plugins/ai", + "category": "productivity", + "author": { + "name": "profullstack", + "url": "https://profullstack.com" + }, + "homepage": "https://github.com/profullstack/cli-tools#ask-web", + "keywords": [ + "search", + "citations", + "perplexity", + "tts", + "speech", + "elevenlabs" + ] + }, { "name": "bo", "description": "BufferOverride from the terminal: wrap a failing command, keep what it printed with the environment that produced it, strip the secrets, and find the existing answer before asking. Answers declare the versions they are valid for, who or what wrote them, and how many people reproduced them.", diff --git a/README.md b/README.md index c3692f8..7d281de 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ TypeScript, installed as executables on `PATH`. | [`domainjson`](#domainjson) | whois-style, JSON-first name lookup | | [`domainfree`](#domainfree) | Which of these domains you can actually register | | [`blog-post`](#blog-post) | Publish to a plain-HTML blog without breaking the feed | +| [`ask-web`](#ask-web) | Answer a question from the live web, with its sources | +| [`tts`](#tts) | Read text aloud and keep the audio | ## Requirements @@ -93,8 +95,9 @@ ln -sf ~/scripts/bin/gh-prs-merge ~/.local/bin/gh-prs-merge # and so on ## API keys -`generate-names` needs an OpenAI or Anthropic key. Store one once, and nothing -has to carry it in an environment again: +Four commands here call a paid API: `generate-names` (OpenAI or Anthropic), +`ask-web` (Perplexity) and `tts` (ElevenLabs). Store the keys once, and nothing +has to carry them in an environment again: ```sh cli-tools config pull # import them from the logicsrc team vault @@ -125,8 +128,8 @@ obscurely. **It imports only the keys these commands read, and leaves the rest in the vault.** Copying a whole vault down would make the local file a second copy of every team secret that nobody remembers to invalidate — which is the thing the -vault exists to avoid. The vault stays the authority; this is a cache of the two -or three keys `generate-names` actually needs. +vault exists to avoid. The vault stays the authority; this is a cache of the +handful of keys these commands actually read. `logicsrc teams pull` can only write a decrypted `.env` to a path, so the plaintext exists for the length of one read: it goes to a `0700` temporary @@ -143,6 +146,12 @@ carries the same masked previews, not the values. | --- | --- | --- | | `openai` | `OPENAI_API_KEY` | `generate-names` | | `anthropic` | `ANTHROPIC_API_KEY` | `generate-names` | +| `perplexity` | `PERPLEXITY_API_KEY` | `ask-web` | +| `elevenlabs` | `ELEVENLABS_API_KEY` | `tts` | + +A key earns a row here by being read by a command in this repository, not by +being a key the team owns. The vault holds more than twice as many; the rest +stay in it. **The environment wins over the file.** A key exported in your shell or injected by CI overrides a stored one, so a one-off `OPENAI_API_KEY=… generate-names …` @@ -421,6 +430,68 @@ What it refuses to do: `check` reports missing, unparseable and future dates, empty descriptions and a missing `

`, and exits non-zero, so it works as a pre-publish gate. +### `ask-web` + +Answers a question from a live web search and prints the pages the answer came +from, numbered to match the `[n]` markers in the text: + +```sh +ask-web "what is the latest Node LTS" --recency month +# The latest stable Node.js LTS version is v24.19.0.[3][5] +# +# Sources: +# [1] Node.js — https://endoflife.date/nodejs (2026-08-06) +# [3] Node.js 24.19.0 (LTS) — https://nodejs.org/en/blog/release/v24.19.0 (2026-08-03) +``` + +```sh +ask-web "…" --domains nodejs.org,github.com # only these hosts +ask-web "…" --model sonar-pro # search wider +ask-web "…" --bare # prose only, for piping +ask-web "…" --json # answer and sources as JSON +``` + +It is not named `ask` because that name is already taken on `PATH` here, and a +command that shadows another one silently is worse than a longer name. + +**The sources are the feature.** An answer whose `[1]` resolves to nothing is +indistinguishable from an answer that was invented, so two fields of the +response are treated differently on purpose: `citations` is a positional URL +list whose order *is* the numbering, while `search_results` carries the titles in +whatever order it likes and is joined on by URL. Numbering from `search_results` +would mislabel every source. When the answer cites a marker no source backs, +that is reported on stderr rather than dropped. + +Answers go to stdout and status to stderr, so `ask-web … | pbcopy` gets prose. + +### `tts` + +Reads text aloud with ElevenLabs and keeps the audio, printing the path it +wrote: + +```sh +tts "the deploy finished" # → the-deploy-finished.mp3 +mpv "$(tts 'build is green')" +cat post.md | tts --voice George --out post.mp3 +tts --voices # the account's voices, by name and ID +``` + +The file is named after the text rather than a timestamp, because a directory of +`speech-1755794400.mp3` tells you nothing about which one it was. + +A voice can be given as an ID, a full label, or just the human part of it — +the account's are called things like `River - Relaxed, Neutral, Informative`, +which nobody is going to type. An ambiguous prefix is an error naming the +candidates rather than a pick of the first match: choosing one would be a coin +flip that changes narrator the day the account gains a voice, with nothing on +screen to explain why. A voice given as an ID skips the lookup entirely, so +`--voice ` works on a key whose plan cannot list voices. + +`--stability`, `--similarity` and `--style` take 0–1. Nothing is sent unless you +ask: a full settings object would override whatever the voice was tuned with in +the dashboard, on an account other people share. Synthesis spends characters from +that shared quota, and nothing here retries, so a failed call never costs twice. + ## As a moshcode plugin This repo is also a plugin marketplace: diff --git a/bin/ask-web.ts b/bin/ask-web.ts new file mode 100755 index 0000000..8035584 --- /dev/null +++ b/bin/ask-web.ts @@ -0,0 +1,124 @@ +#!/usr/bin/env -S npx --yes tsx +/** + * ask-web — answer a question from the live web, with the sources attached. + * + * ask-web "what changed in the EU AI act this month" --recency month + * + * Not named `ask`: that name is already taken on PATH here, and a command that + * shadows another one silently is worse than a longer name. + */ + +import { UsageError, csv, integer, parseArgs } from '../src/args.ts'; +import { + DEFAULT_MODEL, + MODELS, + type Model, + RECENCY, + type Recency, + askWeb, + formatAnswer, + perplexityCaller, +} from '../src/ask-web.ts'; +import { resolveCredentials } from '../src/credentials.ts'; +import { isMain } from '../src/is-main.ts'; + +const USAGE = `Usage: + ask-web "" + ask-web "what shipped in Node 24" --recency month --domains nodejs.org + +Answers from a live web search and prints the pages it used, numbered to match +the [n] markers in the answer. + +Options: + --model M ${MODELS.join(' | ')} + (default: ${DEFAULT_MODEL}) + --recency R only pages from the last ${RECENCY.join(' | ')} + --domains A,B restrict the search to these hosts + --max-tokens N cap the answer length + --bare print the answer only, no source list + --json the whole answer as JSON, sources included + --timeout MS API timeout (default: 60000) + -h, --help show this help + +Needs a Perplexity key. Store one once: + + cli-tools config set perplexity # prompts, nothing echoed or logged + cli-tools config # what is set, and where it came from + +kept 0600 in ~/.config/cli-tools/credentials.json. PERPLEXITY_API_KEY still +works and takes precedence over a stored key. + +The answer goes to stdout and nothing else does, so it pipes cleanly. +`; + +if (isMain(import.meta.url)) { + try { + const { flags, values, positional } = parseArgs(process.argv.slice(2), { + boolean: ['-h', '--help', '--bare', '--json'], + string: ['--model', '--recency', '--domains', '--max-tokens', '--timeout'], + }); + + if (flags.has('-h') || flags.has('--help') || positional.length === 0) { + process.stdout.write(USAGE); + process.exit(positional.length === 0 && !flags.has('-h') && !flags.has('--help') ? 1 : 0); + } + + const question = positional.join(' ').trim(); + if (question.length < 3) throw new UsageError('ask an actual question'); + + const model = values.get('--model') ?? DEFAULT_MODEL; + if (!MODELS.includes(model as Model)) { + throw new UsageError(`unknown model: ${model} (expected ${MODELS.join(', ')})`); + } + + const recency = values.get('--recency'); + if (recency !== undefined && !RECENCY.includes(recency as Recency)) { + throw new UsageError(`--recency must be ${RECENCY.join(', ')}, got "${recency}"`); + } + + const timeout = integer(values, '--timeout', 60_000, { min: 1000, max: 600_000 }); + const maxTokens = values.has('--max-tokens') + ? integer(values, '--max-tokens', 0, { min: 1, max: 32_000 }) + : undefined; + + // Stored keys first, environment on top — see src/credentials.ts. + const credentials = resolveCredentials(process.env); + const apiKey = credentials['PERPLEXITY_API_KEY']; + if (!apiKey) { + throw new UsageError( + 'no Perplexity key — run `cli-tools config set perplexity`, ' + + 'or export PERPLEXITY_API_KEY', + ); + } + + const answer = await askWeb(question, perplexityCaller(apiKey, timeout), { + model, + ...(recency ? { recency: recency as Recency } : {}), + domains: csv(values, '--domains'), + ...(maxTokens !== undefined ? { maxTokens } : {}), + }); + + process.stdout.write( + flags.has('--json') + ? `${JSON.stringify(answer, null, 2)}\n` + : formatAnswer(answer, { bare: flags.has('--bare') }), + ); + + // Status on stderr so it never lands in a pipe. The dangling-citation line + // is the one worth reading: it means the answer cited something the search + // did not return. + process.stderr.write(`${answer.sources.length} sources · ${answer.model}\n`); + if (answer.danglingCitations.length > 0) { + process.stderr.write( + `warning: cites [${answer.danglingCitations.join('], [')}] with no matching source\n`, + ); + } + } catch (error) { + if (error instanceof UsageError) { + process.stderr.write(`ask-web: ${error.message}\n`); + process.exit(1); + } + process.stderr.write(`ask-web: ${error instanceof Error ? error.message : error}\n`); + process.exit(2); + } +} diff --git a/bin/tts.ts b/bin/tts.ts new file mode 100755 index 0000000..214b95c --- /dev/null +++ b/bin/tts.ts @@ -0,0 +1,163 @@ +#!/usr/bin/env -S npx --yes tsx +/** + * tts — read text aloud with ElevenLabs and keep the audio. + * + * tts "the deploy finished" + * blog-post list | tts --voice George --out digest.mp3 + * tts --voices + * + * The path of what it wrote goes to stdout, so it composes: + * + * mpv "$(tts 'build is green')" + */ + +import { writeFileSync } from 'node:fs'; + +import { UsageError, integer, parseArgs } from '../src/args.ts'; +import { resolveCredentials } from '../src/credentials.ts'; +import { isMain } from '../src/is-main.ts'; +import { + DEFAULT_FORMAT, + DEFAULT_MODEL, + DEFAULT_VOICE, + FORMATS, + type Format, + type SpeakOptions, + VoiceError, + elevenLabsClient, + formatVoices, + outputPathFor, + parseFraction, + resolveVoice, +} from '../src/tts.ts'; + +const USAGE = `Usage: + tts "" + tts --voices + cat post.md | tts --voice George --out post.mp3 + +Synthesises speech and writes an audio file, printing its path to stdout. + +Options: + --voice V voice ID, full name, or just the first word + (default: ${DEFAULT_VOICE}) + --voices list the account's voices and exit + -o, --out PATH where to write; "-" streams the audio to stdout + --model M (default: ${DEFAULT_MODEL}; --model eleven_v3 for the newest) + --format F ${FORMATS.join(' | ')} + (default: ${DEFAULT_FORMAT}) + --stability N 0-1, higher is flatter and more consistent + --similarity N 0-1, how closely to imitate the original voice + --style N 0-1, above 0 the delivery gets theatrical and slower + --timeout MS API timeout (default: 120000) + -h, --help show this help + +Text comes from the arguments, or from stdin when there are none. + +Needs an ElevenLabs key. Store one once: + + cli-tools config set elevenlabs # prompts, nothing echoed or logged + cli-tools config # what is set, and where it came from + +kept 0600 in ~/.config/cli-tools/credentials.json. ELEVENLABS_API_KEY still +works and takes precedence over a stored key. + +Synthesis spends characters from the account quota. Nothing here retries, so a +failed call costs nothing twice. +`; + +/** Read all of stdin. Used when the text is piped rather than quoted. */ +async function readStdin(): Promise { + if (process.stdin.isTTY) return ''; + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString('utf8'); +} + +if (isMain(import.meta.url)) { + try { + const { flags, values, positional } = parseArgs(process.argv.slice(2), { + boolean: ['-h', '--help', '--voices'], + string: [ + '--voice', '-o', '--out', '--model', '--format', + '--stability', '--similarity', '--style', '--timeout', + ], + }); + + if (flags.has('-h') || flags.has('--help')) { + process.stdout.write(USAGE); + process.exit(0); + } + + const format = values.get('--format') ?? DEFAULT_FORMAT; + if (!FORMATS.includes(format as Format)) { + throw new UsageError(`unknown --format: ${format} (expected ${FORMATS.join(', ')})`); + } + + const timeout = integer(values, '--timeout', 120_000, { min: 1000, max: 600_000 }); + + // Stored keys first, environment on top — see src/credentials.ts. + const credentials = resolveCredentials(process.env); + const apiKey = credentials['ELEVENLABS_API_KEY']; + if (!apiKey) { + throw new UsageError( + 'no ElevenLabs key — run `cli-tools config set elevenlabs`, ' + + 'or export ELEVENLABS_API_KEY', + ); + } + + const client = elevenLabsClient(apiKey, timeout, format); + + if (flags.has('--voices')) { + const voices = await client.listVoices(); + process.stdout.write(`${formatVoices(voices)}\n`); + process.stderr.write(`${voices.length} voices\n`); + process.exit(0); + } + + const text = (positional.length > 0 ? positional.join(' ') : await readStdin()).trim(); + if (!text) { + process.stdout.write(USAGE); + process.exit(1); + } + + // Only fetch the voice list when the name needs looking up. A voice given + // as an ID goes straight through, which keeps `--voice ` working on a + // key whose plan cannot list voices. + const wanted = values.get('--voice') ?? DEFAULT_VOICE; + const voiceId = /^[A-Za-z0-9]{20}$/.test(wanted) + ? wanted + : resolveVoice(await client.listVoices(), wanted); + + const options: SpeakOptions = { model: values.get('--model') ?? DEFAULT_MODEL }; + for (const [flag, key] of [ + ['--stability', 'stability'], + ['--similarity', 'similarity'], + ['--style', 'style'], + ] as const) { + const raw = values.get(flag); + if (raw !== undefined) options[key] = parseFraction(flag, raw); + } + + const audio = await client.speak(voiceId, text, options); + + const out = values.get('-o') ?? values.get('--out'); + if (out === '-') { + // The audio itself is the output here, so nothing else may touch stdout. + process.stdout.write(audio); + process.stderr.write(`${audio.length} bytes · ${options.model} · ${voiceId}\n`); + } else { + const path = out ?? outputPathFor(text, format); + writeFileSync(path, audio); + process.stdout.write(`${path}\n`); + process.stderr.write(`${audio.length} bytes · ${options.model} · ${voiceId}\n`); + } + } catch (error) { + if (error instanceof UsageError || error instanceof VoiceError) { + process.stderr.write(`tts: ${error.message}\n`); + process.exit(1); + } + process.stderr.write(`tts: ${error instanceof Error ? error.message : error}\n`); + process.exit(2); + } +} diff --git a/plugins/ai/.claude-plugin/plugin.json b/plugins/ai/.claude-plugin/plugin.json new file mode 100644 index 0000000..f6b9cc2 --- /dev/null +++ b/plugins/ai/.claude-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://anthropic.com/claude-code/plugin.schema.json", + "name": "ai", + "description": "Ask a question and get an answer from the live web with the pages it came from, numbered to match the markers in the text, and read text aloud in a named voice. The paid APIs worth a command: a grounded answer you can check, and speech you can keep.", + "version": "0.1.0", + "author": { + "name": "profullstack", + "url": "https://profullstack.com" + }, + "homepage": "https://github.com/profullstack/cli-tools#ask-web", + "license": "MIT", + "keywords": [ + "search", + "citations", + "perplexity", + "tts", + "speech", + "elevenlabs" + ] +} diff --git a/plugins/ai/README.md b/plugins/ai/README.md new file mode 100644 index 0000000..66943fd --- /dev/null +++ b/plugins/ai/README.md @@ -0,0 +1,45 @@ +# ai + +Two paid APIs that earn a command: a web answer you can check, and speech you +can keep. + +`/ai:ask` answers a question from a live web search and prints the pages it +used, numbered to match the `[n]` markers in the answer. `/ai:tts` reads text +aloud in a named voice and writes the audio. + +## Install + +```bash +moshcode plugin marketplace add profullstack/cli-tools +moshcode plugin install ai@cli-tools +``` + +Or install the commands directly, without the plugin: + +```bash +curl -fsSL https://raw.githubusercontent.com/profullstack/cli-tools/master/install.sh | sh +cli-tools config pull # imports the Perplexity and ElevenLabs keys +``` + +## The thing worth knowing + +**The citations are the product, and they are only useful if you open them.** +An answer whose `[1]` resolves to nothing is indistinguishable from an answer +that was invented, so `ask-web` numbers the printed list from the response's +positional `citations` field — the one whose order *is* the numbering — and +joins titles on by URL from `search_results`, which arrives in whatever order it +likes. Numbering from the titles would mislabel every source, quietly. When the +answer cites a marker no source backs, that is a warning on stderr rather than a +line silently dropped. + +A grounded answer is still a summary of pages that may be wrong or stale. The +citation tells you which page to blame; it is not evidence the page was right. + +**`tts` bills a shared account.** Characters come out of the team's ElevenLabs +quota, nothing retries, and a re-run of a successful call bills again — so keep +the file rather than re-synthesising, and do not loop it over a directory +without deciding to. + +Both commands read their key from `~/.config/cli-tools/credentials.json` +(`cli-tools config`), and an exported `PERPLEXITY_API_KEY` or +`ELEVENLABS_API_KEY` overrides the stored one. diff --git a/plugins/ai/commands/ask.md b/plugins/ai/commands/ask.md new file mode 100644 index 0000000..9bed91c --- /dev/null +++ b/plugins/ai/commands/ask.md @@ -0,0 +1,56 @@ +--- +description: Answer a question from the live web, with the pages the answer came from. +allowed-tools: Bash(ask-web:*), Read +--- + +## Task + +Answer a question that needs the current web rather than training data. + +```bash +ask-web "what is the latest Node LTS" --recency month +ask-web "…" --domains nodejs.org,github.com # only these hosts +ask-web "…" --model sonar-pro # search wider +ask-web "…" --bare # prose only, for piping +ask-web "…" --json # answer and sources as JSON +``` + +Models, cheapest first: `sonar`, `sonar-pro`, `sonar-reasoning`, +`sonar-reasoning-pro`, `sonar-deep-research`. The reasoning ones think before +answering and are worth it only for a question with steps in it. + +## Read the sources, not just the answer + +The `[n]` markers in the answer are numbered to match the list printed under it, +and that pairing is the reason to use this rather than a search engine: it is +what makes the answer checkable. + +So check it. Before repeating a claim from here as fact — a version number, a +date, a price, anything a decision rests on — open the source behind the marker +that supports it. A grounded answer is still a summary of pages that may +themselves be wrong, out of date, or SEO filler; the citation tells you which +page to blame, not that the page was right. + +`warning: cites [n] with no matching source` on stderr means the answer +referenced something the search did not return. Treat that answer as unverified. + +## Restrict the search when you can + +`--domains nodejs.org` on a question about Node changes the answer quality more +than a bigger model does — most of what a general search returns for a technical +question is content farms restating the docs. Prefer the primary source and say +where you looked. + +`--recency` is the other one worth reaching for: anything about "the latest" or +"currently" should carry `--recency month` or `--recency week`, or the search is +free to answer from a page written two years ago. + +## Piping + +The answer and its sources go to stdout; counts and warnings go to stderr. So +`ask-web "…" --bare | pbcopy` copies prose and nothing else, and `--json` gives +you `{text, sources, model, danglingCitations}` for a script. + +Needs a Perplexity key: `cli-tools config set perplexity`, or +`cli-tools config pull` from the team vault. Every call spends money — a small +amount, but not zero, so do not loop this over a list without deciding to. diff --git a/plugins/ai/commands/tts.md b/plugins/ai/commands/tts.md new file mode 100644 index 0000000..bc6b00f --- /dev/null +++ b/plugins/ai/commands/tts.md @@ -0,0 +1,54 @@ +--- +description: Read text aloud in a named voice and keep the audio file. +allowed-tools: Bash(tts:*), Read +--- + +## Task + +Turn text into speech and keep the audio. + +```bash +tts "the deploy finished" # → the-deploy-finished.mp3 +mpv "$(tts 'build is green')" # the path is the stdout +cat post.md | tts --voice George --out post.mp3 +tts --voices # names and IDs in this account +tts "…" --out - # audio to stdout, for piping +``` + +The written path goes to stdout and the byte count to stderr, so `tts` composes +into a shell pipeline without a banner getting in the way. + +## Naming a voice + +Give a voice as an ID, its full label, or just the human part — the account's +voices are called things like `River - Relaxed, Neutral, Informative`, and +`--voice River` is enough. An ambiguous prefix fails and names the candidates +rather than picking one, because a silent pick would change narrator the day the +account gains a voice. + +Run `/ai:tts --voices` first if you are choosing one; the descriptions in the +labels are the only guide to what each sounds like. + +## It spends a shared quota + +Synthesis bills characters against the team's ElevenLabs account, so: + +- **Synthesise once.** Nothing here retries, which means a failed call costs + nothing twice — but re-running a successful one bills again. Keep the file. +- **Check the text before sending it**, not after. A typo costs the whole + passage a second time. +- **Do not loop this over a directory** without deciding to out loud first. + +`--stability`, `--similarity` and `--style` take 0–1 and are omitted unless +asked for, so the voice keeps whatever it was tuned with in the dashboard. +Leave them alone unless you have a reason: they are account-visible settings, +not per-call preferences. + +## Formats + +`mp3_44100_128` by default, which anything will play. `--format pcm_24000` and +friends exist for feeding another tool, and the file extension follows the +format so a PCM request never lands in a `.mp3` that nothing can open. + +Needs an ElevenLabs key: `cli-tools config set elevenlabs`, or +`cli-tools config pull` from the team vault. diff --git a/plugins/tools/commands/config.md b/plugins/tools/commands/config.md index 98154cf..87a7678 100644 --- a/plugins/tools/commands/config.md +++ b/plugins/tools/commands/config.md @@ -35,7 +35,7 @@ failure. **Only the keys these commands read are imported; the rest stay in the vault.** Pulling a whole vault down would leave a second copy of every team secret on the machine, drifting from the vault that is supposed to be the authority. This is a -cache of two or three keys, not a mirror. +cache of the handful of keys these commands read, not a mirror. The decrypted `.env` logicsrc writes lives in a `0700` temp directory for the length of one read and is removed in a `finally`, including on failure. @@ -47,6 +47,11 @@ Keys live in `~/.config/cli-tools/credentials.json`, written `0600` inside a | --- | --- | --- | | `openai` | `OPENAI_API_KEY` | `generate-names` | | `anthropic` | `ANTHROPIC_API_KEY` | `generate-names` | +| `perplexity` | `PERPLEXITY_API_KEY` | `ask-web` | +| `elevenlabs` | `ELEVENLABS_API_KEY` | `tts` | + +A key earns a row by being read by a command here, not by being a key the team +owns. ## Never print a key diff --git a/src/ask-web.ts b/src/ask-web.ts new file mode 100644 index 0000000..dfb6168 --- /dev/null +++ b/src/ask-web.ts @@ -0,0 +1,236 @@ +/** + * Ask a question and get an answer with the sources it came from. + * + * This is the one thing a local model cannot do and a search engine will not: + * read the live web and answer in a paragraph, with the pages it used attached. + * Perplexity's API is the whole implementation — there is no scraping here and + * no ranking of our own. + * + * The sources are the point, not a footnote. An answer whose `[1]` resolves to + * nothing is indistinguishable from an answer that was made up, so the marker + * numbers the model writes inline and the list printed underneath are the same + * numbering, and a citation the model referenced but the API did not return is + * reported rather than silently dropped. + */ + +/** + * `sonar` is the cheap grounded default and answers in a second or two. + * `sonar-pro` searches wider for the same question; the reasoning models think + * first and are worth it only for a question with steps in it. + */ +export const DEFAULT_MODEL = 'sonar'; + +export const MODELS = [ + 'sonar', + 'sonar-pro', + 'sonar-reasoning', + 'sonar-reasoning-pro', + 'sonar-deep-research', +] as const; + +export type Model = (typeof MODELS)[number]; + +/** How far back the search may look. The API accepts only these four. */ +export const RECENCY = ['day', 'week', 'month', 'year'] as const; + +export type Recency = (typeof RECENCY)[number]; + +export interface Source { + /** 1-based, matching the `[n]` markers in the answer text. */ + index: number; + url: string; + title: string | null; + /** Publication date when the API knows one; often null. */ + date: string | null; +} + +export interface Answer { + text: string; + sources: Source[]; + model: string; + /** Markers the answer cites that no source backs — see {@link parseAnswer}. */ + danglingCitations: number[]; +} + +export interface AskOptions { + model?: string; + recency?: Recency; + /** Restrict the search to these hosts. */ + domains?: readonly string[]; + maxTokens?: number; +} + +/** + * The request body. + * + * Kept separate from the HTTP call so the shape can be asserted in a test + * without a network round trip — the filters are the part worth pinning, since + * sending `search_domain_filter: []` is not the same as omitting it: an empty + * array is a filter matching nothing, and the API answers from no sources at + * all rather than from the whole web. + */ +export function buildBody(question: string, options: AskOptions = {}): string { + const body: Record = { + model: options.model ?? DEFAULT_MODEL, + messages: [{ role: 'user', content: question }], + }; + + if (options.maxTokens !== undefined) body['max_tokens'] = options.maxTokens; + if (options.recency) body['search_recency_filter'] = options.recency; + if (options.domains && options.domains.length > 0) { + body['search_domain_filter'] = [...options.domains]; + } + + return JSON.stringify(body); +} + +/** The API speaks plain HTTP; this repo has no runtime dependencies. */ +export type Caller = (body: string) => Promise; + +export function perplexityCaller(apiKey: string, timeoutMs: number): Caller { + return async (body) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch('https://api.perplexity.ai/chat/completions', { + method: 'POST', + signal: controller.signal, + headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' }, + body, + }); + const text = await response.text(); + if (!response.ok) throw new Error(`perplexity ${response.status}: ${describeError(text)}`); + return text; + } finally { + clearTimeout(timer); + } + }; +} + +/** + * Pull the useful sentence out of an error body. + * + * A 401 here arrives as a JSON envelope whose `message` says the key is bad, + * wrapped in enough punctuation that pasting the raw body into a terminal + * buries it. Anything unrecognised is passed through untouched rather than + * summarised away. + */ +export function describeError(body: string): string { + try { + const parsed = JSON.parse(body) as { + error?: { message?: string; type?: string } | string; + detail?: unknown; + }; + if (typeof parsed.error === 'string') return parsed.error; + if (parsed.error?.message) return parsed.error.message; + if (typeof parsed.detail === 'string') return parsed.detail; + } catch { + // Not JSON — an HTML error page from a proxy, most likely. + } + return body.trim().slice(0, 400); +} + +interface RawResponse { + choices?: { message?: { content?: string } }[]; + citations?: string[]; + search_results?: { title?: string; url?: string; date?: string | null }[]; + model?: string; +} + +/** + * Turn the response into an answer and a numbered source list. + * + * Two fields carry the sources and they are not redundant: `citations` is a + * bare URL list whose position *is* the `[n]` the model wrote, while + * `search_results` carries the titles and dates but is not guaranteed to be in + * that order. So the numbering comes from `citations`, and `search_results` is + * joined onto it by URL purely to put a title on the line. When only + * `search_results` comes back, its own order is the numbering — that is the + * best available guess and it is at least stable. + */ +export function parseAnswer(raw: string): Answer { + let parsed: RawResponse; + try { + parsed = JSON.parse(raw) as RawResponse; + } catch (error) { + throw new Error(`perplexity returned non-JSON — ${(error as Error).message}`); + } + + const text = (parsed.choices?.[0]?.message?.content ?? '').trim(); + if (!text) throw new Error('perplexity returned an empty answer'); + + const details = new Map(); + for (const result of parsed.search_results ?? []) { + if (!result.url) continue; + details.set(result.url, { title: result.title?.trim() || null, date: result.date ?? null }); + } + + const urls = + parsed.citations && parsed.citations.length > 0 + ? parsed.citations + : (parsed.search_results ?? []).map((result) => result.url ?? '').filter(Boolean); + + const sources: Source[] = urls.map((url, position) => ({ + index: position + 1, + url, + title: details.get(url)?.title ?? null, + date: details.get(url)?.date ?? null, + })); + + return { + text, + sources, + model: parsed.model ?? DEFAULT_MODEL, + danglingCitations: danglingCitations(text, sources.length), + }; +} + +/** + * Markers in the text with no source behind them. + * + * Worth surfacing rather than ignoring: `[7]` in a paragraph backed by four + * sources is the visible edge of an answer that has drifted from what was + * actually retrieved, and it is the one signal available without re-reading + * every page. + */ +export function danglingCitations(text: string, sourceCount: number): number[] { + const seen = new Set(); + for (const match of text.matchAll(/\[(\d{1,3})\]/g)) { + const n = Number(match[1]); + if (n > sourceCount || n === 0) seen.add(n); + } + return [...seen].sort((a, b) => a - b); +} + +export interface FormatOptions { + /** Leave the source list off — for piping the prose somewhere else. */ + bare?: boolean; +} + +/** + * Render for a terminal. + * + * The answer goes to stdout and nothing else does, so `ask-web … | pbcopy` + * gets prose rather than prose plus a banner. The source list is part of the + * answer, not chrome, so it stays on stdout too; `--bare` is how you ask for + * only the paragraph. + */ +export function formatAnswer(answer: Answer, options: FormatOptions = {}): string { + if (options.bare || answer.sources.length === 0) return `${answer.text}\n`; + + const lines = [answer.text, '', 'Sources:']; + for (const source of answer.sources) { + const label = source.title ? `${source.title} — ` : ''; + const when = source.date ? ` (${source.date})` : ''; + lines.push(` [${source.index}] ${label}${source.url}${when}`); + } + return `${lines.join('\n')}\n`; +} + +export async function askWeb( + question: string, + call: Caller, + options: AskOptions = {}, +): Promise { + return parseAnswer(await call(buildBody(question, options))); +} diff --git a/src/credentials.ts b/src/credentials.ts index fb19c44..ca3ac6e 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -18,10 +18,20 @@ import { dirname, join } from 'node:path'; * each key is coming from rather than only whether one exists. */ -/** Friendly name → the environment variable the tools already read. */ +/** + * Friendly name → the environment variable the tools already read. + * + * The rule for this list is that a key earns its place by being read by a + * command in this repository, not by being a key the team happens to own. The + * team vault has more than twice as many; importing all of them would make this + * file a second, drifting copy of the vault, which is the thing the vault + * exists to avoid. + */ export const KNOWN_KEYS: Record = { openai: 'OPENAI_API_KEY', anthropic: 'ANTHROPIC_API_KEY', + perplexity: 'PERPLEXITY_API_KEY', + elevenlabs: 'ELEVENLABS_API_KEY', }; export type Source = 'env' | 'file' | 'unset'; diff --git a/src/registry.ts b/src/registry.ts index 688eb34..2e24ec7 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -27,6 +27,7 @@ export interface Command { /** One-line summaries, so `cli-tools list` says what each command is for. */ const SUMMARIES: Record = { + 'ask-web': 'Answer a question from the live web, with its sources', 'blog-post': 'Publish to a plain-HTML blog without breaking the feed', 'cli-tools': 'This dispatcher: list, update and wire up the others', domainfree: 'Which of these domains can you actually register', @@ -36,6 +37,7 @@ const SUMMARIES: Record = { 'gh-prs-fix-all': 'Repair the open scan PRs that are broken because of us', 'gh-prs-merge': 'Squash-merge the PRs that are genuinely ready', tcfeed: 'Find repositories worth scanning, scan them, print a shortlist', + tts: 'Read text aloud and keep the audio', }; /** The repository root, found from this file rather than from the cwd. */ diff --git a/src/tts.ts b/src/tts.ts new file mode 100644 index 0000000..6966c6b --- /dev/null +++ b/src/tts.ts @@ -0,0 +1,280 @@ +/** + * Turn text into speech with ElevenLabs. + * + * The API is one POST returning audio bytes, so the interesting code here is + * not the request — it is naming a voice. The account's voices are called + * things like "River - Relaxed, Neutral, Informative", which nobody is going to + * type, and the IDs are opaque 20-character strings that say nothing about who + * they are. So a voice may be given as an ID, a full name, or just the human + * part of it, and an ambiguous name is an error listing the candidates rather + * than a silent pick of the first match. + */ + +/** + * Multilingual v2 rather than v3. + * + * v3 is the better model and is worth asking for by name, but it is also the + * one with the sharp edges — it wants a longer passage to sound right and is + * priced accordingly. A default that costs more and sounds worse on a + * one-sentence test is the wrong default; `--model eleven_v3` is one flag. + */ +export const DEFAULT_MODEL = 'eleven_multilingual_v2'; + +/** A neutral narrator that ships with every account. */ +export const DEFAULT_VOICE = 'River'; + +/** + * MP3 at 128kbps: the only format every plan can request, and the one anything + * will play. The rest are here so `--format` can be validated rather than + * passed through to a 422. + */ +export const DEFAULT_FORMAT = 'mp3_44100_128'; + +export const FORMATS = [ + 'mp3_44100_128', + 'mp3_44100_64', + 'mp3_22050_32', + 'pcm_16000', + 'pcm_22050', + 'pcm_24000', + 'ulaw_8000', +] as const; + +export type Format = (typeof FORMATS)[number]; + +export interface Voice { + id: string; + /** The full label, suffix and all. */ + name: string; + /** The part before the first " - ", which is what people call it. */ + shortName: string; + category: string | null; +} + +const BASE = 'https://api.elevenlabs.io/v1'; + +export function speechUrl(voiceId: string, format: string = DEFAULT_FORMAT): string { + return `${BASE}/text-to-speech/${encodeURIComponent(voiceId)}?output_format=${encodeURIComponent(format)}`; +} + +export function voicesUrl(): string { + return `${BASE}/voices`; +} + +export interface SpeakOptions { + model?: string; + /** 0–1. Higher is more consistent and flatter; the API's own default is 0.5. */ + stability?: number; + /** 0–1. How closely to imitate the original voice. */ + similarity?: number; + /** 0–1. Above 0 the delivery gets theatrical, and slower. */ + style?: number; +} + +/** + * The request body. + * + * `voice_settings` is omitted entirely unless something was asked for. Sending + * a full settings object with our own numbers in it would override whatever the + * voice was tuned with in the dashboard, which is a surprising thing for a CLI + * to do to a shared account. + */ +export function buildBody(text: string, options: SpeakOptions = {}): string { + const body: Record = { + text, + model_id: options.model ?? DEFAULT_MODEL, + }; + + const settings: Record = {}; + if (options.stability !== undefined) settings['stability'] = options.stability; + if (options.similarity !== undefined) settings['similarity_boost'] = options.similarity; + if (options.style !== undefined) settings['style'] = options.style; + if (Object.keys(settings).length > 0) body['voice_settings'] = settings; + + return JSON.stringify(body); +} + +export function parseVoices(raw: string): Voice[] { + let parsed: { voices?: { voice_id?: string; name?: string; category?: string }[] }; + try { + parsed = JSON.parse(raw) as typeof parsed; + } catch (error) { + throw new Error(`elevenlabs returned non-JSON — ${(error as Error).message}`); + } + + return (parsed.voices ?? []) + .filter((voice): voice is { voice_id: string; name?: string; category?: string } => + Boolean(voice.voice_id), + ) + .map((voice) => { + const name = voice.name?.trim() ?? voice.voice_id; + return { + id: voice.voice_id, + name, + shortName: name.split(' - ')[0]!.trim(), + category: voice.category ?? null, + }; + }); +} + +/** + * An ElevenLabs voice ID: 20 characters of base62, no separators. + * + * Checked by shape rather than by looking it up, so `--voice ` for a voice + * that is not in this account still reaches the API and fails with the API's + * own message, instead of us claiming it does not exist. + */ +export function looksLikeVoiceId(value: string): boolean { + return /^[A-Za-z0-9]{20}$/.test(value); +} + +export class VoiceError extends Error {} + +/** + * Resolve what somebody typed to a voice ID. + * + * Exact matches win over prefixes, and case never matters. A prefix that hits + * more than one voice is an error naming all of them: picking one would be a + * coin flip that produces a different narrator tomorrow when the account gains + * a voice, with nothing on screen to explain why. + */ +export function resolveVoice(voices: readonly Voice[], wanted: string): string { + const query = wanted.trim(); + if (looksLikeVoiceId(query)) return query; + + const folded = query.toLowerCase(); + const exact = voices.filter( + (voice) => voice.shortName.toLowerCase() === folded || voice.name.toLowerCase() === folded, + ); + if (exact.length === 1) return exact[0]!.id; + if (exact.length > 1) throw ambiguous(query, exact); + + const prefixed = voices.filter((voice) => voice.shortName.toLowerCase().startsWith(folded)); + if (prefixed.length === 1) return prefixed[0]!.id; + if (prefixed.length > 1) throw ambiguous(query, prefixed); + + throw new VoiceError(`no voice matching "${query}" — run \`tts --voices\` to list them`); +} + +function ambiguous(query: string, matches: readonly Voice[]): VoiceError { + const names = matches.map((voice) => voice.shortName).join(', '); + return new VoiceError(`"${query}" matches ${matches.length} voices: ${names}`); +} + +/** Extension for a format, so `--format pcm_24000` does not write a `.mp3`. */ +export function extensionFor(format: string): string { + if (format.startsWith('mp3')) return 'mp3'; + if (format.startsWith('pcm')) return 'pcm'; + if (format.startsWith('ulaw')) return 'ulaw'; + return 'audio'; +} + +/** + * Where the audio goes when nobody said. + * + * Named from the text itself rather than from a timestamp: a directory of + * `speech-1755794400.mp3` tells you nothing, and the whole point of a + * throwaway synthesis is being able to see which one it was. + */ +export function outputPathFor(text: string, format: string = DEFAULT_FORMAT): string { + const slug = text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .split('-') + .filter(Boolean) + .slice(0, 6) + .join('-'); + + return `${slug || 'speech'}.${extensionFor(format)}`; +} + +/** + * Pull the useful sentence out of an error body. + * + * ElevenLabs nests it as `{detail: {status, message}}`, and the status is the + * half that tells you what to do — `quota_exceeded` and `invalid_api_key` are + * both a 401 otherwise. + */ +export function describeError(body: string): string { + try { + const parsed = JSON.parse(body) as { + detail?: { status?: string; message?: string } | string; + }; + if (typeof parsed.detail === 'string') return parsed.detail; + if (parsed.detail?.message) { + return parsed.detail.status + ? `${parsed.detail.message} (${parsed.detail.status})` + : parsed.detail.message; + } + } catch { + // Not JSON — pass it through below. + } + return body.trim().slice(0, 400); +} + +/** + * A 0–1 knob from the command line. + * + * `integer()` in src/args.ts cannot express these, and the failure without a + * check is quiet: `--stability high` becomes NaN, JSON.stringify turns NaN into + * `null`, and the API accepts the object while ignoring the setting. + */ +export function parseFraction(name: string, raw: string): number { + if (!/^(0|1|0?\.\d+|1\.0+)$/.test(raw.trim())) { + throw new VoiceError(`${name} must be between 0 and 1, got ${JSON.stringify(raw)}`); + } + return Number(raw); +} + +export interface Client { + listVoices(): Promise; + speak(voiceId: string, text: string, options?: SpeakOptions): Promise; +} + +/** The real client. Plain fetch; this repo has no runtime dependencies. */ +export function elevenLabsClient( + apiKey: string, + timeoutMs: number, + format: string = DEFAULT_FORMAT, +): Client { + const request = async (url: string, init: RequestInit): Promise => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + ...init, + signal: controller.signal, + headers: { ...init.headers, 'xi-api-key': apiKey }, + }); + if (!response.ok) { + throw new Error(`elevenlabs ${response.status}: ${describeError(await response.text())}`); + } + return response; + } finally { + clearTimeout(timer); + } + }; + + return { + async listVoices() { + return parseVoices(await (await request(voicesUrl(), { method: 'GET' })).text()); + }, + async speak(voiceId, text, options) { + const response = await request(speechUrl(voiceId, format), { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'audio/mpeg' }, + body: buildBody(text, options), + }); + return new Uint8Array(await response.arrayBuffer()); + }, + }; +} + +/** One line per voice, for `--voices`. */ +export function formatVoices(voices: readonly Voice[]): string { + const width = Math.max(0, ...voices.map((voice) => voice.shortName.length)); + return voices + .map((voice) => `${voice.shortName.padEnd(width)} ${voice.id} ${voice.category ?? ''}`.trimEnd()) + .join('\n'); +} diff --git a/test/ask-web.test.ts b/test/ask-web.test.ts new file mode 100644 index 0000000..05214de --- /dev/null +++ b/test/ask-web.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + DEFAULT_MODEL, + askWeb, + buildBody, + danglingCitations, + describeError, + formatAnswer, + parseAnswer, + perplexityCaller, +} from '../src/ask-web.ts'; + +describe('buildBody', () => { + it('sends the question and the default model', () => { + const body = JSON.parse(buildBody('why is the sky blue')); + expect(body.model).toBe(DEFAULT_MODEL); + expect(body.messages).toEqual([{ role: 'user', content: 'why is the sky blue' }]); + }); + + // An empty search_domain_filter is a filter matching nothing, not an absent + // one: sending it answers the question from no sources at all. + it('omits the domain filter rather than sending an empty one', () => { + expect(JSON.parse(buildBody('q', { domains: [] }))).not.toHaveProperty( + 'search_domain_filter', + ); + expect(JSON.parse(buildBody('q', { domains: ['nodejs.org'] })).search_domain_filter).toEqual([ + 'nodejs.org', + ]); + }); + + it('passes recency and max tokens through only when asked', () => { + expect(JSON.parse(buildBody('q'))).not.toHaveProperty('search_recency_filter'); + const body = JSON.parse(buildBody('q', { recency: 'week', maxTokens: 100 })); + expect(body.search_recency_filter).toBe('week'); + expect(body.max_tokens).toBe(100); + }); +}); + +describe('parseAnswer', () => { + const response = { + model: 'sonar', + choices: [{ message: { content: 'Paris is the capital [1].' } }], + citations: ['https://a.example/paris', 'https://b.example/france'], + search_results: [ + { title: 'France', url: 'https://b.example/france', date: '2020-01-01' }, + { title: 'Paris', url: 'https://a.example/paris', date: null }, + ], + }; + + // citations is positional — its order IS the [n] numbering — while + // search_results carries the titles in whatever order it likes. Numbering + // from search_results would mislabel every source in this response. + it('numbers sources by citations, not by search_results order', () => { + const answer = parseAnswer(JSON.stringify(response)); + expect(answer.sources.map((source) => source.url)).toEqual([ + 'https://a.example/paris', + 'https://b.example/france', + ]); + expect(answer.sources[0]).toMatchObject({ index: 1, title: 'Paris' }); + expect(answer.sources[1]).toMatchObject({ index: 2, title: 'France', date: '2020-01-01' }); + }); + + it('falls back to search_results when there are no citations', () => { + const answer = parseAnswer( + JSON.stringify({ ...response, citations: undefined }), + ); + expect(answer.sources.map((source) => source.index)).toEqual([1, 2]); + expect(answer.sources[0]!.url).toBe('https://b.example/france'); + }); + + it('rejects an empty answer rather than printing nothing', () => { + expect(() => parseAnswer(JSON.stringify({ choices: [{ message: { content: ' ' } }] }))).toThrow( + /empty answer/, + ); + }); + + it('reports non-JSON as such', () => { + expect(() => parseAnswer('502')).toThrow(/non-JSON/); + }); +}); + +describe('danglingCitations', () => { + // A [7] in a paragraph backed by four sources is the visible edge of an + // answer that has drifted from what was retrieved. + it('finds markers with no source behind them', () => { + expect(danglingCitations('a [1] b [7] c [2]', 4)).toEqual([7]); + }); + + it('is quiet when every marker resolves', () => { + expect(danglingCitations('a [1] b [2]', 2)).toEqual([]); + }); +}); + +describe('formatAnswer', () => { + const answer = { + text: 'Paris [1].', + model: 'sonar', + danglingCitations: [], + sources: [{ index: 1, url: 'https://a.example', title: 'Paris', date: null }], + }; + + it('numbers the printed list to match the inline markers', () => { + expect(formatAnswer(answer)).toBe('Paris [1].\n\nSources:\n [1] Paris — https://a.example\n'); + }); + + it('prints prose alone under --bare', () => { + expect(formatAnswer(answer, { bare: true })).toBe('Paris [1].\n'); + }); +}); + +describe('describeError', () => { + it('pulls the message out of the envelope', () => { + expect(describeError('{"error":{"message":"invalid api key","type":"auth"}}')).toBe( + 'invalid api key', + ); + }); + + it('passes a non-JSON body through', () => { + expect(describeError(' Bad Gateway ')).toBe('Bad Gateway'); + }); +}); + +describe('perplexityCaller', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('reports the status and the message on failure', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: false, + status: 401, + text: async () => '{"error":{"message":"bad key"}}', + })), + ); + await expect(perplexityCaller('k', 1000)('{}')).rejects.toThrow('perplexity 401: bad key'); + }); +}); + +describe('askWeb', () => { + it('asks and parses in one step', async () => { + const call = vi.fn(async (_body: string) => + JSON.stringify({ + model: 'sonar', + choices: [{ message: { content: 'yes [1]' } }], + citations: ['https://a.example'], + }), + ); + const answer = await askWeb('is it', call, { recency: 'day' }); + expect(JSON.parse(call.mock.calls[0]![0]).search_recency_filter).toBe('day'); + expect(answer.text).toBe('yes [1]'); + expect(answer.sources).toHaveLength(1); + }); +}); diff --git a/test/tts.test.ts b/test/tts.test.ts new file mode 100644 index 0000000..7cf9d69 --- /dev/null +++ b/test/tts.test.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + DEFAULT_FORMAT, + DEFAULT_MODEL, + type Voice, + VoiceError, + buildBody, + describeError, + elevenLabsClient, + extensionFor, + formatVoices, + looksLikeVoiceId, + outputPathFor, + parseFraction, + parseVoices, + resolveVoice, + speechUrl, +} from '../src/tts.ts'; + +const VOICES: Voice[] = [ + { id: 'SAz9YHcvj6GT2YYXdXww', name: 'River - Relaxed, Neutral', shortName: 'River', category: 'premade' }, + { id: 'JBFqnCBsd6RMkjVDRZzb', name: 'George - Warm Storyteller', shortName: 'George', category: 'premade' }, + { id: 'pqHfZKP75CvOlQylNhV4', name: 'Bill - Wise, Mature', shortName: 'Bill', category: 'premade' }, + { id: 'bIHbv24MWmeRgasZH58o', name: 'Will - Relaxed Optimist', shortName: 'Will', category: 'premade' }, +]; + +describe('parseVoices', () => { + // The account's names carry a description after " - " that nobody is going + // to type, so the part in front of it is what a name has to match. + it('splits the human part off the label', () => { + const voices = parseVoices( + JSON.stringify({ voices: [{ voice_id: 'x', name: 'River - Relaxed, Neutral' }] }), + ); + expect(voices[0]).toMatchObject({ id: 'x', shortName: 'River' }); + }); + + it('drops entries with no id rather than emitting an unusable voice', () => { + expect(parseVoices(JSON.stringify({ voices: [{ name: 'Nobody' }] }))).toEqual([]); + }); +}); + +describe('resolveVoice', () => { + it('takes an ID unchanged', () => { + expect(resolveVoice([], 'SAz9YHcvj6GT2YYXdXww')).toBe('SAz9YHcvj6GT2YYXdXww'); + }); + + it('matches a short name, ignoring case', () => { + expect(resolveVoice(VOICES, 'george')).toBe('JBFqnCBsd6RMkjVDRZzb'); + }); + + it('matches the full label too', () => { + expect(resolveVoice(VOICES, 'River - Relaxed, Neutral')).toBe('SAz9YHcvj6GT2YYXdXww'); + }); + + // "Bill" is also a prefix of nothing else, but "Wil" hits both Will and + // nothing named Wilma — an arbitrary pick would change narrator the day the + // account gains a voice, with nothing on screen to explain it. + it('refuses an ambiguous prefix and names the candidates', () => { + const voices = [...VOICES, { id: 'z', name: 'Willow - Bright', shortName: 'Willow', category: null }]; + expect(() => resolveVoice(voices, 'wil')).toThrow(/matches 2 voices: Will, Willow/); + }); + + it('prefers an exact name over a longer one it prefixes', () => { + const voices = [...VOICES, { id: 'z', name: 'Willow - Bright', shortName: 'Willow', category: null }]; + expect(resolveVoice(voices, 'will')).toBe('bIHbv24MWmeRgasZH58o'); + }); + + it('says how to list them when nothing matches', () => { + expect(() => resolveVoice(VOICES, 'nobody')).toThrow(VoiceError); + expect(() => resolveVoice(VOICES, 'nobody')).toThrow(/tts --voices/); + }); +}); + +describe('looksLikeVoiceId', () => { + it('is 20 characters of base62', () => { + expect(looksLikeVoiceId('SAz9YHcvj6GT2YYXdXww')).toBe(true); + expect(looksLikeVoiceId('River')).toBe(false); + expect(looksLikeVoiceId('SAz9YHcvj6GT2YYXdXw-')).toBe(false); + }); +}); + +describe('buildBody', () => { + it('sends the text and the default model', () => { + const body = JSON.parse(buildBody('hello')); + expect(body).toEqual({ text: 'hello', model_id: DEFAULT_MODEL }); + }); + + // Sending our own numbers would override whatever the voice was tuned with + // in the dashboard, on an account other people share. + it('omits voice_settings entirely when nothing was asked for', () => { + expect(JSON.parse(buildBody('hello'))).not.toHaveProperty('voice_settings'); + expect(JSON.parse(buildBody('hello', { stability: 0.3 })).voice_settings).toEqual({ + stability: 0.3, + }); + }); +}); + +describe('parseFraction', () => { + // Without the check this becomes NaN, JSON.stringify writes null, and the + // API accepts the object while ignoring the setting. + it('rejects anything outside 0-1', () => { + expect(() => parseFraction('--stability', 'high')).toThrow(/between 0 and 1/); + expect(() => parseFraction('--stability', '1.5')).toThrow(/between 0 and 1/); + expect(parseFraction('--stability', '0.4')).toBe(0.4); + expect(parseFraction('--stability', '1')).toBe(1); + }); +}); + +describe('outputPathFor', () => { + it('names the file after the text', () => { + expect(outputPathFor('The deploy finished')).toBe('the-deploy-finished.mp3'); + }); + + it('keeps it short and drops punctuation', () => { + expect(outputPathFor('one two three four five six seven eight')).toBe( + 'one-two-three-four-five-six.mp3', + ); + }); + + it('still produces a name when the text has no letters', () => { + expect(outputPathFor('!!! ???')).toBe('speech.mp3'); + }); + + // A pcm request that wrote a .mp3 would produce a file nothing can play. + it('follows the format', () => { + expect(outputPathFor('hi', 'pcm_24000')).toBe('hi.pcm'); + expect(extensionFor('ulaw_8000')).toBe('ulaw'); + }); +}); + +describe('speechUrl', () => { + it('carries the output format', () => { + expect(speechUrl('abc', DEFAULT_FORMAT)).toBe( + 'https://api.elevenlabs.io/v1/text-to-speech/abc?output_format=mp3_44100_128', + ); + }); +}); + +describe('describeError', () => { + // quota_exceeded and invalid_api_key are both a 401 without the status. + it('keeps the status alongside the message', () => { + expect(describeError('{"detail":{"status":"quota_exceeded","message":"out of credits"}}')).toBe( + 'out of credits (quota_exceeded)', + ); + }); + + it('handles a plain string detail', () => { + expect(describeError('{"detail":"nope"}')).toBe('nope'); + }); +}); + +describe('formatVoices', () => { + it('lines the columns up', () => { + expect(formatVoices(VOICES.slice(0, 2))).toBe( + 'River SAz9YHcvj6GT2YYXdXww premade\nGeorge JBFqnCBsd6RMkjVDRZzb premade', + ); + }); +}); + +describe('elevenLabsClient', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('sends the key as a header and returns the audio bytes', async () => { + const fetchMock = vi.fn(async (_url: string, init: { headers: Record }) => { + void init; + return { + ok: true, + status: 200, + arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer, + }; + }); + vi.stubGlobal('fetch', fetchMock); + + const audio = await elevenLabsClient('secret', 1000).speak('abc', 'hi'); + expect(audio).toEqual(new Uint8Array([1, 2, 3])); + expect(fetchMock.mock.calls[0]![1]!.headers['xi-api-key']).toBe('secret'); + }); + + it('surfaces the API message on failure', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: false, + status: 401, + text: async () => '{"detail":{"status":"invalid_api_key","message":"bad key"}}', + })), + ); + await expect(elevenLabsClient('k', 1000).speak('abc', 'hi')).rejects.toThrow( + 'elevenlabs 401: bad key (invalid_api_key)', + ); + }); +});