@pyai/sdk 0.6.0 → 0.7.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/AGENT_GUIDE.md CHANGED
@@ -10,8 +10,8 @@ Markdown and does not require the marketing website renderer.
10
10
 
11
11
  ## Availability and sources of truth
12
12
 
13
- The CLI is included in `@pyai/sdk` 0.6.0. Install it with
14
- `npm install -g @pyai/sdk@0.6.0`. Browser login supports existing PyAI accounts;
13
+ The CLI is included in `@pyai/sdk` 0.6.1. Install it with
14
+ `npm install -g @pyai/sdk@0.7.0`. Browser login supports existing PyAI accounts;
15
15
  unattended agents should use an authorized environment key. Inspect the
16
16
  installed command schema before acting.
17
17
 
@@ -35,7 +35,7 @@ do not invent an endpoint or present the feature as deployed.
35
35
  Install the published CLI with Node.js 22 or newer recommended:
36
36
 
37
37
  ```bash
38
- npm install -g @pyai/sdk@0.6.0
38
+ npm install -g @pyai/sdk@0.7.0
39
39
  pyai --version
40
40
  pyai schema --json
41
41
  ```
@@ -51,7 +51,7 @@ npm install -g .
51
51
  ```
52
52
 
53
53
  Alternatively install a supplied build with
54
- `npm install -g /path/to/pyai-sdk-0.6.0.tgz`. The compiled executable supports
54
+ `npm install -g /path/to/pyai-sdk-0.6.1.tgz`. The compiled executable supports
55
55
  Node.js 18 or newer. Use `node dist/cli.js` explicitly when another package's
56
56
  `pyai` shadows this executable.
57
57
 
@@ -242,6 +242,7 @@ Treat that file as sensitive; never commit it or paste it into a model prompt.
242
242
 
243
243
  ```bash
244
244
  pyai init voice-project
245
+ pyai init voice-omni --template omni
245
246
  pyai init voice-ts --template typescript
246
247
  pyai init voice-python --template python
247
248
  pyai init voice-project --template agent --dry-run --json
@@ -252,6 +253,8 @@ present. Scaffolding is offline; it creates `PYAI.md` and starter assets without
252
253
  installing packages, minting credentials, or making API calls. Every template includes `README.md`, `PYAI.md`, `.env.example`, and
253
254
  `.gitignore`. The agent template adds `agent.json`, `speech.json`, and
254
255
  `job.json`; TypeScript adds `main.ts` and `package.json`; Python adds `main.py`.
256
+ Omni adds a complete Node 20.19+/22 ESM example with `ws`, one paced audio
257
+ stream, a read-only tool, captured WAVs, and simulated interruption handling.
255
258
  Follow the created README. JSON output includes `directory`, `template`, `files`,
256
259
  `created`, and `next_steps`.
257
260
 
package/CLI.md CHANGED
@@ -6,8 +6,8 @@ scripts and in coding agents with shell access, including Cursor, Claude Code,
6
6
  and Codex. `--json` provides structured results; `--help` and `schema` describe
7
7
  the installed command surface.
8
8
 
9
- **Version:** the expanded CLI is included in `@pyai/sdk` 0.6.0. Install it from
10
- npm with `npm install -g @pyai/sdk@0.6.0`, then run `pyai login` for browser
9
+ **Version:** the expanded CLI is included in `@pyai/sdk` 0.6.1. Install it from
10
+ npm with `npm install -g @pyai/sdk@0.7.0`, then run `pyai login` for browser
11
11
  sign-in or provide `PYAI_API_KEY` for unattended automation.
12
12
 
13
13
  ## Contents
@@ -31,7 +31,7 @@ sign-in or provide `PYAI_API_KEY` for unattended automation.
31
31
  Install the published package (Node.js 22 or newer recommended):
32
32
 
33
33
  ```bash
34
- npm install -g @pyai/sdk@0.6.0
34
+ npm install -g @pyai/sdk@0.7.0
35
35
  pyai --version
36
36
  pyai login
37
37
  pyai speak "Hello from PyAI." -o hello.wav
@@ -56,7 +56,7 @@ also be packaged and installed on another machine:
56
56
  # In sdk/typescript after building
57
57
  npm pack --ignore-scripts
58
58
  # On the destination machine, using the actual tarball path
59
- npm install -g /path/to/pyai-sdk-0.6.0.tgz
59
+ npm install -g /path/to/pyai-sdk-0.6.1.tgz
60
60
  pyai schema --json
61
61
  ```
62
62
 
@@ -436,7 +436,7 @@ The offline schema is authoritative for the installed build.
436
436
  | Hear vocabulary | `vocabulary get`, `set` |
437
437
  | AMD | `amd calls`, `get ID`, `config`, `configure` |
438
438
  | Discovery | `schema`, `schema --openapi`, `recipes [NAME]`, `help` |
439
- | Project setup | `init DIRECTORY --template agent\|typescript\|python` |
439
+ | Project setup | `init DIRECTORY --template agent\|typescript\|python\|omni` |
440
440
  | Generic API | `request METHOD /PATH` |
441
441
  | Diagnostics | `doctor`, `smoke` |
442
442
 
@@ -483,6 +483,7 @@ Create a starter directory without credentials or network calls:
483
483
 
484
484
  ```bash
485
485
  pyai init voice-project
486
+ pyai init voice-omni --template omni
486
487
  pyai init voice-ts --template typescript
487
488
  pyai init voice-python --template python
488
489
  pyai init voice-project --template agent --dry-run -j
@@ -493,6 +494,8 @@ existing parent; scaffolding
493
494
  does not merge into or overwrite an existing project. Every template includes `README.md`, `PYAI.md`, `.env.example`, and
494
495
  `.gitignore`. The agent template adds `agent.json`, `speech.json`, and
495
496
  `job.json`; TypeScript adds `main.ts` and `package.json`; Python adds `main.py`.
497
+ Omni adds a complete Node 20.19+/22 ESM example with `ws`, one paced audio
498
+ stream, a read-only tool, captured WAVs, and simulated interruption handling.
496
499
  `PYAI.md` provides an integration handoff for an engineer or coding agent. Follow the
497
500
  starter's README for its files, runtime prerequisites, and next commands.
498
501
  Scaffolding does not install dependencies, create an account, or make paid API
package/CLI.schema.json CHANGED
@@ -1307,6 +1307,21 @@
1307
1307
  ".env.example",
1308
1308
  ".gitignore"
1309
1309
  ]
1310
+ },
1311
+ {
1312
+ "id": "omni",
1313
+ "description": "Runnable Node 20/22 Omni audio, read tool, capture and simulated interruption",
1314
+ "files": [
1315
+ "README.md",
1316
+ "package.json",
1317
+ "timing.mjs",
1318
+ "main.mjs",
1319
+ "session.mjs",
1320
+ "audio.mjs",
1321
+ ".env.example",
1322
+ ".gitignore",
1323
+ "PYAI.md"
1324
+ ]
1310
1325
  }
1311
1326
  ],
1312
1327
  "output": {
package/README.md CHANGED
@@ -78,21 +78,47 @@ the instant the socket opens and routes server frames to typed callbacks, so you
78
78
  **can't** trip the #1 Omni integration bug (a hand-rolled `{"event":"configure"}`
79
79
  is acked but silently dropped, giving you a connected session with zero turns):
80
80
 
81
- ```ts
82
- // Omni is zero-state: the key's org authorizes the session, nothing to create.
83
- const omni = pyai.omni.connect({
84
- rate: 16000, // 24000 browser · 16000 wideband telephony · 8000 G.711/Twilio
85
- configure: { voice_id: "stock_emma_en_gb", persona: "You are a receptionist." },
86
- onAudio: (chunk) => speaker.write(chunk), // binary agent audio, play it out
87
- onTranscript: (f) => console.log(f.text),
88
- onError: (e) => console.error(e),
89
- });
81
+ <!-- omni-starter:start -->
82
+ Requires SDK 0.7.0 for `--template omni`. The runnable starter uses ESM,
83
+ Node 20.19+ with an explicit `ws` transport, or Node 22+. MCP 0.5.0
84
+ requires Node 22+. The SDK's REST client continues to support Node 18+.
85
+
86
+ ```sh
87
+ npm install @pyai/sdk@0.7.0
88
+ npx pyai init voice-demo --template omni
89
+ cd voice-demo
90
+ npm install
91
+ ```
92
+
93
+ Initialization is offline; installing dependencies and running `main.mjs` are
94
+ separate steps. For an existing checkout of this example, run `npm install`
95
+ in its directory instead.
90
96
 
91
- omni.sendAudio(pcm16Chunk); // stream caller audio continuously (server-side VAD)
92
- omni.sendDtmf("5");
93
- omni.close();
97
+ Inject `PYAI_API_KEY` through your environment, then supply a 24 kHz PCM16 mono
98
+ WAV (at most 20 seconds) saying “Please look up the office opening time.”
99
+
100
+ ```sh
101
+ node main.mjs caller.wav
102
+ # Optional interruption clip, sent while reply audio is queued:
103
+ node main.mjs caller.wav interruption.wav
94
104
  ```
95
105
 
106
+ The generated project contains the complete runnable source, including its
107
+ Node 20 WebSocket import, WAV reader, one paced input stream, read-only tool,
108
+ and bounded capture. It waits for configuration and greeting playback to drain.
109
+ It sends caller PCM or silence in each slot, never overlapping silence timers.
110
+
111
+ Running it consumes Omni and Hear usage under the injected key. It saves a
112
+ private report and WAV files. The report separates received audio from an
113
+ answer recovered by Hear from captured bytes; synthesis text alone cannot pass.
114
+ The playback sink is simulated. Interruption clears that queue; physical speaker
115
+ playback remains a separate test. Capture uses two seconds of quiet after the
116
+ queue drains, because the protocol has no reply-end marker.
117
+
118
+ This section and the CLI project are generated from the same tested example.
119
+ Release readiness: https://pyai.com/agents/bot-release.json
120
+ <!-- omni-starter:end -->
121
+
96
122
  Live `0x02` transcript bodies are plain UTF-8 caller-text deltas, not JSON.
97
123
  `onTranscript` receives the normalized
98
124
  `{ event:"transcript", role:"user", text, final:false, mode:"delta" }` shape.
@@ -108,6 +134,28 @@ Caller transcripts continue to require the `0x02` carrier.
108
134
  `rate` configures caller input. A `rate: 16000` session still receives 24 kHz
109
135
  agent audio; `rate: 8000` receives 8 kHz. Read `hello.audio_out` before playback.
110
136
  Omni has no commit frame—keep streaming silence during caller pauses.
137
+ Use one paced input stream: send caller PCM when available, otherwise silence.
138
+ Pause any separate silence timer while microphone or fixture frames are being
139
+ sent; never interleave extra silence with active input.
140
+
141
+ For a client-executed lookup, declare `side_effect: "read"` in its tool definition:
142
+
143
+ ```js
144
+ const officeHoursTool = {
145
+ name: "lookup_office_hours",
146
+ description: "Read the office opening time.",
147
+ side_effect: "read",
148
+ parameters: { type: "object", properties: {} },
149
+ };
150
+ // Include officeHoursTool in configure.tools. In onToolCall, return the
151
+ // actual lookup result using session.toolResult(frame.call_id, { result }).
152
+ ```
153
+
154
+ An omitted `side_effect` is treated as an action. Action results need a positive
155
+ completion acknowledgement, for example `{ ok: true, receipt_id: actualReceiptId }`,
156
+ returned inside `toolResult`'s `result`. Send that only after the operation has
157
+ completed; a queued request or transport acknowledgement is insufficient.
158
+ On failure, return `{ error: "Operation failed" }` instead of claiming success.
111
159
 
112
160
  From the browser, mint an ephemeral token server-side with
113
161
  `pyai.omni.createSession({ allowedOrigins })` and pass it as `token` so the page
package/dist/cli-dx.js CHANGED
@@ -34,5 +34,5 @@ export const recipes = [
34
34
  notes: ["Local schema and recipes work without credentials. Live OpenAPI needs a network connection but no API key.", "The CLI manages Agent profiles; use the SDK or WebSocket API for a live Omni audio session."] },
35
35
  { name: "ci", title: "Use PyAI in a build", description: "Check access and produce an audio artifact with machine-readable output.",
36
36
  commands: ["pyai whoami --json", 'pyai speak "Your build is ready" -o build.wav --json', "pyai doctor --json"],
37
- notes: ["Set PYAI_API_KEY through the CI secret store; do not put it in source or shell arguments.", "doctor synthesizes and transcribes a sample, which consumes usage. Use whoami for an authentication-only check.", "Install the CLI with npm install -g @pyai/sdk@0.6.0 and pin that version in repeatable builds."] },
37
+ notes: ["Set PYAI_API_KEY through the CI secret store; do not put it in source or shell arguments.", "doctor synthesizes and transcribes a sample, which consumes usage. Use whoami for an authentication-only check.", "Install the CLI with npm install -g @pyai/sdk@0.7.0 and pin that version in repeatable builds."] },
38
38
  ].map(recipe => ({ ...recipe, docs_url: "https://pyai.com/agents/speech-calling.md" }));
