@urun-sh/sense 0.0.0-stage → 0.1.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.
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * 🧀 Sense SDK cheese classifier — browser + mic (Bun server).
4
+ *
5
+ * git clone https://github.com/urun-sh/sense-sdk.git
6
+ * cd sense-sdk/typescript
7
+ * bun install && bun run build
8
+ * SENSE_API_KEY="<urun-key>" bun examples/cheese_demo.ts
9
+ */
10
+ import { spawnSync } from "node:child_process";
11
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
12
+ import { homedir, networkInterfaces } from "node:os";
13
+ import { join } from "node:path";
14
+ import { Sense, type Session, type Wave } from "@urun-sh/sense";
15
+
16
+ // -- Sense: define the questions, relay audio, read decisions -----------------
17
+
18
+ const app = new Sense(); // apiKey / baseUrl default from SENSE_API_KEY / SENSE_BASE_URL
19
+
20
+ app.noul("Is someone speaking right now?", { id: "speech" });
21
+ app.noul("Is the speaker talking about cheese right now?", { id: "cheese" });
22
+ app.choice(
23
+ "Which type of cheese are they talking about?",
24
+ ["cheddar", "brie", "gouda", "parmesan", "camembert", "mozzarella", "blue", "feta", "other"],
25
+ { id: "cheese_type" },
26
+ );
27
+ app.score("How does the speaker feel about that cheese?", ["hate", "dislike", "meh", "like", "love"], { id: "feeling" });
28
+
29
+ /** A wave is a batch of decisions, not an audio response. */
30
+ export type Verdict =
31
+ | { waveId: number; speaking: boolean; talking: false }
32
+ | { waveId: number; speaking: boolean; talking: true; kind: string; feeling: string; score: number; p: number };
33
+
34
+ export function toVerdict(wave: Wave): Verdict {
35
+ const speaking = Boolean(wave.result("speech").value);
36
+ const detection = wave.result("cheese");
37
+ if (detection.p < 0.6) return { waveId: wave.waveId, speaking, talking: false };
38
+ const kind = wave.result("cheese_type");
39
+ const feeling = wave.result("feeling");
40
+ return {
41
+ waveId: wave.waveId,
42
+ speaking,
43
+ talking: true,
44
+ kind: kind.pOption(String(kind.value)) >= 0.6 ? String(kind.value) : "unknown type",
45
+ feeling: feeling.p >= 0.6 ? feeling.top : "meh",
46
+ score: Number(feeling.value),
47
+ p: detection.p,
48
+ };
49
+ }
50
+
51
+ // -- relay: browser WebSocket ⇄ SDK session -----------------------------------
52
+
53
+ const CAP_MS = 300_000; // 5-minute session cap
54
+ const MAX_PENDING_BYTES = 1_000_000; // ~20 s of mic audio; the dial takes seconds normally
55
+ const MAX_RELAYS = 4; // the server spends its own API budget on LAN clients
56
+ let activeRelays = 0;
57
+
58
+ /** Relay state for one browser socket. Exported for tests. */
59
+ export type Relay = {
60
+ session: Session | null;
61
+ send: (event: unknown) => void;
62
+ capTimer: ReturnType<typeof setTimeout> | null;
63
+ done: boolean;
64
+ closed: boolean;
65
+ pending: (Uint8Array | ArrayBuffer)[]; // mic frames buffered until the dial completes
66
+ streaming: boolean; // true once the run loop has started
67
+ wake: (() => void) | null;
68
+ end: () => void; // assigned by the ws handler; tears down the browser socket too
69
+ };
70
+
71
+ /** First close wins; returns false when the relay was already closed. */
72
+ export function closeRelay(relay: Relay): boolean {
73
+ if (relay.closed) return false;
74
+ relay.closed = true;
75
+ if (relay.capTimer !== null) {
76
+ clearTimeout(relay.capTimer);
77
+ relay.capTimer = null;
78
+ }
79
+ relay.wake?.();
80
+ relay.session?.close();
81
+ return true;
82
+ }
83
+
84
+ export function handleAudio(relay: Relay, frame: Uint8Array | ArrayBuffer): void {
85
+ if (relay.closed) return;
86
+ if (relay.session && !relay.streaming) {
87
+ let total = frame.byteLength;
88
+ for (const f of relay.pending) total += f.byteLength;
89
+ if (total > MAX_PENDING_BYTES) {
90
+ // A stuck dial would grow this buffer unboundedly; give up instead.
91
+ relay.pending.length = 0;
92
+ relay.send({ type: "error", message: "upstream connection too slow — audio buffer overflow" });
93
+ relay.end();
94
+ return;
95
+ }
96
+ const first = relay.pending.length === 0;
97
+ relay.pending.push(frame);
98
+ if (first) relay.wake?.();
99
+ return;
100
+ }
101
+ if (relay.session) relay.session.audio.push(frame); // PCM16, mono, 24 kHz; one 40 ms block
102
+ }
103
+
104
+ export async function pump(relay: Relay): Promise<void> {
105
+ const session = relay.session;
106
+ if (!session) return;
107
+ try {
108
+ await session.connect();
109
+ if (relay.closed) {
110
+ // The client left during the dial; shut the session down instead of lingering.
111
+ session.close();
112
+ return;
113
+ }
114
+ // The turn opens on the first pushed audio frame, so wait for mic audio
115
+ // before starting the loop.
116
+ if (relay.pending.length === 0) await new Promise<void>((resolve) => (relay.wake = resolve));
117
+ if (relay.closed) {
118
+ session.close();
119
+ return;
120
+ }
121
+ relay.streaming = true;
122
+ for (const frame of relay.pending.splice(0)) session.audio.push(frame);
123
+ for await (const wave of session.run()) {
124
+ relay.send({ type: "wave", verdict: toVerdict(wave) });
125
+ }
126
+ if (relay.done) return; // cap path already reported stats and ended
127
+ relay.send({ type: "stats", stats: session.stats });
128
+ relay.send({ type: "ended", reason: "closed" });
129
+ } catch (err) {
130
+ relay.send({ type: "error", message: err instanceof Error ? err.message : String(err) });
131
+ }
132
+ }
133
+
134
+ function openRelay(send: (event: unknown) => void): Relay {
135
+ const relay: Relay = {
136
+ session: app.apiKey ? app.session() : null,
137
+ send,
138
+ capTimer: null,
139
+ done: false,
140
+ closed: false,
141
+ pending: [],
142
+ streaming: false,
143
+ wake: null,
144
+ end: () => {},
145
+ };
146
+ relay.send({ type: "hello", mode: relay.session ? "online" : "mic-only" });
147
+ if (relay.session) {
148
+ relay.capTimer = setTimeout(() => {
149
+ relay.done = true;
150
+ relay.send({ type: "stats", stats: relay.session!.stats });
151
+ relay.send({ type: "ended", reason: "cap" });
152
+ relay.end();
153
+ }, CAP_MS);
154
+ void pump(relay);
155
+ }
156
+ return relay;
157
+ }
158
+
159
+ // -- server: the demo page and the browser relay -------------------------------
160
+
161
+ type WsData = { relay: Relay | null };
162
+
163
+ function generateCert(): { cert: Buffer; key: Buffer } {
164
+ const dir = join(homedir(), ".cache", "sense-cheese-demo");
165
+ const certPath = join(dir, "cert.pem");
166
+ const keyPath = join(dir, "key.pem");
167
+ if (!existsSync(certPath) || !existsSync(keyPath)) {
168
+ mkdirSync(dir, { recursive: true });
169
+ const generated = spawnSync(
170
+ "openssl",
171
+ [
172
+ "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1",
173
+ "-nodes", "-keyout", keyPath, "-out", certPath, "-days", "365", "-subj", "/CN=cheese-demo",
174
+ ],
175
+ { stdio: "pipe" },
176
+ );
177
+ if (generated.error) throw new Error("openssl is required to generate the HTTPS certificate");
178
+ if (generated.status !== 0) {
179
+ throw new Error(`openssl certificate generation failed: ${(generated.stderr?.toString() ?? "").trim()}`);
180
+ }
181
+ }
182
+ return { cert: readFileSync(certPath), key: readFileSync(keyPath) };
183
+ }
184
+
185
+ function lanUrls(port: number): string[] {
186
+ const urls = [`https://localhost:${port}`];
187
+ for (const addrs of Object.values(networkInterfaces())) {
188
+ for (const addr of addrs ?? []) {
189
+ if (addr.family === "IPv4" && !addr.internal) urls.push(`https://${addr.address}:${port}`);
190
+ }
191
+ }
192
+ return urls;
193
+ }
194
+
195
+ if (import.meta.main) {
196
+ const port = Number(process.env.PORT) || 8080;
197
+ const { cert, key } = generateCert();
198
+ const page = readFileSync(join(import.meta.dir, "cheese_demo.html"));
199
+ console.log(`🧀 cheese demo (mode: ${app.apiKey ? "online" : "mic-only — set SENSE_API_KEY to classify"})`);
200
+ for (const url of lanUrls(port)) console.log(` ${url}`);
201
+ console.log(" accept the self-signed certificate warning; HTTPS is required for phone mic access");
202
+ Bun.serve<WsData>({
203
+ port,
204
+ hostname: "0.0.0.0",
205
+ tls: { cert, key },
206
+ fetch: (req, server) => {
207
+ const { pathname } = new URL(req.url);
208
+ if (pathname === "/ws") {
209
+ // Browsers cannot set the bearer header Sense needs, so the server
210
+ // relays the browser's audio and events.
211
+ if (server.upgrade(req, { data: { relay: null } })) return undefined;
212
+ return new Response("upgrade failed", { status: 500 });
213
+ }
214
+ if (pathname === "/") return new Response(page, { headers: { "content-type": "text/html; charset=utf-8" } });
215
+ return new Response("not found", { status: 404 });
216
+ },
217
+ websocket: {
218
+ open(ws) {
219
+ if (activeRelays >= MAX_RELAYS) {
220
+ try {
221
+ ws.send(JSON.stringify({ type: "error", message: `relay busy: ${MAX_RELAYS} concurrent sessions max` }));
222
+ } catch {
223
+ /* the client is gone */
224
+ }
225
+ ws.close();
226
+ return;
227
+ }
228
+ activeRelays++;
229
+ const relay = openRelay((event) => {
230
+ try {
231
+ ws.send(JSON.stringify(event));
232
+ } catch {
233
+ /* the client is gone; the pump's remaining sends are best-effort */
234
+ }
235
+ });
236
+ relay.end = () => {
237
+ if (closeRelay(relay)) activeRelays--;
238
+ ws.close();
239
+ };
240
+ ws.data.relay = relay;
241
+ },
242
+ message(ws, message) {
243
+ if (ws.data.relay && typeof message !== "string") handleAudio(ws.data.relay, message);
244
+ },
245
+ close(ws) {
246
+ if (ws.data.relay && closeRelay(ws.data.relay)) activeRelays--;
247
+ },
248
+ },
249
+ });
250
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Flagship full-duplex voice agent on @urun-sh/sense (TypeScript).
3
+ *
4
+ * Sense decides WHEN to respond, WHICH tool, and WHEN to interrupt; your code
5
+ * executes. Reads a wav (mic capture), streams it to the shared Sense
6
+ * endpoint, and reacts on edge-triggered predicates — including a barge-in
7
+ * that cancels the in-flight reply via ctx.cancel().
8
+ *
9
+ * Usage (run `bun install && bun run build` first — the demo imports the
10
+ * built package and the build stages the bundled sample wav):
11
+ * SENSE_API_KEY=<key> bun examples/voice_agent.ts
12
+ */
13
+ import { readFileSync } from "node:fs";
14
+ import { fileURLToPath } from "node:url";
15
+ import { Score, Sense, type Context, type Wave } from "@urun-sh/sense";
16
+
17
+ // -- questions ----------------------------------------------------------------
18
+
19
+ const app = new Sense({}); // apiKey/baseUrl default from SENSE_API_KEY / SENSE_BASE_URL
20
+ const finished = app.noul("Did the current speaker just finish their turn?", { id: "finished" });
21
+ const userSpeaking = app.noul("Is a human speaking right now?", { id: "user_speaking" });
22
+ const intent = app.choice("What does the user want?", ["support", "sales", "other"], { id: "intent" });
23
+ const actions = app.choice("What should the agent do?", ["respond", "ignore", "wait"], { id: "actions" });
24
+ app.score("How urgent is the request?", ["low", "medium", "high"], { id: "urgency" });
25
+ const tool = app.choice("Should a tool run?", ["none", "weather", "search", "timer"], { id: "tool" });
26
+
27
+ // The most recent trigger context — barge-in cancels tasks through it.
28
+ let lastEvent: Context | null = null;
29
+
30
+ // -- reactions (edge-triggered: fire on first true, then each false->true) ----
31
+
32
+ app.when(
33
+ finished
34
+ .and(intent.eq("support"))
35
+ .and(actions.top.eq("respond")),
36
+ (event) => {
37
+ lastEvent = event;
38
+ const wave = event.latest()!;
39
+ console.log(`[respond] wave ${wave.waveId} t=${wave.tMs}ms urgency=${(wave.result("urgency") as Score).value.toFixed(2)}`);
40
+ // A tracked cancellable run: duplicate active keys reject; the barge-in
41
+ // watcher cancels it.
42
+ event
43
+ .run("reply", (ctx) => reply(wave, ctx.signal), {
44
+ onCancel: () => console.log("[respond] reply cancelled"),
45
+ })
46
+ .catch((err: unknown) => {
47
+ if ((err as Error).message?.includes("cancelled")) return;
48
+ throw err;
49
+ });
50
+ },
51
+ );
52
+
53
+ app.when(finished.and(intent.p("support").gte(0.7)), (event) => {
54
+ const wave = event.latest()!;
55
+ console.log(`[sales-handoff] wave ${wave.waveId}: P(support)=${wave.result("intent").pOption("support").toFixed(2)}`);
56
+ });
57
+
58
+ app.when(tool.ne("none"), (event) => {
59
+ const wave = event.latest()!;
60
+ console.log(`[tool] wave ${wave.waveId}: invoking ${wave.result("tool").value}`);
61
+ });
62
+
63
+ // Turn boundary: the speaker finished AND went quiet — the moment a voice
64
+ // agent would hand the floor to its reply path.
65
+ app.when(finished.and(userSpeaking.not()), (event) => {
66
+ const wave = event.latest()!;
67
+ console.log(`[turn-boundary] wave ${wave.waveId} t=${wave.tMs}ms — speaker finished and quiet`);
68
+ });
69
+
70
+ // Probability predicate with hysteresis: true at P(finished) >= 0.8, resets
71
+ // only below 0.6 — thresholds near the boundary flap and edge triggers re-fire.
72
+ app.when(finished.p.between(0.8, 0.6), (event) => {
73
+ console.log(`[settled] wave ${event.latest()!.waveId}: speaker settled`);
74
+ });
75
+
76
+ app.onWave((event) => {
77
+ const w = event.latest()!;
78
+ if (w.result("actions").value === "ignore") return;
79
+ console.log(`[wave ${w.waveId}] t=${w.tMs}ms finished=${w.result("finished").value} intent=${w.result("intent").value}`);
80
+ }, { coalesce: true });
81
+
82
+ function delay(ms: number): Promise<void> {
83
+ const { promise, resolve } = Promise.withResolvers<void>();
84
+ setTimeout(resolve, ms);
85
+ return promise;
86
+ }
87
+
88
+ async function reply(wave: Wave, signal: AbortSignal | null): Promise<void> {
89
+ console.log(`[agent] replying to wave ${wave.waveId} (stub execution)`);
90
+ await delay(2000);
91
+ if (signal?.aborted) throw new Error(`reply for wave ${wave.waveId} cancelled`);
92
+ console.log(`[agent] reply for wave ${wave.waveId} complete`);
93
+ }
94
+
95
+ async function bargeInWatch(): Promise<void> {
96
+ for (;;) {
97
+ await delay(100);
98
+ if (!lastEvent) continue;
99
+ const spk = lastEvent.get("user_speaking");
100
+ if (spk && spk.value === true && spk.p >= 0.9) {
101
+ console.log("[barge-in] user resumed — cancelling the in-flight reply");
102
+ lastEvent.cancel("reply");
103
+ return;
104
+ }
105
+ }
106
+ }
107
+
108
+ // -- the session ---------------------------------------------------------------
109
+
110
+ /** 24 kHz PCM16 mono audio frames, paced like a live mic (48 kHz decimated). */
111
+ async function* audioFrames(path: string, chunkMs = 40): AsyncGenerator<{ audio: Uint8Array }> {
112
+ const buf = readFileSync(path);
113
+ // Minimal RIFF walk to the data chunk.
114
+ let off = 12;
115
+ let rate = 48000;
116
+ let data: Uint8Array | null = null;
117
+ while (off + 8 <= buf.length) {
118
+ const id = buf.subarray(off, off + 4).toString("latin1");
119
+ const size = buf.readUInt32LE(off + 4);
120
+ const body = buf.subarray(off + 8, off + 8 + size);
121
+ if (id === "fmt ") rate = body.readUInt32LE(4);
122
+ if (id === "data") data = body;
123
+ off += 8 + size + (size % 2);
124
+ }
125
+ if (!data) throw new Error("no data chunk");
126
+ const samples = new Int16Array(data.buffer, data.byteOffset, data.byteLength >> 1);
127
+ const decimated = rate === 48000 ? samples.filter((_, i) => i % 2 === 0) : samples;
128
+ const n = Math.floor((24000 * chunkMs) / 1000);
129
+ for (let i = 0; i + n <= decimated.length; i += n) {
130
+ yield { audio: new Uint8Array(decimated.buffer, decimated.byteOffset + i * 2, n * 2).slice() };
131
+ await delay(chunkMs);
132
+ }
133
+ }
134
+
135
+ async function main(): Promise<number> {
136
+ const path = process.argv[2] ?? fileURLToPath(new URL("./assets/mic-question-48k-mono.wav", import.meta.url));
137
+ const session = app.session();
138
+ // Text input alongside the audio (e.g. a transcript from your own ASR): the model
139
+ // hears the audio directly; text is extra context and exercises session.text.
140
+ session.text.push("Hi — my internet has been down since this morning and I need help.");
141
+ void bargeInWatch();
142
+ // Demo bound: a live agent runs forever; the example stops after 20s.
143
+ const done = Promise.withResolvers<void>();
144
+ const timer = setTimeout(() => done.resolve(), 30_000);
145
+ try {
146
+ const waves = session.run(audioFrames(path))[Symbol.asyncIterator]();
147
+ for (;;) {
148
+ const next = await Promise.race([waves.next(), done.promise.then(() => null)]);
149
+ if (next === null || next.done) break;
150
+ void next.value; // handlers react; the run loop assembles and yields waves
151
+ }
152
+ } finally {
153
+ clearTimeout(timer);
154
+ await session.close();
155
+ }
156
+ console.log(`stats: ${JSON.stringify(session.stats)}`);
157
+ return 0;
158
+ }
159
+
160
+ main().then(
161
+ (code) => process.exitCode = code,
162
+ (err: unknown) => {
163
+ console.error(err);
164
+ process.exitCode = 1;
165
+ },
166
+ );
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@urun-sh/sense",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "TypeScript SDK for the Sense realtime question stream",
5
+ "license": "Apache-2.0",
6
+ "author": "Urun Platform, Inc",
7
+ "type": "module",
8
+ "main": "./dist/index.js",
9
+ "module": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "examples",
21
+ "LICENSE"
22
+ ],
23
+ "bin": {
24
+ "sense-cheese-demo": "./examples/cheese_demo.ts"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/urun-sh/sense-sdk.git",
29
+ "directory": "typescript"
30
+ },
31
+ "homepage": "https://github.com/urun-sh/sense-sdk/tree/main/typescript#readme",
32
+ "bugs": {
33
+ "url": "https://github.com/urun-sh/sense-sdk/issues"
34
+ },
35
+ "engines": {
36
+ "node": ">=22"
37
+ },
38
+ "scripts": {
39
+ "build": "tsc -p tsconfig.build.json && node scripts/fix-dts.mjs && node scripts/stage-assets.mjs",
40
+ "prepack": "npm run build",
41
+ "test": "bun test",
42
+ "conformance": "bun test ../conformance/typescript_runner.ts",
43
+ "lint": "oxlint",
44
+ "typecheck": "tsc --noEmit"
45
+ },
46
+ "dependencies": {
47
+ "@msgpack/msgpack": "^3.1.3",
48
+ "ws": "^8.22.1"
49
+ },
50
+ "devDependencies": {
51
+ "@types/bun": "latest",
52
+ "@types/ws": "^8.18.2",
53
+ "jscpd": "^5.4.2",
54
+ "oxlint": "^1.91.0",
55
+ "typescript": "^5.9.0"
56
+ }
57
+ }