@@ -1,4 +1,4 @@
1
- export type CliTemplate = "agent" | "typescript" | "python";
1
+ export type CliTemplate = "agent" | "typescript" | "python" | "omni";
2
2
  export interface CliTemplateManifest {
3
3
  id: CliTemplate;
4
4
  description: string;
package/dist/cli-init.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { constants } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import { basename, dirname, isAbsolute, join, parse, resolve } from "node:path";
4
+ import { omniTemplate } from "./cli-omni-template.js";
4
5
  export class CliInitError extends Error {
5
6
  code;
6
7
  constructor(code, message) {
@@ -127,7 +128,7 @@ const install = `## Install the CLI
127
128
  Install the published CLI with Node.js 22 or newer recommended:
128
129
 
129
130
  \`\`\`bash
130
- npm install -g @pyai/sdk@0.6.0
131
+ npm install -g @pyai/sdk@0.7.0
131
132
  pyai --version
132
133
  \`\`\`
133
134
 
@@ -220,7 +221,7 @@ and product calls below are separate explicit steps. Read \`PYAI.md\` first.
220
221
  Install the official SDK:
221
222
 
222
223
  \`\`\`bash
223
- npm install @pyai/sdk@0.6.0
224
+ npm install @pyai/sdk@0.7.0
224
225
  cp .env.example .env
225
226
  \`\`\`
226
227
 
@@ -321,6 +322,7 @@ Check the live contract before expanding the example.
321
322
 
322
323
  ${install}`;
323
324
  const content = Object.freeze({
325
+ omni: Object.freeze({ ...omniTemplate, "PYAI.md": context }),
324
326
  agent: Object.freeze({ "README.md": agentReadme, "PYAI.md": context, "speech.json": speech, "job.json": job, "agent.json": agent, ".env.example": env, ".gitignore": ignore }),
325
327
  typescript: Object.freeze({ "README.md": tsReadme, "PYAI.md": context, "main.ts": tsMain, "package.json": JSON.stringify({ name: "pyai-starter", version: "0.0.0", private: true, type: "module", engines: { node: ">=22.6.0" }, scripts: { speak: "node --experimental-strip-types --env-file=.env main.ts speak", hear: "node --experimental-strip-types --env-file=.env main.ts hear" } }, null, 2) + "\n", ".env.example": env, ".gitignore": ignore }),
326
328
  python: Object.freeze({ "README.md": pythonReadme, "PYAI.md": context, "main.py": pythonMain, ".env.example": env, ".gitignore": ignore }),
@@ -330,6 +332,7 @@ export const CLI_TEMPLATES = Object.freeze([
330
332
  { id: "agent", description: "API context and JSON requests for coding agents" },
331
333
  { id: "typescript", description: "Runnable Node.js Speak and Hear examples using @pyai/sdk" },
332
334
  { id: "python", description: "Runnable Python Speak and Hear examples using pyai-sdk" },
335
+ { id: "omni", description: "Runnable Node 20/22 Omni audio, read tool, capture and simulated interruption" },
333
336
  ].map((entry) => Object.freeze({ ...entry, files: Object.freeze(Object.keys(content[entry.id])) })));
334
337
  function hasCode(error, code) {
335
338
  return typeof error === "object" && error !== null && "code" in error && error.code === code;
@@ -350,7 +353,7 @@ async function exists(path) {
350
353
  export async function scaffoldProject(options) {
351
354
  const template = options.template ?? "agent";
352
355
  if (!Object.hasOwn(content, template))
353
- throw new CliInitError("invalid_template", "Choose agent, typescript, or python with --template.");
356
+ throw new CliInitError("invalid_template", "Choose agent, typescript, python, or omni with --template.");
354
357
  if (typeof options.directory !== "string" || !options.directory.trim() || /[\p{Cc}]/u.test(options.directory)) {
355
358
  throw new CliInitError("invalid_directory", "Provide a new project directory without control characters.");
356
359
  }
@@ -0,0 +1,10 @@
1
+ export declare const omniTemplate: Readonly<{
2
+ "README.md": "# Runnable Omni starter\n\nRequires Node 20.19+ and ESM. Node 20 uses the included `ws` transport;\nNode 22 also works. The separate MCP server requires Node 22+.\nThis server-side example uses a synthetic read-only office-hours tool. Read\n`PYAI.md` when generating the project through the CLI.\n\n<!-- omni-install:start -->\n```sh\nnpm install @pyai/sdk@0.7.0\nnpx pyai init voice-demo --template omni\ncd voice-demo\nnpm install\n```\n\nInitialization is offline; installing dependencies and running `main.mjs` are\nseparate steps. For an existing checkout of this example, run `npm install`\nin its directory instead.\n<!-- omni-install:end -->\n\nInject `PYAI_API_KEY` through your secret manager or shell environment. Do not\npaste a real key into source, a prompt, or shell history. `.env.example` documents\nthe variable; this program does not load `.env` or saved CLI profiles.\n\nPrepare a 24 kHz PCM16 mono WAV, at most 20 seconds, saying “Please look up the\noffice opening time.” Then run:\n\n```sh\nnode main.mjs caller.wav\n```\n\nRunning the program explicitly opens one bounded Omni session and uploads its\ncaptured reply to Hear. Both consume your key's allowed usage. Automatic retries\nare disabled. No managed number is bought and no phone call is placed.\n\n`session.mjs` waits for configuration and the greeting to drain, sends exactly\none paced input stream (caller PCM or silence), returns the read tool's result,\nand captures output using the rate from `hello.audio_out`. Never run a separate\nsilence timer alongside caller audio. `timing.mjs` is generated from the existing\nOmni evaluation harness's timing utilities.\n\nThe new private `omni-run-*` folder contains `session.wav`, `reply.wav`, and\n`report.json`. The report separates received audio, an office-hours answer\nrecovered by Hear from captured audio, and physical playback (not tested).\nA synthesis transcript is advisory; it is not evidence that speech arrived.\nCapture ends after the simulated queue drains and two seconds of quiet. Omni\nhas no protocol reply-end marker, so this is a bounded capture heuristic.\nListen to the saved file when qualifying actual sound quality or completeness.\n\nTo exercise interruption, supply a second WAV saying “Stop speaking now”:\n\n```sh\nnode main.mjs caller.wav interruption.wav\n```\n\nThe second clip starts while reply audio is queued. `onBargeIn` clears that\nsimulated queue, and capture observes at least five seconds after that clip ends.\nAn interrupted answer can correctly fail the full-answer check;\ninspect the separate interruption evidence. This is not a physical speaker test.\nFor a real playback adapter, cancel both queued and currently playing audio.\nReplace the example lookup with authorized application data before product use.\n";
3
+ "package.json": "{\n \"name\": \"pyai-omni-starter\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"engines\": {\n \"node\": \">=20.19.0\"\n },\n \"scripts\": {\n \"start\": \"node main.mjs\"\n },\n \"dependencies\": {\n \"@pyai/sdk\": \"0.7.0\",\n \"ws\": \"^8.18.3\"\n }\n}\n";
4
+ "timing.mjs": "// Monotonic capture timing. Arrival time is distinct from queued playback:\n// a single packet may contain seconds of audio and must finish before settling.\nexport const FRAME_MS = 20;\nexport const REALTIME_GAP_LIMIT_MS = 100;\n// Interruption tests need an observation window after caller transmission,\n// even when an earlier cue/response already finished. These are test bounds,\n// not an inferred engine reply-end or a naturalness threshold.\nexport const INTERRUPTION_OBSERVATION = Object.freeze({\n minimumAfterCallerStreamMs: 5000, quietMs: 2000, maximumAfterCallerStreamMs: 25000,\n});\n\n/** Approximate audible bounds; this is energy detection, not a speech/VAD claim. */\nexport function audibleBounds(pcm, rate, rmsFloor = 160) {\n const window = Math.max(1, Math.round(rate / 100)); // 10 ms windows\n let first = null;\n let last = null;\n for (let off = 0; off < pcm.length; off += window) {\n const end = Math.min(pcm.length, off + window);\n let energy = 0;\n for (let i = off; i < end; i++) energy += pcm[i] * pcm[i];\n if (Math.sqrt(energy / (end - off)) >= rmsFloor) {\n first ??= off;\n last = end;\n }\n }\n return { first, last };\n}\n\nexport function newAudioCapture(started) {\n return {\n started, firstPacketAt: null, lastPacketAt: null,\n firstAudioAt: null, lastAudioAt: null, playbackEndAt: null,\n samples: 0, pcm: [], turnBeginAt: null, eouMs: null,\n latestTurnBeginAt: null, postTurnBeginFirstPacketAt: null,\n postTurnBeginFirstAudioAt: null, lastAssistantTranscriptAt: null, lastCallerTranscriptAt: null,\n callerTranscriptEvents: [], assistantTranscriptEvents: [],\n turnBegins: [], tools: [], toolResults: [], kb: null, events: [],\n };\n}\n\nexport function recordTurnBegin(ctx, at, turn, origin = 0) {\n ctx.turnBeginAt ??= at;\n ctx.latestTurnBeginAt = at;\n // Earlier PCM can be a listening cue. A new reply is still pending even\n // when that cue has already finished playing; retain all of its PCM.\n ctx.postTurnBeginFirstPacketAt = null;\n ctx.postTurnBeginFirstAudioAt = null;\n ctx.turnBegins.push({ atMs: Math.round(at - origin), turn: turn ?? null });\n}\n\nexport function recordAudio(ctx, pcm, at, rate) {\n if (!(pcm instanceof Int16Array) || !pcm.length) return null;\n const copy = pcm.slice(); // Some transports reuse callback buffers.\n const playbackAt = Math.max(at, ctx.playbackEndAt ?? at);\n const endAt = playbackAt + (copy.length / rate) * 1000;\n const bounds = audibleBounds(copy, rate);\n ctx.firstPacketAt ??= at;\n ctx.lastPacketAt = at;\n ctx.playbackEndAt = endAt;\n const afterTurnBegin = ctx.latestTurnBeginAt != null && at >= ctx.latestTurnBeginAt;\n if (afterTurnBegin) ctx.postTurnBeginFirstPacketAt ??= at;\n if (bounds.first != null) {\n ctx.firstAudioAt ??= playbackAt + (bounds.first / rate) * 1000;\n ctx.lastAudioAt = playbackAt + (bounds.last / rate) * 1000;\n if (afterTurnBegin) ctx.postTurnBeginFirstAudioAt ??= playbackAt + (bounds.first / rate) * 1000;\n }\n ctx.samples += copy.length;\n ctx.pcm.push(copy);\n return { playbackAt, endAt, pcm: copy };\n}\n\n/** Preserve negative gaps: they are overlaps, never missing measurements. */\nexport function captureTiming(ctx, caller, origin = 0) {\n const relative = (at) => at == null ? null : Math.round(at - origin);\n const gap = (at) => at == null || caller.speechOffsetAt == null\n ? null : Math.round(at - caller.speechOffsetAt);\n return {\n ttfbMs: gap(ctx.firstAudioAt),\n anyAudioTtfbMs: gap(ctx.firstAudioAt),\n postTurnBeginTtfbMs: gap(ctx.postTurnBeginFirstAudioAt),\n turnMs: gap(ctx.lastAudioAt),\n packetTtfbMs: gap(ctx.firstPacketAt),\n callerStartMs: relative(caller.startedAt),\n callerSpeechOnsetMs: relative(caller.speechOnsetAt),\n callerSpeechOffsetMs: relative(caller.speechOffsetAt),\n callerStreamEndMs: relative(caller.streamEndAt),\n agentFirstPacketMs: relative(ctx.firstPacketAt),\n agentLastPacketMs: relative(ctx.lastPacketAt),\n agentSpeechOnsetMs: relative(ctx.firstAudioAt),\n agentSpeechOffsetMs: relative(ctx.lastAudioAt),\n agentPlaybackEndMs: relative(ctx.playbackEndAt),\n latestTurnBeginMs: relative(ctx.latestTurnBeginAt),\n postTurnBeginFirstPacketMs: relative(ctx.postTurnBeginFirstPacketAt),\n postTurnBeginSpeechOnsetMs: relative(ctx.postTurnBeginFirstAudioAt),\n lastAssistantTranscriptMs: relative(ctx.lastAssistantTranscriptAt),\n callerMaxFrameGapMs: Math.round(caller.maxFrameGapMs ?? 0),\n method: \"client-monotonic-queued-playback-energy-bounds\",\n };\n}\n\nexport async function streamPcmRealtime(omni, pcm, rate, clock, onFrame = () => {}) {\n const frameSize = Math.max(1, Math.round((rate * FRAME_MS) / 1000));\n const bounds = audibleBounds(pcm, rate);\n const startedAt = clock.now();\n let speechOnsetAt = null;\n let speechOffsetAt = null;\n let previousFrameEnd = startedAt;\n let maxFrameGapMs = 0;\n for (let off = 0; off < pcm.length; off += frameSize) {\n const frame = pcm.subarray(off, Math.min(off + frameSize, pcm.length));\n const at = clock.now();\n maxFrameGapMs = Math.max(maxFrameGapMs, at - previousFrameEnd);\n const durationMs = (frame.length / rate) * 1000;\n omni.sendAudio(frame);\n onFrame(frame, at);\n if (bounds.first != null && bounds.first >= off && bounds.first < off + frame.length) {\n speechOnsetAt = at + ((bounds.first - off) / rate) * 1000;\n }\n if (bounds.last != null && bounds.last > off && bounds.last <= off + frame.length) {\n speechOffsetAt = at + ((bounds.last - off) / rate) * 1000;\n }\n previousFrameEnd = at + durationMs;\n // Pace from the actual send. Never burst late frames to \"catch up\".\n await clock.sleep(Math.max(0, previousFrameEnd - clock.now()));\n }\n return { startedAt, speechOnsetAt, speechOffsetAt,\n streamEndAt: clock.now(), maxFrameGapMs };\n}\n\nexport async function streamSilenceWhile(omni, rate, shouldContinue, clock, onFrame = () => {}) {\n const silence = new Int16Array(Math.max(1, Math.round((rate * FRAME_MS) / 1000)));\n while (shouldContinue()) {\n const at = clock.now();\n omni.sendAudio(silence);\n onFrame(silence, at);\n await clock.sleep(FRAME_MS);\n }\n}\n\nexport async function waitForAgentSettle(getCtx, clock, opts = {}) {\n const started = clock.now();\n const observation = opts.callerStreamEndAt != null;\n if (observation && (!Number.isFinite(opts.callerStreamEndAt)\n || opts.callerStreamEndAt < 0 || opts.callerStreamEndAt > started)) {\n throw new Error(\"Invalid caller observation boundary\");\n }\n const timeoutMs = observation ? INTERRUPTION_OBSERVATION.maximumAfterCallerStreamMs : opts.timeoutMs ?? 25000;\n const settleMs = observation ? INTERRUPTION_OBSERVATION.quietMs : opts.settleMs ?? 2000;\n const deadlineAt = (observation ? opts.callerStreamEndAt : started) + timeoutMs;\n const minimumAt = observation ? opts.callerStreamEndAt + INTERRUPTION_OBSERVATION.minimumAfterCallerStreamMs : -Infinity;\n while (true) {\n const ctx = getCtx();\n const at = clock.now();\n if (opts.isClosed?.()) return { reason: \"closed\", at };\n if (observation && at >= deadlineAt) return { reason: \"timeout\", at };\n const hasResponseAudio = !opts.requireTurnBegin || (ctx.latestTurnBeginAt != null\n && ctx.postTurnBeginFirstAudioAt != null);\n // Synthesis text is progress, not a completion event. It can arrive after\n // early/final audio, so extend the quiet floor without demanding another\n // packet after every advisory. The protocol still has no reply-end marker.\n const activityEnd = Math.max(ctx.playbackEndAt ?? -Infinity,\n ctx.latestTurnBeginAt ?? -Infinity, ctx.lastAssistantTranscriptAt ?? -Infinity,\n observation ? ctx.lastCallerTranscriptAt ?? -Infinity : -Infinity);\n // Do not demand a new post-caller turn_begin: an earlier turn can be\n // coalesced and speak later. This records a bounded quiet observation;\n // downstream response-timing checks can impose a stricter begin boundary.\n if (ctx.firstPacketAt != null && hasResponseAudio && at >= minimumAt && at >= activityEnd + settleMs) {\n return { reason: \"settled\", at };\n }\n if (opts.allowEmpty && ctx.firstPacketAt == null && at >= minimumAt && at - started >= (opts.emptyWaitMs ?? 800)) {\n return { reason: \"empty\", at };\n }\n if (at - started >= timeoutMs) return { reason: \"timeout\", at };\n await clock.sleep(observation ? Math.min(20, deadlineAt - at) : 20);\n }\n}\n\nexport async function withTimeout(promise, ms, message, clock) {\n let timer;\n try {\n return await Promise.race([\n promise,\n new Promise((_, reject) => {\n timer = clock.setTimeout(() => reject(new Error(message)), ms);\n }),\n ]);\n } finally {\n clock.clearTimeout(timer);\n }\n}\n";
5
+ "main.mjs": "import PyAI from \"@pyai/sdk\";\nimport WebSocket from \"ws\"; // Explicit transport makes this work on Node 20 too.\nimport { readFile, writeFile, mkdtemp } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { runSession } from \"./session.mjs\";\nimport { readWav, writeWav, assessAnswer } from \"./audio.mjs\";\n\nasync function main() {\n const [callerPath, interruptionPath, extra] = process.argv.slice(2);\n if (!callerPath || extra) throw new Error(\"Usage: node main.mjs caller.wav [interruption.wav]\");\n if (!process.env.PYAI_API_KEY) throw new Error(\"Inject PYAI_API_KEY through your environment first\");\n const caller = readWav(await readFile(callerPath));\n const interruption = interruptionPath ? readWav(await readFile(interruptionPath)) : undefined;\n const pyai = new PyAI({ apiKey: process.env.PYAI_API_KEY, maxRetries: 0,\n fetch: (url, options) => fetch(url, { ...options, signal: AbortSignal.timeout(30000) }),\n });\n const { report, audio, replyAudio } = await runSession({ pyai, webSocket: WebSocket, caller, interruption });\n const directory = await mkdtemp(join(process.cwd(), \"omni-run-\"));\n const save = (name, data) => writeFile(join(directory, name), data, { mode: 0o600, flag: \"wx\" });\n await save(\"session.wav\", writeWav(audio, report.output_rate ?? 24000));\n const reply = writeWav(replyAudio, report.output_rate ?? 24000);\n await save(\"reply.wav\", reply);\n // Hear the captured bytes, rather than trusting synthesis-advisory text.\n let capturedTranscript = \"\";\n if (report.reply_audio_bytes) {\n try {\n const heard = await pyai.audio.transcriptions.create({ file: new File([reply], \"reply.wav\", { type: \"audio/wav\" }), model: \"pyai-hear\" });\n capturedTranscript = heard.text;\n } catch { report.hear_error = \"Captured audio saved; Hear verification failed\"; }\n }\n const result = { ...report, captured_transcript: capturedTranscript, evidence: assessAnswer(report, capturedTranscript) };\n await save(\"report.json\", JSON.stringify(result, null, 2) + \"\\n\");\n console.log(JSON.stringify({ directory, ...result }, null, 2));\n if (result.evidence.captured_answer !== \"verified_by_hear\" || (interruption && result.evidence.interruption !== \"simulated_queue_cleared\")) process.exitCode = 1;\n}\n\nmain().catch(error => {\n const message = String(error.message).replaceAll(process.env.PYAI_API_KEY || \"\\0\", \"[REDACTED]\");\n console.error(JSON.stringify({ error: message })); process.exitCode = 1;\n});\n";
6
+ "session.mjs": "import { newAudioCapture, recordAudio, recordTurnBegin, streamPcmRealtime, INTERRUPTION_OBSERVATION } from \"./timing.mjs\";\n\nexport const realClock = {\n now: () => performance.now(),\n sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),\n};\n\n/** One bounded, server-side example. Its playback queue is simulated. */\nexport async function runSession({ pyai, webSocket, caller, interruption, clock = realClock, timeoutMs = 60000 }) {\n const rate = 24000, frameSize = 480, silence = new Int16Array(frameSize);\n if (!(caller instanceof Int16Array) || !caller.length || caller.length > rate * 20) throw new Error(\"Provide 1–20 seconds of caller PCM16 at 24 kHz\");\n if (interruption && (!(interruption instanceof Int16Array) || !interruption.length || interruption.length > rate * 20)) throw new Error(\"Invalid interruption PCM\");\n const started = clock.now(), all = newAudioCapture(started), reply = newAudioCapture(started);\n const report = { input_rate: rate, output_rate: null, playback_sink: \"simulated\", tool_calls: 0, tool_executions: 0, duplicate_tool_calls: 0, flushes_after_interruption: 0, cleared_queue_ms: 0, interruption_requested: !!interruption, caller_started_ms: null, interruption_started_ms: null, advisory_transcripts: [], end_reason: \"timeout\" };\n let session, configuredAt = null, closed = false, failure = null, phase = \"greeting\", offset = 0, queueEnd = started, interruptionSent = false, inputEndAt = started;\n const handled = new Set();\n const fail = message => { failure ??= new Error(message); };\n try {\n session = pyai.omni.connect({\n webSocket, rate,\n configure: {\n voice_id: \"stock_amos_en_us\", language: \"en\", greeting: \"Hello. How can I help?\",\n persona: \"When asked about office hours, call lookup_office_hours. Never guess. After the result, say one short sentence giving its opening_time.\",\n tools: [{ name: \"lookup_office_hours\", description: \"Read the synthetic example office opening time.\", side_effect: \"read\", parameters: { type: \"object\", properties: {}, required: [], additionalProperties: false } }],\n },\n onHello: frame => {\n const match = /^pcm16@(8000|24000)$/.exec(frame.audio_out ?? \"\");\n if (!match) return fail(\"Unsupported Omni output format\");\n report.output_rate = Number(match[1]);\n },\n onConfigured: () => { configuredAt ??= clock.now(); },\n onAudio: chunk => {\n if (!report.output_rate) return fail(\"Audio arrived before its output format\");\n const bytes = chunk instanceof ArrayBuffer ? new Uint8Array(chunk) : new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);\n if (bytes.byteLength % 2) return fail(\"Invalid PCM16 output\");\n const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);\n const pcm = Int16Array.from({ length: bytes.length / 2 }, (_, i) => view.getInt16(i * 2, true));\n const now = clock.now();\n recordAudio(all, pcm, now, report.output_rate);\n queueEnd = Math.max(now, queueEnd) + pcm.length / report.output_rate * 1000;\n if (report.tool_executions) recordAudio(reply, pcm, now, report.output_rate);\n },\n onEvent: frame => {\n if (frame.event === \"turn_begin\") recordTurnBegin(all, clock.now(), frame.turn);\n },\n onTranscript: frame => {\n // An assistant advisory is progress; never treat it as received speech.\n if (report.advisory_transcripts.length < 100) report.advisory_transcripts.push({ role: frame.role, text: frame.text });\n if (frame.role === \"assistant\") all.lastAssistantTranscriptAt = clock.now();\n },\n onToolCall: frame => {\n report.tool_calls++;\n if (handled.has(frame.call_id)) { report.duplicate_tool_calls++; return; }\n handled.add(frame.call_id);\n if (frame.name !== \"lookup_office_hours\") { session.toolResult(frame.call_id, { error: \"Unknown example tool\" }); return; }\n // Replace this read-only fixture with your authorized data lookup.\n report.tool_executions++;\n session.toolResult(frame.call_id, { result: { opening_time: \"9 a.m.\", source: \"synthetic example office\" } });\n },\n onBargeIn: () => {\n const now = clock.now(), queued = Math.max(0, queueEnd - now);\n if (report.interruption_started_ms != null) { report.flushes_after_interruption++; report.cleared_queue_ms += queued; }\n // A real speaker adapter must cancel scheduled and currently playing audio.\n queueEnd = now; all.playbackEndAt = now; reply.playbackEndAt = now;\n },\n onError: () => fail(\"Omni reported an error; inspect authorized server diagnostics\"),\n onClose: () => { closed = true; },\n });\n while (clock.now() - started < timeoutMs) {\n if (failure) throw failure;\n if (closed) { report.end_reason = \"closed\"; break; }\n const now = clock.now();\n if (configuredAt == null || !report.output_rate) {\n if (now - started >= 10000) throw new Error(\"Omni connection/configuration timed out\");\n await clock.sleep(20); continue;\n }\n const quietAt = Math.max(queueEnd, all.lastPacketAt ?? now, all.lastAssistantTranscriptAt ?? 0, inputEndAt);\n if (phase === \"greeting\") {\n if (all.firstPacketAt != null && now - configuredAt >= 2000 && now >= quietAt + 800) {\n phase = \"caller\"; report.caller_started_ms = now - started;\n } else if (now - configuredAt > 15000) throw new Error(\"Greeting did not drain within 15 seconds\");\n }\n if (phase === \"reply\" && interruption && !interruptionSent && reply.samples && queueEnd - now >= 200) {\n phase = \"interruption\"; offset = 0; interruptionSent = true; report.interruption_started_ms = now - started;\n }\n const observationEnd = inputEndAt + (interruptionSent ? INTERRUPTION_OBSERVATION.minimumAfterCallerStreamMs : 0);\n if (phase === \"reply\" && reply.samples && now >= quietAt + 2000 && now >= observationEnd && (!interruption || interruptionSent)) {\n report.end_reason = \"quiet_window\"; break;\n }\n // Exactly one producer: each 20 ms slot contains caller PCM OR silence.\n let pcm = silence;\n if (phase === \"caller\" || phase === \"interruption\") {\n const source = phase === \"caller\" ? caller : interruption;\n pcm = source.subarray(offset, offset + frameSize); offset += pcm.length;\n if (offset >= source.length) {\n inputEndAt = now + pcm.length / rate * 1000;\n report[phase === \"caller\" ? \"caller_completed_ms\" : \"interruption_completed_ms\"] = inputEndAt - started;\n phase = \"reply\"; offset = 0;\n }\n }\n await streamPcmRealtime(session, pcm, rate, clock);\n }\n report.audio_bytes = all.samples * 2;\n report.reply_audio_bytes = reply.samples * 2;\n report.elapsed_ms = Math.round(clock.now() - started);\n return { report, audio: all.pcm, replyAudio: reply.pcm };\n } finally { session?.close(); }\n}\n";
7
+ "audio.mjs": "// Small, strict WAV boundary: the wire receives samples, never WAV headers.\nexport function readWav(bytes) {\n const b = Buffer.from(bytes);\n if (b.length < 44 || b.toString(\"ascii\", 0, 4) !== \"RIFF\" || b.toString(\"ascii\", 8, 12) !== \"WAVE\") throw new Error(\"Expected a PCM16 mono WAV file\");\n const end = b.readUInt32LE(4) + 8;\n if (end > b.length) throw new Error(\"Truncated WAV file\");\n let rate, data;\n for (let at = 12; at + 8 <= end;) {\n const name = b.toString(\"ascii\", at, at + 4), size = b.readUInt32LE(at + 4);\n const start = at + 8;\n if (start + size > end) throw new Error(\"Truncated WAV chunk\");\n if (name === \"fmt \") {\n if (size < 16 || b.readUInt16LE(start) !== 1 || b.readUInt16LE(start + 2) !== 1 || b.readUInt16LE(start + 14) !== 16) throw new Error(\"Convert input to PCM16 mono WAV first\");\n rate = b.readUInt32LE(start + 4);\n } else if (name === \"data\") data = b.subarray(start, start + size);\n at = start + size + (size % 2);\n }\n if (rate !== 24000 || !data?.length || data.length % 2 || data.length > rate * 2 * 20) throw new Error(\"Use a non-empty 24 kHz PCM16 mono WAV, at most 20 seconds\");\n return Int16Array.from({ length: data.length / 2 }, (_, i) => data.readInt16LE(i * 2));\n}\n\nexport function writeWav(chunks, rate) {\n const samples = chunks.reduce((n, pcm) => n + pcm.length, 0);\n const b = Buffer.alloc(44 + samples * 2);\n b.write(\"RIFF\", 0); b.writeUInt32LE(b.length - 8, 4); b.write(\"WAVEfmt \", 8);\n b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);\n b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28);\n b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write(\"data\", 36);\n b.writeUInt32LE(samples * 2, 40);\n let at = 44;\n for (const pcm of chunks) for (const value of pcm) { b.writeInt16LE(value, at); at += 2; }\n return b;\n}\n\nexport function assessAnswer(report, capturedTranscript) {\n // This checks this example's fixture, not general speech correctness.\n const text = String(capturedTranscript ?? \"\").toLowerCase();\n const expected = /\\boffice\\b/.test(text) && /\\b(?:nine|9)\\b/.test(text)\n && /\\bopen(?:s|ing)?\\b/.test(text) && /\\b(?:a\\.?\\s*m\\.?|morning)\\b/.test(text);\n return {\n audio_received: report.reply_audio_bytes > 0,\n captured_answer: report.end_reason === \"quiet_window\" && report.tool_executions === 1 && expected ? \"verified_by_hear\" : \"not_verified\",\n capture_boundary: \"bounded_quiet_window_no_protocol_reply_end\",\n physical_playback: \"not_tested\",\n interruption: report.interruption_requested ? (report.flushes_after_interruption > 0 && report.cleared_queue_ms > 0 ? \"simulated_queue_cleared\" : \"not_verified\") : \"not_tested\",\n };\n}\n";
8
+ ".env.example": "PYAI_API_KEY=\n";
9
+ ".gitignore": "node_modules/\n.env\nomni-run-*/\n*.wav\n";
10
+ }>;
@@ -0,0 +1,11 @@
1
+ // Generated by scripts/generate-omni-starter.mjs; edit examples/omni-starter instead.
2
+ export const omniTemplate = Object.freeze({
3
+ "README.md": "# Runnable Omni starter\n\nRequires Node 20.19+ and ESM. Node 20 uses the included `ws` transport;\nNode 22 also works. The separate MCP server requires Node 22+.\nThis server-side example uses a synthetic read-only office-hours tool. Read\n`PYAI.md` when generating the project through the CLI.\n\n<!-- omni-install:start -->\n```sh\nnpm install @pyai/sdk@0.7.0\nnpx pyai init voice-demo --template omni\ncd voice-demo\nnpm install\n```\n\nInitialization is offline; installing dependencies and running `main.mjs` are\nseparate steps. For an existing checkout of this example, run `npm install`\nin its directory instead.\n<!-- omni-install:end -->\n\nInject `PYAI_API_KEY` through your secret manager or shell environment. Do not\npaste a real key into source, a prompt, or shell history. `.env.example` documents\nthe variable; this program does not load `.env` or saved CLI profiles.\n\nPrepare a 24 kHz PCM16 mono WAV, at most 20 seconds, saying “Please look up the\noffice opening time.” Then run:\n\n```sh\nnode main.mjs caller.wav\n```\n\nRunning the program explicitly opens one bounded Omni session and uploads its\ncaptured reply to Hear. Both consume your key's allowed usage. Automatic retries\nare disabled. No managed number is bought and no phone call is placed.\n\n`session.mjs` waits for configuration and the greeting to drain, sends exactly\none paced input stream (caller PCM or silence), returns the read tool's result,\nand captures output using the rate from `hello.audio_out`. Never run a separate\nsilence timer alongside caller audio. `timing.mjs` is generated from the existing\nOmni evaluation harness's timing utilities.\n\nThe new private `omni-run-*` folder contains `session.wav`, `reply.wav`, and\n`report.json`. The report separates received audio, an office-hours answer\nrecovered by Hear from captured audio, and physical playback (not tested).\nA synthesis transcript is advisory; it is not evidence that speech arrived.\nCapture ends after the simulated queue drains and two seconds of quiet. Omni\nhas no protocol reply-end marker, so this is a bounded capture heuristic.\nListen to the saved file when qualifying actual sound quality or completeness.\n\nTo exercise interruption, supply a second WAV saying “Stop speaking now”:\n\n```sh\nnode main.mjs caller.wav interruption.wav\n```\n\nThe second clip starts while reply audio is queued. `onBargeIn` clears that\nsimulated queue, and capture observes at least five seconds after that clip ends.\nAn interrupted answer can correctly fail the full-answer check;\ninspect the separate interruption evidence. This is not a physical speaker test.\nFor a real playback adapter, cancel both queued and currently playing audio.\nReplace the example lookup with authorized application data before product use.\n",
4
+ "package.json": "{\n \"name\": \"pyai-omni-starter\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"engines\": {\n \"node\": \">=20.19.0\"\n },\n \"scripts\": {\n \"start\": \"node main.mjs\"\n },\n \"dependencies\": {\n \"@pyai/sdk\": \"0.7.0\",\n \"ws\": \"^8.18.3\"\n }\n}\n",
5
+ "timing.mjs": "// Monotonic capture timing. Arrival time is distinct from queued playback:\n// a single packet may contain seconds of audio and must finish before settling.\nexport const FRAME_MS = 20;\nexport const REALTIME_GAP_LIMIT_MS = 100;\n// Interruption tests need an observation window after caller transmission,\n// even when an earlier cue/response already finished. These are test bounds,\n// not an inferred engine reply-end or a naturalness threshold.\nexport const INTERRUPTION_OBSERVATION = Object.freeze({\n minimumAfterCallerStreamMs: 5000, quietMs: 2000, maximumAfterCallerStreamMs: 25000,\n});\n\n/** Approximate audible bounds; this is energy detection, not a speech/VAD claim. */\nexport function audibleBounds(pcm, rate, rmsFloor = 160) {\n const window = Math.max(1, Math.round(rate / 100)); // 10 ms windows\n let first = null;\n let last = null;\n for (let off = 0; off < pcm.length; off += window) {\n const end = Math.min(pcm.length, off + window);\n let energy = 0;\n for (let i = off; i < end; i++) energy += pcm[i] * pcm[i];\n if (Math.sqrt(energy / (end - off)) >= rmsFloor) {\n first ??= off;\n last = end;\n }\n }\n return { first, last };\n}\n\nexport function newAudioCapture(started) {\n return {\n started, firstPacketAt: null, lastPacketAt: null,\n firstAudioAt: null, lastAudioAt: null, playbackEndAt: null,\n samples: 0, pcm: [], turnBeginAt: null, eouMs: null,\n latestTurnBeginAt: null, postTurnBeginFirstPacketAt: null,\n postTurnBeginFirstAudioAt: null, lastAssistantTranscriptAt: null, lastCallerTranscriptAt: null,\n callerTranscriptEvents: [], assistantTranscriptEvents: [],\n turnBegins: [], tools: [], toolResults: [], kb: null, events: [],\n };\n}\n\nexport function recordTurnBegin(ctx, at, turn, origin = 0) {\n ctx.turnBeginAt ??= at;\n ctx.latestTurnBeginAt = at;\n // Earlier PCM can be a listening cue. A new reply is still pending even\n // when that cue has already finished playing; retain all of its PCM.\n ctx.postTurnBeginFirstPacketAt = null;\n ctx.postTurnBeginFirstAudioAt = null;\n ctx.turnBegins.push({ atMs: Math.round(at - origin), turn: turn ?? null });\n}\n\nexport function recordAudio(ctx, pcm, at, rate) {\n if (!(pcm instanceof Int16Array) || !pcm.length) return null;\n const copy = pcm.slice(); // Some transports reuse callback buffers.\n const playbackAt = Math.max(at, ctx.playbackEndAt ?? at);\n const endAt = playbackAt + (copy.length / rate) * 1000;\n const bounds = audibleBounds(copy, rate);\n ctx.firstPacketAt ??= at;\n ctx.lastPacketAt = at;\n ctx.playbackEndAt = endAt;\n const afterTurnBegin = ctx.latestTurnBeginAt != null && at >= ctx.latestTurnBeginAt;\n if (afterTurnBegin) ctx.postTurnBeginFirstPacketAt ??= at;\n if (bounds.first != null) {\n ctx.firstAudioAt ??= playbackAt + (bounds.first / rate) * 1000;\n ctx.lastAudioAt = playbackAt + (bounds.last / rate) * 1000;\n if (afterTurnBegin) ctx.postTurnBeginFirstAudioAt ??= playbackAt + (bounds.first / rate) * 1000;\n }\n ctx.samples += copy.length;\n ctx.pcm.push(copy);\n return { playbackAt, endAt, pcm: copy };\n}\n\n/** Preserve negative gaps: they are overlaps, never missing measurements. */\nexport function captureTiming(ctx, caller, origin = 0) {\n const relative = (at) => at == null ? null : Math.round(at - origin);\n const gap = (at) => at == null || caller.speechOffsetAt == null\n ? null : Math.round(at - caller.speechOffsetAt);\n return {\n ttfbMs: gap(ctx.firstAudioAt),\n anyAudioTtfbMs: gap(ctx.firstAudioAt),\n postTurnBeginTtfbMs: gap(ctx.postTurnBeginFirstAudioAt),\n turnMs: gap(ctx.lastAudioAt),\n packetTtfbMs: gap(ctx.firstPacketAt),\n callerStartMs: relative(caller.startedAt),\n callerSpeechOnsetMs: relative(caller.speechOnsetAt),\n callerSpeechOffsetMs: relative(caller.speechOffsetAt),\n callerStreamEndMs: relative(caller.streamEndAt),\n agentFirstPacketMs: relative(ctx.firstPacketAt),\n agentLastPacketMs: relative(ctx.lastPacketAt),\n agentSpeechOnsetMs: relative(ctx.firstAudioAt),\n agentSpeechOffsetMs: relative(ctx.lastAudioAt),\n agentPlaybackEndMs: relative(ctx.playbackEndAt),\n latestTurnBeginMs: relative(ctx.latestTurnBeginAt),\n postTurnBeginFirstPacketMs: relative(ctx.postTurnBeginFirstPacketAt),\n postTurnBeginSpeechOnsetMs: relative(ctx.postTurnBeginFirstAudioAt),\n lastAssistantTranscriptMs: relative(ctx.lastAssistantTranscriptAt),\n callerMaxFrameGapMs: Math.round(caller.maxFrameGapMs ?? 0),\n method: \"client-monotonic-queued-playback-energy-bounds\",\n };\n}\n\nexport async function streamPcmRealtime(omni, pcm, rate, clock, onFrame = () => {}) {\n const frameSize = Math.max(1, Math.round((rate * FRAME_MS) / 1000));\n const bounds = audibleBounds(pcm, rate);\n const startedAt = clock.now();\n let speechOnsetAt = null;\n let speechOffsetAt = null;\n let previousFrameEnd = startedAt;\n let maxFrameGapMs = 0;\n for (let off = 0; off < pcm.length; off += frameSize) {\n const frame = pcm.subarray(off, Math.min(off + frameSize, pcm.length));\n const at = clock.now();\n maxFrameGapMs = Math.max(maxFrameGapMs, at - previousFrameEnd);\n const durationMs = (frame.length / rate) * 1000;\n omni.sendAudio(frame);\n onFrame(frame, at);\n if (bounds.first != null && bounds.first >= off && bounds.first < off + frame.length) {\n speechOnsetAt = at + ((bounds.first - off) / rate) * 1000;\n }\n if (bounds.last != null && bounds.last > off && bounds.last <= off + frame.length) {\n speechOffsetAt = at + ((bounds.last - off) / rate) * 1000;\n }\n previousFrameEnd = at + durationMs;\n // Pace from the actual send. Never burst late frames to \"catch up\".\n await clock.sleep(Math.max(0, previousFrameEnd - clock.now()));\n }\n return { startedAt, speechOnsetAt, speechOffsetAt,\n streamEndAt: clock.now(), maxFrameGapMs };\n}\n\nexport async function streamSilenceWhile(omni, rate, shouldContinue, clock, onFrame = () => {}) {\n const silence = new Int16Array(Math.max(1, Math.round((rate * FRAME_MS) / 1000)));\n while (shouldContinue()) {\n const at = clock.now();\n omni.sendAudio(silence);\n onFrame(silence, at);\n await clock.sleep(FRAME_MS);\n }\n}\n\nexport async function waitForAgentSettle(getCtx, clock, opts = {}) {\n const started = clock.now();\n const observation = opts.callerStreamEndAt != null;\n if (observation && (!Number.isFinite(opts.callerStreamEndAt)\n || opts.callerStreamEndAt < 0 || opts.callerStreamEndAt > started)) {\n throw new Error(\"Invalid caller observation boundary\");\n }\n const timeoutMs = observation ? INTERRUPTION_OBSERVATION.maximumAfterCallerStreamMs : opts.timeoutMs ?? 25000;\n const settleMs = observation ? INTERRUPTION_OBSERVATION.quietMs : opts.settleMs ?? 2000;\n const deadlineAt = (observation ? opts.callerStreamEndAt : started) + timeoutMs;\n const minimumAt = observation ? opts.callerStreamEndAt + INTERRUPTION_OBSERVATION.minimumAfterCallerStreamMs : -Infinity;\n while (true) {\n const ctx = getCtx();\n const at = clock.now();\n if (opts.isClosed?.()) return { reason: \"closed\", at };\n if (observation && at >= deadlineAt) return { reason: \"timeout\", at };\n const hasResponseAudio = !opts.requireTurnBegin || (ctx.latestTurnBeginAt != null\n && ctx.postTurnBeginFirstAudioAt != null);\n // Synthesis text is progress, not a completion event. It can arrive after\n // early/final audio, so extend the quiet floor without demanding another\n // packet after every advisory. The protocol still has no reply-end marker.\n const activityEnd = Math.max(ctx.playbackEndAt ?? -Infinity,\n ctx.latestTurnBeginAt ?? -Infinity, ctx.lastAssistantTranscriptAt ?? -Infinity,\n observation ? ctx.lastCallerTranscriptAt ?? -Infinity : -Infinity);\n // Do not demand a new post-caller turn_begin: an earlier turn can be\n // coalesced and speak later. This records a bounded quiet observation;\n // downstream response-timing checks can impose a stricter begin boundary.\n if (ctx.firstPacketAt != null && hasResponseAudio && at >= minimumAt && at >= activityEnd + settleMs) {\n return { reason: \"settled\", at };\n }\n if (opts.allowEmpty && ctx.firstPacketAt == null && at >= minimumAt && at - started >= (opts.emptyWaitMs ?? 800)) {\n return { reason: \"empty\", at };\n }\n if (at - started >= timeoutMs) return { reason: \"timeout\", at };\n await clock.sleep(observation ? Math.min(20, deadlineAt - at) : 20);\n }\n}\n\nexport async function withTimeout(promise, ms, message, clock) {\n let timer;\n try {\n return await Promise.race([\n promise,\n new Promise((_, reject) => {\n timer = clock.setTimeout(() => reject(new Error(message)), ms);\n }),\n ]);\n } finally {\n clock.clearTimeout(timer);\n }\n}\n",
6
+ "main.mjs": "import PyAI from \"@pyai/sdk\";\nimport WebSocket from \"ws\"; // Explicit transport makes this work on Node 20 too.\nimport { readFile, writeFile, mkdtemp } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { runSession } from \"./session.mjs\";\nimport { readWav, writeWav, assessAnswer } from \"./audio.mjs\";\n\nasync function main() {\n const [callerPath, interruptionPath, extra] = process.argv.slice(2);\n if (!callerPath || extra) throw new Error(\"Usage: node main.mjs caller.wav [interruption.wav]\");\n if (!process.env.PYAI_API_KEY) throw new Error(\"Inject PYAI_API_KEY through your environment first\");\n const caller = readWav(await readFile(callerPath));\n const interruption = interruptionPath ? readWav(await readFile(interruptionPath)) : undefined;\n const pyai = new PyAI({ apiKey: process.env.PYAI_API_KEY, maxRetries: 0,\n fetch: (url, options) => fetch(url, { ...options, signal: AbortSignal.timeout(30000) }),\n });\n const { report, audio, replyAudio } = await runSession({ pyai, webSocket: WebSocket, caller, interruption });\n const directory = await mkdtemp(join(process.cwd(), \"omni-run-\"));\n const save = (name, data) => writeFile(join(directory, name), data, { mode: 0o600, flag: \"wx\" });\n await save(\"session.wav\", writeWav(audio, report.output_rate ?? 24000));\n const reply = writeWav(replyAudio, report.output_rate ?? 24000);\n await save(\"reply.wav\", reply);\n // Hear the captured bytes, rather than trusting synthesis-advisory text.\n let capturedTranscript = \"\";\n if (report.reply_audio_bytes) {\n try {\n const heard = await pyai.audio.transcriptions.create({ file: new File([reply], \"reply.wav\", { type: \"audio/wav\" }), model: \"pyai-hear\" });\n capturedTranscript = heard.text;\n } catch { report.hear_error = \"Captured audio saved; Hear verification failed\"; }\n }\n const result = { ...report, captured_transcript: capturedTranscript, evidence: assessAnswer(report, capturedTranscript) };\n await save(\"report.json\", JSON.stringify(result, null, 2) + \"\\n\");\n console.log(JSON.stringify({ directory, ...result }, null, 2));\n if (result.evidence.captured_answer !== \"verified_by_hear\" || (interruption && result.evidence.interruption !== \"simulated_queue_cleared\")) process.exitCode = 1;\n}\n\nmain().catch(error => {\n const message = String(error.message).replaceAll(process.env.PYAI_API_KEY || \"\\0\", \"[REDACTED]\");\n console.error(JSON.stringify({ error: message })); process.exitCode = 1;\n});\n",
7
+ "session.mjs": "import { newAudioCapture, recordAudio, recordTurnBegin, streamPcmRealtime, INTERRUPTION_OBSERVATION } from \"./timing.mjs\";\n\nexport const realClock = {\n now: () => performance.now(),\n sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),\n};\n\n/** One bounded, server-side example. Its playback queue is simulated. */\nexport async function runSession({ pyai, webSocket, caller, interruption, clock = realClock, timeoutMs = 60000 }) {\n const rate = 24000, frameSize = 480, silence = new Int16Array(frameSize);\n if (!(caller instanceof Int16Array) || !caller.length || caller.length > rate * 20) throw new Error(\"Provide 1–20 seconds of caller PCM16 at 24 kHz\");\n if (interruption && (!(interruption instanceof Int16Array) || !interruption.length || interruption.length > rate * 20)) throw new Error(\"Invalid interruption PCM\");\n const started = clock.now(), all = newAudioCapture(started), reply = newAudioCapture(started);\n const report = { input_rate: rate, output_rate: null, playback_sink: \"simulated\", tool_calls: 0, tool_executions: 0, duplicate_tool_calls: 0, flushes_after_interruption: 0, cleared_queue_ms: 0, interruption_requested: !!interruption, caller_started_ms: null, interruption_started_ms: null, advisory_transcripts: [], end_reason: \"timeout\" };\n let session, configuredAt = null, closed = false, failure = null, phase = \"greeting\", offset = 0, queueEnd = started, interruptionSent = false, inputEndAt = started;\n const handled = new Set();\n const fail = message => { failure ??= new Error(message); };\n try {\n session = pyai.omni.connect({\n webSocket, rate,\n configure: {\n voice_id: \"stock_amos_en_us\", language: \"en\", greeting: \"Hello. How can I help?\",\n persona: \"When asked about office hours, call lookup_office_hours. Never guess. After the result, say one short sentence giving its opening_time.\",\n tools: [{ name: \"lookup_office_hours\", description: \"Read the synthetic example office opening time.\", side_effect: \"read\", parameters: { type: \"object\", properties: {}, required: [], additionalProperties: false } }],\n },\n onHello: frame => {\n const match = /^pcm16@(8000|24000)$/.exec(frame.audio_out ?? \"\");\n if (!match) return fail(\"Unsupported Omni output format\");\n report.output_rate = Number(match[1]);\n },\n onConfigured: () => { configuredAt ??= clock.now(); },\n onAudio: chunk => {\n if (!report.output_rate) return fail(\"Audio arrived before its output format\");\n const bytes = chunk instanceof ArrayBuffer ? new Uint8Array(chunk) : new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);\n if (bytes.byteLength % 2) return fail(\"Invalid PCM16 output\");\n const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);\n const pcm = Int16Array.from({ length: bytes.length / 2 }, (_, i) => view.getInt16(i * 2, true));\n const now = clock.now();\n recordAudio(all, pcm, now, report.output_rate);\n queueEnd = Math.max(now, queueEnd) + pcm.length / report.output_rate * 1000;\n if (report.tool_executions) recordAudio(reply, pcm, now, report.output_rate);\n },\n onEvent: frame => {\n if (frame.event === \"turn_begin\") recordTurnBegin(all, clock.now(), frame.turn);\n },\n onTranscript: frame => {\n // An assistant advisory is progress; never treat it as received speech.\n if (report.advisory_transcripts.length < 100) report.advisory_transcripts.push({ role: frame.role, text: frame.text });\n if (frame.role === \"assistant\") all.lastAssistantTranscriptAt = clock.now();\n },\n onToolCall: frame => {\n report.tool_calls++;\n if (handled.has(frame.call_id)) { report.duplicate_tool_calls++; return; }\n handled.add(frame.call_id);\n if (frame.name !== \"lookup_office_hours\") { session.toolResult(frame.call_id, { error: \"Unknown example tool\" }); return; }\n // Replace this read-only fixture with your authorized data lookup.\n report.tool_executions++;\n session.toolResult(frame.call_id, { result: { opening_time: \"9 a.m.\", source: \"synthetic example office\" } });\n },\n onBargeIn: () => {\n const now = clock.now(), queued = Math.max(0, queueEnd - now);\n if (report.interruption_started_ms != null) { report.flushes_after_interruption++; report.cleared_queue_ms += queued; }\n // A real speaker adapter must cancel scheduled and currently playing audio.\n queueEnd = now; all.playbackEndAt = now; reply.playbackEndAt = now;\n },\n onError: () => fail(\"Omni reported an error; inspect authorized server diagnostics\"),\n onClose: () => { closed = true; },\n });\n while (clock.now() - started < timeoutMs) {\n if (failure) throw failure;\n if (closed) { report.end_reason = \"closed\"; break; }\n const now = clock.now();\n if (configuredAt == null || !report.output_rate) {\n if (now - started >= 10000) throw new Error(\"Omni connection/configuration timed out\");\n await clock.sleep(20); continue;\n }\n const quietAt = Math.max(queueEnd, all.lastPacketAt ?? now, all.lastAssistantTranscriptAt ?? 0, inputEndAt);\n if (phase === \"greeting\") {\n if (all.firstPacketAt != null && now - configuredAt >= 2000 && now >= quietAt + 800) {\n phase = \"caller\"; report.caller_started_ms = now - started;\n } else if (now - configuredAt > 15000) throw new Error(\"Greeting did not drain within 15 seconds\");\n }\n if (phase === \"reply\" && interruption && !interruptionSent && reply.samples && queueEnd - now >= 200) {\n phase = \"interruption\"; offset = 0; interruptionSent = true; report.interruption_started_ms = now - started;\n }\n const observationEnd = inputEndAt + (interruptionSent ? INTERRUPTION_OBSERVATION.minimumAfterCallerStreamMs : 0);\n if (phase === \"reply\" && reply.samples && now >= quietAt + 2000 && now >= observationEnd && (!interruption || interruptionSent)) {\n report.end_reason = \"quiet_window\"; break;\n }\n // Exactly one producer: each 20 ms slot contains caller PCM OR silence.\n let pcm = silence;\n if (phase === \"caller\" || phase === \"interruption\") {\n const source = phase === \"caller\" ? caller : interruption;\n pcm = source.subarray(offset, offset + frameSize); offset += pcm.length;\n if (offset >= source.length) {\n inputEndAt = now + pcm.length / rate * 1000;\n report[phase === \"caller\" ? \"caller_completed_ms\" : \"interruption_completed_ms\"] = inputEndAt - started;\n phase = \"reply\"; offset = 0;\n }\n }\n await streamPcmRealtime(session, pcm, rate, clock);\n }\n report.audio_bytes = all.samples * 2;\n report.reply_audio_bytes = reply.samples * 2;\n report.elapsed_ms = Math.round(clock.now() - started);\n return { report, audio: all.pcm, replyAudio: reply.pcm };\n } finally { session?.close(); }\n}\n",
8
+ "audio.mjs": "// Small, strict WAV boundary: the wire receives samples, never WAV headers.\nexport function readWav(bytes) {\n const b = Buffer.from(bytes);\n if (b.length < 44 || b.toString(\"ascii\", 0, 4) !== \"RIFF\" || b.toString(\"ascii\", 8, 12) !== \"WAVE\") throw new Error(\"Expected a PCM16 mono WAV file\");\n const end = b.readUInt32LE(4) + 8;\n if (end > b.length) throw new Error(\"Truncated WAV file\");\n let rate, data;\n for (let at = 12; at + 8 <= end;) {\n const name = b.toString(\"ascii\", at, at + 4), size = b.readUInt32LE(at + 4);\n const start = at + 8;\n if (start + size > end) throw new Error(\"Truncated WAV chunk\");\n if (name === \"fmt \") {\n if (size < 16 || b.readUInt16LE(start) !== 1 || b.readUInt16LE(start + 2) !== 1 || b.readUInt16LE(start + 14) !== 16) throw new Error(\"Convert input to PCM16 mono WAV first\");\n rate = b.readUInt32LE(start + 4);\n } else if (name === \"data\") data = b.subarray(start, start + size);\n at = start + size + (size % 2);\n }\n if (rate !== 24000 || !data?.length || data.length % 2 || data.length > rate * 2 * 20) throw new Error(\"Use a non-empty 24 kHz PCM16 mono WAV, at most 20 seconds\");\n return Int16Array.from({ length: data.length / 2 }, (_, i) => data.readInt16LE(i * 2));\n}\n\nexport function writeWav(chunks, rate) {\n const samples = chunks.reduce((n, pcm) => n + pcm.length, 0);\n const b = Buffer.alloc(44 + samples * 2);\n b.write(\"RIFF\", 0); b.writeUInt32LE(b.length - 8, 4); b.write(\"WAVEfmt \", 8);\n b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);\n b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28);\n b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write(\"data\", 36);\n b.writeUInt32LE(samples * 2, 40);\n let at = 44;\n for (const pcm of chunks) for (const value of pcm) { b.writeInt16LE(value, at); at += 2; }\n return b;\n}\n\nexport function assessAnswer(report, capturedTranscript) {\n // This checks this example's fixture, not general speech correctness.\n const text = String(capturedTranscript ?? \"\").toLowerCase();\n const expected = /\\boffice\\b/.test(text) && /\\b(?:nine|9)\\b/.test(text)\n && /\\bopen(?:s|ing)?\\b/.test(text) && /\\b(?:a\\.?\\s*m\\.?|morning)\\b/.test(text);\n return {\n audio_received: report.reply_audio_bytes > 0,\n captured_answer: report.end_reason === \"quiet_window\" && report.tool_executions === 1 && expected ? \"verified_by_hear\" : \"not_verified\",\n capture_boundary: \"bounded_quiet_window_no_protocol_reply_end\",\n physical_playback: \"not_tested\",\n interruption: report.interruption_requested ? (report.flushes_after_interruption > 0 && report.cleared_queue_ms > 0 ? \"simulated_queue_cleared\" : \"not_verified\") : \"not_tested\",\n };\n}\n",
9
+ ".env.example": "PYAI_API_KEY=\n",
10
+ ".gitignore": "node_modules/\n.env\nomni-run-*/\n*.wav\n"
11
+ });
package/dist/cli.js CHANGED
@@ -405,7 +405,7 @@ async function main() {
405
405
  if (flags.json)
406
406
  output({ name: "pyai", globals, aliases, short_flags: shortFlags, commands: visible }, flags, undefined, false);
407
407
  else if (!spec && !group)
408
- process.stdout.write(`PyAI CLI — speech, agents, and automation\n\nStart here\n pyai login Sign in through your browser\n pyai speak "Hello from PyAI" -o hello.wav Turn text into audio\n pyai hear call.wav --text-only Print a transcript\n pyai dub call.wav --from en --to hi -o hi.wav\n Submit, wait, and save dubbed audio\n pyai init voice-demo --template typescript Create a starter project offline\n\nDiscover\n pyai recipes [name] Copyable workflows\n pyai help <command> Command options and examples\n pyai help all Every command\n pyai schema [command] -j Machine-readable command definitions\n\nGroups\n auth profiles models voices agents jobs clones design\n cast dub recap trace tools vocabulary amd numbers calls omni\n\nShortcuts: login, logout, whoami, use, say (speak), hear (transcribe)\nOptions: -o FILE -f FILE -t TEXT -p PROFILE -j (JSON)\nGlobal: --base-url URL --api-key KEY --timeout SECONDS --retries N --dry-run\nCredentials: pyai login or PYAI_API_KEY. Run pyai doctor for a usage-consuming diagnostic.\n`);
408
+ process.stdout.write(`PyAI CLI — speech, agents, and automation\n\nStart here\n pyai login Sign in through your browser\n pyai speak "Hello from PyAI" -o hello.wav Turn text into audio\n pyai hear call.wav --text-only Print a transcript\n pyai dub call.wav --from en --to hi -o hi.wav\n Submit, wait, and save dubbed audio\n pyai init voice-demo --template omni Create a runnable Omni starter offline\n\nCalling\n pyai numbers list List owned phone numbers\n pyai numbers search --country US Find available numbers\n pyai calls list Inspect outbound calls\n pyai recipes calling --json Setup and preview examples\n pyai calls create --data @call.json --idempotency-key REQUEST_KEY --dry-run\n Preview a call without submitting it\n --dry-run previews a change without sending the request.\n Number purchases and outbound calls require --confirm and --idempotency-key.\n Use --confirm only after that purchase or call is authorized.\n\nDiscover\n pyai recipes [name] Copyable workflows\n pyai help <command> Command options and examples\n pyai help all Every command\n pyai schema [command] -j Machine-readable command definitions\n\nGroups\n auth profiles models voices agents jobs clones design\n cast dub recap trace tools vocabulary amd numbers calls omni\n\nShortcuts: login, logout, whoami, use, say (speak), hear (transcribe)\nOptions: -o FILE -f FILE -t TEXT -p PROFILE -j (JSON)\nGlobal: --base-url URL --api-key KEY --timeout SECONDS --retries N --dry-run\nCredentials: pyai login or PYAI_API_KEY. Run pyai doctor for a usage-consuming diagnostic.\n`);
409
409
  else
410
410
  process.stdout.write(`PyAI CLI — speech, agents, and automation\n\n${visible.map(c => ` pyai ${c.command}${(c.args ?? []).map(a => c.optionalArgs ? ` [${a}${c.variadic ? "..." : ""}]` : ` <${a}>`).join("")}\n ${c.description}${c.flags ? `\n Options: ${Object.keys(c.flags).map(k => `--${k}${c.flags[k] === "boolean" ? "" : " VALUE"}`).join(" ")}` : ""}${c.example ? `\n ${c.example}` : ""}`).join("\n")}\n\nGlobal: --profile NAME --json --timeout SECONDS --retries N --dry-run\n --base-url URL --api-key KEY --help --version\nShort flags: -o FILE -f FILE -t TEXT -p PROFILE -j (JSON)\nAuth: pyai login or PYAI_API_KEY. Discover: pyai schema --json\n`);
411
411
  return;
package/dist/index.d.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Thin, dependency-free wrapper over the public OpenAI-compatible surface at
5
5
  * https://api.pyai.com (contract: https://api.pyai.com/openapi.json). Runs in
6
- * the browser and Node 22+ (uses global fetch + WebSocket). Keys are opaque, * never parsed.
6
+ * the browser and Node 18+ for REST. Realtime needs an injected WebSocket
7
+ * on Node 20; Node 22+ provides a global WebSocket. Keys are opaque, never parsed.
7
8
  */
8
9
  export interface PyAIOptions {
9
10
  /** A pyai_live_ or pyai_test_ key. */
@@ -594,6 +595,9 @@ export interface OmniToolDef {
594
595
  name: string;
595
596
  description?: string;
596
597
  parameters?: Record<string, unknown>;
598
+ /** Declare lookups as read-only. Omitted tools are treated as actions and
599
+ * require a positive completion acknowledgement in their result. */
600
+ side_effect?: "read" | "action";
597
601
  }
598
602
  export interface OmniToolCallFrame {
599
603
  event: "tool_call";
package/dist/index.js CHANGED
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Thin, dependency-free wrapper over the public OpenAI-compatible surface at
5
5
  * https://api.pyai.com (contract: https://api.pyai.com/openapi.json). Runs in
6
- * the browser and Node 22+ (uses global fetch + WebSocket). Keys are opaque, * never parsed.
6
+ * the browser and Node 18+ for REST. Realtime needs an injected WebSocket
7
+ * on Node 20; Node 22+ provides a global WebSocket. Keys are opaque, never parsed.
7
8
  */
8
9
  /** Stable, machine-readable error. Branch on `code`, not `message`. */
9
10
  export class PyAIError extends Error {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyai/sdk",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Official TypeScript/JavaScript SDK for PyAI, speech-to-text (Hear), text-to-speech (Speak), realtime voice agents (Omni), and call compliance (Trace).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,8 +31,8 @@
31
31
  "bugs": "https://github.com/atomsai/pyai-platform-backend/issues",
32
32
  "author": "PyAI",
33
33
  "scripts": {
34
- "gen:cli-assets": "node scripts/generate-cli-assets.mjs",
35
- "check:cli-assets": "node scripts/generate-cli-assets.mjs --check",
34
+ "gen:cli-assets": "node ../../scripts/generate-omni-starter.mjs && node scripts/generate-cli-assets.mjs",
35
+ "check:cli-assets": "node ../../scripts/generate-omni-starter.mjs --check && node scripts/generate-cli-assets.mjs --check",
36
36
  "build": "tsc -p tsconfig.build.json",
37
37
  "test": "node --test test/**/*.test.ts",
38
38
  "typecheck": "tsc --noEmit"
package/src/cli-dx.ts CHANGED
@@ -35,5 +35,5 @@ export const recipes: Recipe[] = [
35
35
  notes: ["Local schema and recipes work without credentials. Live OpenAPI needs a network connection but no API key.", "The CLI manages Agent profiles; use the SDK or WebSocket API for a live Omni audio session."] },
36
36
  { name: "ci", title: "Use PyAI in a build", description: "Check access and produce an audio artifact with machine-readable output.",
37
37
  commands: ["pyai whoami --json", 'pyai speak "Your build is ready" -o build.wav --json', "pyai doctor --json"],
38
- notes: ["Set PYAI_API_KEY through the CI secret store; do not put it in source or shell arguments.", "doctor synthesizes and transcribes a sample, which consumes usage. Use whoami for an authentication-only check.", "Install the CLI with npm install -g @pyai/sdk@0.6.0 and pin that version in repeatable builds."] },
38
+ notes: ["Set PYAI_API_KEY through the CI secret store; do not put it in source or shell arguments.", "doctor synthesizes and transcribes a sample, which consumes usage. Use whoami for an authentication-only check.", "Install the CLI with npm install -g @pyai/sdk@0.7.0 and pin that version in repeatable builds."] },
39
39
  ].map(recipe => ({ ...recipe, docs_url: "https://pyai.com/agents/speech-calling.md" }));
package/src/cli-init.ts CHANGED
@@ -2,7 +2,9 @@ import { constants, type Stats } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import { basename, dirname, isAbsolute, join, parse, resolve } from "node:path";
4
4
 
5
- export type CliTemplate = "agent" | "typescript" | "python";
5
+ import { omniTemplate } from "./cli-omni-template.ts";
6
+
7
+ export type CliTemplate = "agent" | "typescript" | "python" | "omni";
6
8
  export interface CliTemplateManifest {
7
9
  id: CliTemplate;
8
10
  description: string;
@@ -149,7 +151,7 @@ const install = `## Install the CLI
149
151
  Install the published CLI with Node.js 22 or newer recommended:
150
152
 
151
153
  \`\`\`bash
152
- npm install -g @pyai/sdk@0.6.0
154
+ npm install -g @pyai/sdk@0.7.0
153
155
  pyai --version
154
156
  \`\`\`
155
157
 
@@ -242,7 +244,7 @@ and product calls below are separate explicit steps. Read \`PYAI.md\` first.
242
244
  Install the official SDK:
243
245
 
244
246
  \`\`\`bash
245
- npm install @pyai/sdk@0.6.0
247
+ npm install @pyai/sdk@0.7.0
246
248
  cp .env.example .env
247
249
  \`\`\`
248
250
 
@@ -344,6 +346,7 @@ Check the live contract before expanding the example.
344
346
  ${install}`;
345
347
 
346
348
  const content: Readonly<Record<CliTemplate, Readonly<Record<string, string>>>> = Object.freeze({
349
+ omni: Object.freeze({ ...omniTemplate, "PYAI.md": context }),
347
350
  agent: Object.freeze({ "README.md": agentReadme, "PYAI.md": context, "speech.json": speech, "job.json": job, "agent.json": agent, ".env.example": env, ".gitignore": ignore }),
348
351
  typescript: Object.freeze({ "README.md": tsReadme, "PYAI.md": context, "main.ts": tsMain, "package.json": JSON.stringify({ name: "pyai-starter", version: "0.0.0", private: true, type: "module", engines: { node: ">=22.6.0" }, scripts: { speak: "node --experimental-strip-types --env-file=.env main.ts speak", hear: "node --experimental-strip-types --env-file=.env main.ts hear" } }, null, 2) + "\n", ".env.example": env, ".gitignore": ignore }),
349
352
  python: Object.freeze({ "README.md": pythonReadme, "PYAI.md": context, "main.py": pythonMain, ".env.example": env, ".gitignore": ignore }),
@@ -354,6 +357,7 @@ export const CLI_TEMPLATES: readonly CliTemplateManifest[] = Object.freeze(([
354
357
  { id: "agent", description: "API context and JSON requests for coding agents" },
355
358
  { id: "typescript", description: "Runnable Node.js Speak and Hear examples using @pyai/sdk" },
356
359
  { id: "python", description: "Runnable Python Speak and Hear examples using pyai-sdk" },
360
+ { id: "omni", description: "Runnable Node 20/22 Omni audio, read tool, capture and simulated interruption" },
357
361
  ] as const).map((entry) => Object.freeze({ ...entry, files: Object.freeze(Object.keys(content[entry.id])) })));
358
362
 
359
363
  function hasCode(error: unknown, code: string): boolean {
@@ -368,7 +372,7 @@ async function exists(path: string): Promise<boolean> {
368
372
  /** Create a new project only. Never authenticates, installs packages, or runs a shell. */
369
373
  export async function scaffoldProject(options: ScaffoldOptions): Promise<ScaffoldResult> {
370
374
  const template = options.template ?? "agent";
371
- if (!Object.hasOwn(content, template)) throw new CliInitError("invalid_template", "Choose agent, typescript, or python with --template.");
375
+ if (!Object.hasOwn(content, template)) throw new CliInitError("invalid_template", "Choose agent, typescript, python, or omni with --template.");
372
376
  if (typeof options.directory !== "string" || !options.directory.trim() || /[\p{Cc}]/u.test(options.directory)) {
373
377
  throw new CliInitError("invalid_directory", "Provide a new project directory without control characters.");
374
378
  }
@@ -0,0 +1,11 @@
1
+ // Generated by scripts/generate-omni-starter.mjs; edit examples/omni-starter instead.
2
+ export const omniTemplate = Object.freeze({
3
+ "README.md": "# Runnable Omni starter\n\nRequires Node 20.19+ and ESM. Node 20 uses the included `ws` transport;\nNode 22 also works. The separate MCP server requires Node 22+.\nThis server-side example uses a synthetic read-only office-hours tool. Read\n`PYAI.md` when generating the project through the CLI.\n\n<!-- omni-install:start -->\n```sh\nnpm install @pyai/sdk@0.7.0\nnpx pyai init voice-demo --template omni\ncd voice-demo\nnpm install\n```\n\nInitialization is offline; installing dependencies and running `main.mjs` are\nseparate steps. For an existing checkout of this example, run `npm install`\nin its directory instead.\n<!-- omni-install:end -->\n\nInject `PYAI_API_KEY` through your secret manager or shell environment. Do not\npaste a real key into source, a prompt, or shell history. `.env.example` documents\nthe variable; this program does not load `.env` or saved CLI profiles.\n\nPrepare a 24 kHz PCM16 mono WAV, at most 20 seconds, saying “Please look up the\noffice opening time.” Then run:\n\n```sh\nnode main.mjs caller.wav\n```\n\nRunning the program explicitly opens one bounded Omni session and uploads its\ncaptured reply to Hear. Both consume your key's allowed usage. Automatic retries\nare disabled. No managed number is bought and no phone call is placed.\n\n`session.mjs` waits for configuration and the greeting to drain, sends exactly\none paced input stream (caller PCM or silence), returns the read tool's result,\nand captures output using the rate from `hello.audio_out`. Never run a separate\nsilence timer alongside caller audio. `timing.mjs` is generated from the existing\nOmni evaluation harness's timing utilities.\n\nThe new private `omni-run-*` folder contains `session.wav`, `reply.wav`, and\n`report.json`. The report separates received audio, an office-hours answer\nrecovered by Hear from captured audio, and physical playback (not tested).\nA synthesis transcript is advisory; it is not evidence that speech arrived.\nCapture ends after the simulated queue drains and two seconds of quiet. Omni\nhas no protocol reply-end marker, so this is a bounded capture heuristic.\nListen to the saved file when qualifying actual sound quality or completeness.\n\nTo exercise interruption, supply a second WAV saying “Stop speaking now”:\n\n```sh\nnode main.mjs caller.wav interruption.wav\n```\n\nThe second clip starts while reply audio is queued. `onBargeIn` clears that\nsimulated queue, and capture observes at least five seconds after that clip ends.\nAn interrupted answer can correctly fail the full-answer check;\ninspect the separate interruption evidence. This is not a physical speaker test.\nFor a real playback adapter, cancel both queued and currently playing audio.\nReplace the example lookup with authorized application data before product use.\n",
4
+ "package.json": "{\n \"name\": \"pyai-omni-starter\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"engines\": {\n \"node\": \">=20.19.0\"\n },\n \"scripts\": {\n \"start\": \"node main.mjs\"\n },\n \"dependencies\": {\n \"@pyai/sdk\": \"0.7.0\",\n \"ws\": \"^8.18.3\"\n }\n}\n",
5
+ "timing.mjs": "// Monotonic capture timing. Arrival time is distinct from queued playback:\n// a single packet may contain seconds of audio and must finish before settling.\nexport const FRAME_MS = 20;\nexport const REALTIME_GAP_LIMIT_MS = 100;\n// Interruption tests need an observation window after caller transmission,\n// even when an earlier cue/response already finished. These are test bounds,\n// not an inferred engine reply-end or a naturalness threshold.\nexport const INTERRUPTION_OBSERVATION = Object.freeze({\n minimumAfterCallerStreamMs: 5000, quietMs: 2000, maximumAfterCallerStreamMs: 25000,\n});\n\n/** Approximate audible bounds; this is energy detection, not a speech/VAD claim. */\nexport function audibleBounds(pcm, rate, rmsFloor = 160) {\n const window = Math.max(1, Math.round(rate / 100)); // 10 ms windows\n let first = null;\n let last = null;\n for (let off = 0; off < pcm.length; off += window) {\n const end = Math.min(pcm.length, off + window);\n let energy = 0;\n for (let i = off; i < end; i++) energy += pcm[i] * pcm[i];\n if (Math.sqrt(energy / (end - off)) >= rmsFloor) {\n first ??= off;\n last = end;\n }\n }\n return { first, last };\n}\n\nexport function newAudioCapture(started) {\n return {\n started, firstPacketAt: null, lastPacketAt: null,\n firstAudioAt: null, lastAudioAt: null, playbackEndAt: null,\n samples: 0, pcm: [], turnBeginAt: null, eouMs: null,\n latestTurnBeginAt: null, postTurnBeginFirstPacketAt: null,\n postTurnBeginFirstAudioAt: null, lastAssistantTranscriptAt: null, lastCallerTranscriptAt: null,\n callerTranscriptEvents: [], assistantTranscriptEvents: [],\n turnBegins: [], tools: [], toolResults: [], kb: null, events: [],\n };\n}\n\nexport function recordTurnBegin(ctx, at, turn, origin = 0) {\n ctx.turnBeginAt ??= at;\n ctx.latestTurnBeginAt = at;\n // Earlier PCM can be a listening cue. A new reply is still pending even\n // when that cue has already finished playing; retain all of its PCM.\n ctx.postTurnBeginFirstPacketAt = null;\n ctx.postTurnBeginFirstAudioAt = null;\n ctx.turnBegins.push({ atMs: Math.round(at - origin), turn: turn ?? null });\n}\n\nexport function recordAudio(ctx, pcm, at, rate) {\n if (!(pcm instanceof Int16Array) || !pcm.length) return null;\n const copy = pcm.slice(); // Some transports reuse callback buffers.\n const playbackAt = Math.max(at, ctx.playbackEndAt ?? at);\n const endAt = playbackAt + (copy.length / rate) * 1000;\n const bounds = audibleBounds(copy, rate);\n ctx.firstPacketAt ??= at;\n ctx.lastPacketAt = at;\n ctx.playbackEndAt = endAt;\n const afterTurnBegin = ctx.latestTurnBeginAt != null && at >= ctx.latestTurnBeginAt;\n if (afterTurnBegin) ctx.postTurnBeginFirstPacketAt ??= at;\n if (bounds.first != null) {\n ctx.firstAudioAt ??= playbackAt + (bounds.first / rate) * 1000;\n ctx.lastAudioAt = playbackAt + (bounds.last / rate) * 1000;\n if (afterTurnBegin) ctx.postTurnBeginFirstAudioAt ??= playbackAt + (bounds.first / rate) * 1000;\n }\n ctx.samples += copy.length;\n ctx.pcm.push(copy);\n return { playbackAt, endAt, pcm: copy };\n}\n\n/** Preserve negative gaps: they are overlaps, never missing measurements. */\nexport function captureTiming(ctx, caller, origin = 0) {\n const relative = (at) => at == null ? null : Math.round(at - origin);\n const gap = (at) => at == null || caller.speechOffsetAt == null\n ? null : Math.round(at - caller.speechOffsetAt);\n return {\n ttfbMs: gap(ctx.firstAudioAt),\n anyAudioTtfbMs: gap(ctx.firstAudioAt),\n postTurnBeginTtfbMs: gap(ctx.postTurnBeginFirstAudioAt),\n turnMs: gap(ctx.lastAudioAt),\n packetTtfbMs: gap(ctx.firstPacketAt),\n callerStartMs: relative(caller.startedAt),\n callerSpeechOnsetMs: relative(caller.speechOnsetAt),\n callerSpeechOffsetMs: relative(caller.speechOffsetAt),\n callerStreamEndMs: relative(caller.streamEndAt),\n agentFirstPacketMs: relative(ctx.firstPacketAt),\n agentLastPacketMs: relative(ctx.lastPacketAt),\n agentSpeechOnsetMs: relative(ctx.firstAudioAt),\n agentSpeechOffsetMs: relative(ctx.lastAudioAt),\n agentPlaybackEndMs: relative(ctx.playbackEndAt),\n latestTurnBeginMs: relative(ctx.latestTurnBeginAt),\n postTurnBeginFirstPacketMs: relative(ctx.postTurnBeginFirstPacketAt),\n postTurnBeginSpeechOnsetMs: relative(ctx.postTurnBeginFirstAudioAt),\n lastAssistantTranscriptMs: relative(ctx.lastAssistantTranscriptAt),\n callerMaxFrameGapMs: Math.round(caller.maxFrameGapMs ?? 0),\n method: \"client-monotonic-queued-playback-energy-bounds\",\n };\n}\n\nexport async function streamPcmRealtime(omni, pcm, rate, clock, onFrame = () => {}) {\n const frameSize = Math.max(1, Math.round((rate * FRAME_MS) / 1000));\n const bounds = audibleBounds(pcm, rate);\n const startedAt = clock.now();\n let speechOnsetAt = null;\n let speechOffsetAt = null;\n let previousFrameEnd = startedAt;\n let maxFrameGapMs = 0;\n for (let off = 0; off < pcm.length; off += frameSize) {\n const frame = pcm.subarray(off, Math.min(off + frameSize, pcm.length));\n const at = clock.now();\n maxFrameGapMs = Math.max(maxFrameGapMs, at - previousFrameEnd);\n const durationMs = (frame.length / rate) * 1000;\n omni.sendAudio(frame);\n onFrame(frame, at);\n if (bounds.first != null && bounds.first >= off && bounds.first < off + frame.length) {\n speechOnsetAt = at + ((bounds.first - off) / rate) * 1000;\n }\n if (bounds.last != null && bounds.last > off && bounds.last <= off + frame.length) {\n speechOffsetAt = at + ((bounds.last - off) / rate) * 1000;\n }\n previousFrameEnd = at + durationMs;\n // Pace from the actual send. Never burst late frames to \"catch up\".\n await clock.sleep(Math.max(0, previousFrameEnd - clock.now()));\n }\n return { startedAt, speechOnsetAt, speechOffsetAt,\n streamEndAt: clock.now(), maxFrameGapMs };\n}\n\nexport async function streamSilenceWhile(omni, rate, shouldContinue, clock, onFrame = () => {}) {\n const silence = new Int16Array(Math.max(1, Math.round((rate * FRAME_MS) / 1000)));\n while (shouldContinue()) {\n const at = clock.now();\n omni.sendAudio(silence);\n onFrame(silence, at);\n await clock.sleep(FRAME_MS);\n }\n}\n\nexport async function waitForAgentSettle(getCtx, clock, opts = {}) {\n const started = clock.now();\n const observation = opts.callerStreamEndAt != null;\n if (observation && (!Number.isFinite(opts.callerStreamEndAt)\n || opts.callerStreamEndAt < 0 || opts.callerStreamEndAt > started)) {\n throw new Error(\"Invalid caller observation boundary\");\n }\n const timeoutMs = observation ? INTERRUPTION_OBSERVATION.maximumAfterCallerStreamMs : opts.timeoutMs ?? 25000;\n const settleMs = observation ? INTERRUPTION_OBSERVATION.quietMs : opts.settleMs ?? 2000;\n const deadlineAt = (observation ? opts.callerStreamEndAt : started) + timeoutMs;\n const minimumAt = observation ? opts.callerStreamEndAt + INTERRUPTION_OBSERVATION.minimumAfterCallerStreamMs : -Infinity;\n while (true) {\n const ctx = getCtx();\n const at = clock.now();\n if (opts.isClosed?.()) return { reason: \"closed\", at };\n if (observation && at >= deadlineAt) return { reason: \"timeout\", at };\n const hasResponseAudio = !opts.requireTurnBegin || (ctx.latestTurnBeginAt != null\n && ctx.postTurnBeginFirstAudioAt != null);\n // Synthesis text is progress, not a completion event. It can arrive after\n // early/final audio, so extend the quiet floor without demanding another\n // packet after every advisory. The protocol still has no reply-end marker.\n const activityEnd = Math.max(ctx.playbackEndAt ?? -Infinity,\n ctx.latestTurnBeginAt ?? -Infinity, ctx.lastAssistantTranscriptAt ?? -Infinity,\n observation ? ctx.lastCallerTranscriptAt ?? -Infinity : -Infinity);\n // Do not demand a new post-caller turn_begin: an earlier turn can be\n // coalesced and speak later. This records a bounded quiet observation;\n // downstream response-timing checks can impose a stricter begin boundary.\n if (ctx.firstPacketAt != null && hasResponseAudio && at >= minimumAt && at >= activityEnd + settleMs) {\n return { reason: \"settled\", at };\n }\n if (opts.allowEmpty && ctx.firstPacketAt == null && at >= minimumAt && at - started >= (opts.emptyWaitMs ?? 800)) {\n return { reason: \"empty\", at };\n }\n if (at - started >= timeoutMs) return { reason: \"timeout\", at };\n await clock.sleep(observation ? Math.min(20, deadlineAt - at) : 20);\n }\n}\n\nexport async function withTimeout(promise, ms, message, clock) {\n let timer;\n try {\n return await Promise.race([\n promise,\n new Promise((_, reject) => {\n timer = clock.setTimeout(() => reject(new Error(message)), ms);\n }),\n ]);\n } finally {\n clock.clearTimeout(timer);\n }\n}\n",
6
+ "main.mjs": "import PyAI from \"@pyai/sdk\";\nimport WebSocket from \"ws\"; // Explicit transport makes this work on Node 20 too.\nimport { readFile, writeFile, mkdtemp } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { runSession } from \"./session.mjs\";\nimport { readWav, writeWav, assessAnswer } from \"./audio.mjs\";\n\nasync function main() {\n const [callerPath, interruptionPath, extra] = process.argv.slice(2);\n if (!callerPath || extra) throw new Error(\"Usage: node main.mjs caller.wav [interruption.wav]\");\n if (!process.env.PYAI_API_KEY) throw new Error(\"Inject PYAI_API_KEY through your environment first\");\n const caller = readWav(await readFile(callerPath));\n const interruption = interruptionPath ? readWav(await readFile(interruptionPath)) : undefined;\n const pyai = new PyAI({ apiKey: process.env.PYAI_API_KEY, maxRetries: 0,\n fetch: (url, options) => fetch(url, { ...options, signal: AbortSignal.timeout(30000) }),\n });\n const { report, audio, replyAudio } = await runSession({ pyai, webSocket: WebSocket, caller, interruption });\n const directory = await mkdtemp(join(process.cwd(), \"omni-run-\"));\n const save = (name, data) => writeFile(join(directory, name), data, { mode: 0o600, flag: \"wx\" });\n await save(\"session.wav\", writeWav(audio, report.output_rate ?? 24000));\n const reply = writeWav(replyAudio, report.output_rate ?? 24000);\n await save(\"reply.wav\", reply);\n // Hear the captured bytes, rather than trusting synthesis-advisory text.\n let capturedTranscript = \"\";\n if (report.reply_audio_bytes) {\n try {\n const heard = await pyai.audio.transcriptions.create({ file: new File([reply], \"reply.wav\", { type: \"audio/wav\" }), model: \"pyai-hear\" });\n capturedTranscript = heard.text;\n } catch { report.hear_error = \"Captured audio saved; Hear verification failed\"; }\n }\n const result = { ...report, captured_transcript: capturedTranscript, evidence: assessAnswer(report, capturedTranscript) };\n await save(\"report.json\", JSON.stringify(result, null, 2) + \"\\n\");\n console.log(JSON.stringify({ directory, ...result }, null, 2));\n if (result.evidence.captured_answer !== \"verified_by_hear\" || (interruption && result.evidence.interruption !== \"simulated_queue_cleared\")) process.exitCode = 1;\n}\n\nmain().catch(error => {\n const message = String(error.message).replaceAll(process.env.PYAI_API_KEY || \"\\0\", \"[REDACTED]\");\n console.error(JSON.stringify({ error: message })); process.exitCode = 1;\n});\n",
7
+ "session.mjs": "import { newAudioCapture, recordAudio, recordTurnBegin, streamPcmRealtime, INTERRUPTION_OBSERVATION } from \"./timing.mjs\";\n\nexport const realClock = {\n now: () => performance.now(),\n sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),\n};\n\n/** One bounded, server-side example. Its playback queue is simulated. */\nexport async function runSession({ pyai, webSocket, caller, interruption, clock = realClock, timeoutMs = 60000 }) {\n const rate = 24000, frameSize = 480, silence = new Int16Array(frameSize);\n if (!(caller instanceof Int16Array) || !caller.length || caller.length > rate * 20) throw new Error(\"Provide 1–20 seconds of caller PCM16 at 24 kHz\");\n if (interruption && (!(interruption instanceof Int16Array) || !interruption.length || interruption.length > rate * 20)) throw new Error(\"Invalid interruption PCM\");\n const started = clock.now(), all = newAudioCapture(started), reply = newAudioCapture(started);\n const report = { input_rate: rate, output_rate: null, playback_sink: \"simulated\", tool_calls: 0, tool_executions: 0, duplicate_tool_calls: 0, flushes_after_interruption: 0, cleared_queue_ms: 0, interruption_requested: !!interruption, caller_started_ms: null, interruption_started_ms: null, advisory_transcripts: [], end_reason: \"timeout\" };\n let session, configuredAt = null, closed = false, failure = null, phase = \"greeting\", offset = 0, queueEnd = started, interruptionSent = false, inputEndAt = started;\n const handled = new Set();\n const fail = message => { failure ??= new Error(message); };\n try {\n session = pyai.omni.connect({\n webSocket, rate,\n configure: {\n voice_id: \"stock_amos_en_us\", language: \"en\", greeting: \"Hello. How can I help?\",\n persona: \"When asked about office hours, call lookup_office_hours. Never guess. After the result, say one short sentence giving its opening_time.\",\n tools: [{ name: \"lookup_office_hours\", description: \"Read the synthetic example office opening time.\", side_effect: \"read\", parameters: { type: \"object\", properties: {}, required: [], additionalProperties: false } }],\n },\n onHello: frame => {\n const match = /^pcm16@(8000|24000)$/.exec(frame.audio_out ?? \"\");\n if (!match) return fail(\"Unsupported Omni output format\");\n report.output_rate = Number(match[1]);\n },\n onConfigured: () => { configuredAt ??= clock.now(); },\n onAudio: chunk => {\n if (!report.output_rate) return fail(\"Audio arrived before its output format\");\n const bytes = chunk instanceof ArrayBuffer ? new Uint8Array(chunk) : new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);\n if (bytes.byteLength % 2) return fail(\"Invalid PCM16 output\");\n const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);\n const pcm = Int16Array.from({ length: bytes.length / 2 }, (_, i) => view.getInt16(i * 2, true));\n const now = clock.now();\n recordAudio(all, pcm, now, report.output_rate);\n queueEnd = Math.max(now, queueEnd) + pcm.length / report.output_rate * 1000;\n if (report.tool_executions) recordAudio(reply, pcm, now, report.output_rate);\n },\n onEvent: frame => {\n if (frame.event === \"turn_begin\") recordTurnBegin(all, clock.now(), frame.turn);\n },\n onTranscript: frame => {\n // An assistant advisory is progress; never treat it as received speech.\n if (report.advisory_transcripts.length < 100) report.advisory_transcripts.push({ role: frame.role, text: frame.text });\n if (frame.role === \"assistant\") all.lastAssistantTranscriptAt = clock.now();\n },\n onToolCall: frame => {\n report.tool_calls++;\n if (handled.has(frame.call_id)) { report.duplicate_tool_calls++; return; }\n handled.add(frame.call_id);\n if (frame.name !== \"lookup_office_hours\") { session.toolResult(frame.call_id, { error: \"Unknown example tool\" }); return; }\n // Replace this read-only fixture with your authorized data lookup.\n report.tool_executions++;\n session.toolResult(frame.call_id, { result: { opening_time: \"9 a.m.\", source: \"synthetic example office\" } });\n },\n onBargeIn: () => {\n const now = clock.now(), queued = Math.max(0, queueEnd - now);\n if (report.interruption_started_ms != null) { report.flushes_after_interruption++; report.cleared_queue_ms += queued; }\n // A real speaker adapter must cancel scheduled and currently playing audio.\n queueEnd = now; all.playbackEndAt = now; reply.playbackEndAt = now;\n },\n onError: () => fail(\"Omni reported an error; inspect authorized server diagnostics\"),\n onClose: () => { closed = true; },\n });\n while (clock.now() - started < timeoutMs) {\n if (failure) throw failure;\n if (closed) { report.end_reason = \"closed\"; break; }\n const now = clock.now();\n if (configuredAt == null || !report.output_rate) {\n if (now - started >= 10000) throw new Error(\"Omni connection/configuration timed out\");\n await clock.sleep(20); continue;\n }\n const quietAt = Math.max(queueEnd, all.lastPacketAt ?? now, all.lastAssistantTranscriptAt ?? 0, inputEndAt);\n if (phase === \"greeting\") {\n if (all.firstPacketAt != null && now - configuredAt >= 2000 && now >= quietAt + 800) {\n phase = \"caller\"; report.caller_started_ms = now - started;\n } else if (now - configuredAt > 15000) throw new Error(\"Greeting did not drain within 15 seconds\");\n }\n if (phase === \"reply\" && interruption && !interruptionSent && reply.samples && queueEnd - now >= 200) {\n phase = \"interruption\"; offset = 0; interruptionSent = true; report.interruption_started_ms = now - started;\n }\n const observationEnd = inputEndAt + (interruptionSent ? INTERRUPTION_OBSERVATION.minimumAfterCallerStreamMs : 0);\n if (phase === \"reply\" && reply.samples && now >= quietAt + 2000 && now >= observationEnd && (!interruption || interruptionSent)) {\n report.end_reason = \"quiet_window\"; break;\n }\n // Exactly one producer: each 20 ms slot contains caller PCM OR silence.\n let pcm = silence;\n if (phase === \"caller\" || phase === \"interruption\") {\n const source = phase === \"caller\" ? caller : interruption;\n pcm = source.subarray(offset, offset + frameSize); offset += pcm.length;\n if (offset >= source.length) {\n inputEndAt = now + pcm.length / rate * 1000;\n report[phase === \"caller\" ? \"caller_completed_ms\" : \"interruption_completed_ms\"] = inputEndAt - started;\n phase = \"reply\"; offset = 0;\n }\n }\n await streamPcmRealtime(session, pcm, rate, clock);\n }\n report.audio_bytes = all.samples * 2;\n report.reply_audio_bytes = reply.samples * 2;\n report.elapsed_ms = Math.round(clock.now() - started);\n return { report, audio: all.pcm, replyAudio: reply.pcm };\n } finally { session?.close(); }\n}\n",
8
+ "audio.mjs": "// Small, strict WAV boundary: the wire receives samples, never WAV headers.\nexport function readWav(bytes) {\n const b = Buffer.from(bytes);\n if (b.length < 44 || b.toString(\"ascii\", 0, 4) !== \"RIFF\" || b.toString(\"ascii\", 8, 12) !== \"WAVE\") throw new Error(\"Expected a PCM16 mono WAV file\");\n const end = b.readUInt32LE(4) + 8;\n if (end > b.length) throw new Error(\"Truncated WAV file\");\n let rate, data;\n for (let at = 12; at + 8 <= end;) {\n const name = b.toString(\"ascii\", at, at + 4), size = b.readUInt32LE(at + 4);\n const start = at + 8;\n if (start + size > end) throw new Error(\"Truncated WAV chunk\");\n if (name === \"fmt \") {\n if (size < 16 || b.readUInt16LE(start) !== 1 || b.readUInt16LE(start + 2) !== 1 || b.readUInt16LE(start + 14) !== 16) throw new Error(\"Convert input to PCM16 mono WAV first\");\n rate = b.readUInt32LE(start + 4);\n } else if (name === \"data\") data = b.subarray(start, start + size);\n at = start + size + (size % 2);\n }\n if (rate !== 24000 || !data?.length || data.length % 2 || data.length > rate * 2 * 20) throw new Error(\"Use a non-empty 24 kHz PCM16 mono WAV, at most 20 seconds\");\n return Int16Array.from({ length: data.length / 2 }, (_, i) => data.readInt16LE(i * 2));\n}\n\nexport function writeWav(chunks, rate) {\n const samples = chunks.reduce((n, pcm) => n + pcm.length, 0);\n const b = Buffer.alloc(44 + samples * 2);\n b.write(\"RIFF\", 0); b.writeUInt32LE(b.length - 8, 4); b.write(\"WAVEfmt \", 8);\n b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);\n b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28);\n b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write(\"data\", 36);\n b.writeUInt32LE(samples * 2, 40);\n let at = 44;\n for (const pcm of chunks) for (const value of pcm) { b.writeInt16LE(value, at); at += 2; }\n return b;\n}\n\nexport function assessAnswer(report, capturedTranscript) {\n // This checks this example's fixture, not general speech correctness.\n const text = String(capturedTranscript ?? \"\").toLowerCase();\n const expected = /\\boffice\\b/.test(text) && /\\b(?:nine|9)\\b/.test(text)\n && /\\bopen(?:s|ing)?\\b/.test(text) && /\\b(?:a\\.?\\s*m\\.?|morning)\\b/.test(text);\n return {\n audio_received: report.reply_audio_bytes > 0,\n captured_answer: report.end_reason === \"quiet_window\" && report.tool_executions === 1 && expected ? \"verified_by_hear\" : \"not_verified\",\n capture_boundary: \"bounded_quiet_window_no_protocol_reply_end\",\n physical_playback: \"not_tested\",\n interruption: report.interruption_requested ? (report.flushes_after_interruption > 0 && report.cleared_queue_ms > 0 ? \"simulated_queue_cleared\" : \"not_verified\") : \"not_tested\",\n };\n}\n",
9
+ ".env.example": "PYAI_API_KEY=\n",
10
+ ".gitignore": "node_modules/\n.env\nomni-run-*/\n*.wav\n"
11
+ });
package/src/cli.ts CHANGED
@@ -270,7 +270,7 @@ async function main(): Promise<void> {
270
270
  const grouped = group ? commands.filter(c => c.command.startsWith(`${group} `)) : commands;
271
271
  const visible = spec ? [spec, ...commands.filter(c => c.command.startsWith(`${spec.command} `))] : grouped.length ? grouped : commands;
272
272
  if (flags.json) output({ name: "pyai", globals, aliases, short_flags: shortFlags, commands: visible }, flags, undefined, false);
273
- else if (!spec && !group) process.stdout.write(`PyAI CLI — speech, agents, and automation\n\nStart here\n pyai login Sign in through your browser\n pyai speak "Hello from PyAI" -o hello.wav Turn text into audio\n pyai hear call.wav --text-only Print a transcript\n pyai dub call.wav --from en --to hi -o hi.wav\n Submit, wait, and save dubbed audio\n pyai init voice-demo --template typescript Create a starter project offline\n\nDiscover\n pyai recipes [name] Copyable workflows\n pyai help <command> Command options and examples\n pyai help all Every command\n pyai schema [command] -j Machine-readable command definitions\n\nGroups\n auth profiles models voices agents jobs clones design\n cast dub recap trace tools vocabulary amd numbers calls omni\n\nShortcuts: login, logout, whoami, use, say (speak), hear (transcribe)\nOptions: -o FILE -f FILE -t TEXT -p PROFILE -j (JSON)\nGlobal: --base-url URL --api-key KEY --timeout SECONDS --retries N --dry-run\nCredentials: pyai login or PYAI_API_KEY. Run pyai doctor for a usage-consuming diagnostic.\n`);
273
+ else if (!spec && !group) process.stdout.write(`PyAI CLI — speech, agents, and automation\n\nStart here\n pyai login Sign in through your browser\n pyai speak "Hello from PyAI" -o hello.wav Turn text into audio\n pyai hear call.wav --text-only Print a transcript\n pyai dub call.wav --from en --to hi -o hi.wav\n Submit, wait, and save dubbed audio\n pyai init voice-demo --template omni Create a runnable Omni starter offline\n\nCalling\n pyai numbers list List owned phone numbers\n pyai numbers search --country US Find available numbers\n pyai calls list Inspect outbound calls\n pyai recipes calling --json Setup and preview examples\n pyai calls create --data @call.json --idempotency-key REQUEST_KEY --dry-run\n Preview a call without submitting it\n --dry-run previews a change without sending the request.\n Number purchases and outbound calls require --confirm and --idempotency-key.\n Use --confirm only after that purchase or call is authorized.\n\nDiscover\n pyai recipes [name] Copyable workflows\n pyai help <command> Command options and examples\n pyai help all Every command\n pyai schema [command] -j Machine-readable command definitions\n\nGroups\n auth profiles models voices agents jobs clones design\n cast dub recap trace tools vocabulary amd numbers calls omni\n\nShortcuts: login, logout, whoami, use, say (speak), hear (transcribe)\nOptions: -o FILE -f FILE -t TEXT -p PROFILE -j (JSON)\nGlobal: --base-url URL --api-key KEY --timeout SECONDS --retries N --dry-run\nCredentials: pyai login or PYAI_API_KEY. Run pyai doctor for a usage-consuming diagnostic.\n`);
274
274
  else process.stdout.write(`PyAI CLI — speech, agents, and automation\n\n${visible.map(c => ` pyai ${c.command}${(c.args ?? []).map(a => c.optionalArgs ? ` [${a}${c.variadic ? "..." : ""}]` : ` <${a}>`).join("")}\n ${c.description}${c.flags ? `\n Options: ${Object.keys(c.flags).map(k => `--${k}${c.flags![k] === "boolean" ? "" : " VALUE"}`).join(" ")}` : ""}${c.example ? `\n ${c.example}` : ""}`).join("\n")}\n\nGlobal: --profile NAME --json --timeout SECONDS --retries N --dry-run\n --base-url URL --api-key KEY --help --version\nShort flags: -o FILE -f FILE -t TEXT -p PROFILE -j (JSON)\nAuth: pyai login or PYAI_API_KEY. Discover: pyai schema --json\n`);
275
275
  return;
276
276
  }
package/src/index.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Thin, dependency-free wrapper over the public OpenAI-compatible surface at
5
5
  * https://api.pyai.com (contract: https://api.pyai.com/openapi.json). Runs in
6
- * the browser and Node 22+ (uses global fetch + WebSocket). Keys are opaque, * never parsed.
6
+ * the browser and Node 18+ for REST. Realtime needs an injected WebSocket
7
+ * on Node 20; Node 22+ provides a global WebSocket. Keys are opaque, never parsed.
7
8
  */
8
9
 
9
10
  export interface PyAIOptions {
@@ -887,6 +888,9 @@ export interface OmniToolDef {
887
888
  name: string;
888
889
  description?: string;
889
890
  parameters?: Record<string, unknown>;
891
+ /** Declare lookups as read-only. Omitted tools are treated as actions and
892
+ * require a positive completion acknowledgement in their result. */
893
+ side_effect?: "read" | "action";
890
894
  }
891
895
 
892
896
  export interface OmniToolCallFrame {