@absolutejs/voice 0.0.22-beta.0 → 0.0.22-beta.2
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/README.md +629 -0
- package/dist/agent.d.ts +113 -0
- package/dist/assistant.d.ts +99 -0
- package/dist/fileStore.d.ts +13 -3
- package/dist/index.d.ts +25 -3
- package/dist/index.js +7410 -2515
- package/dist/ops.d.ts +230 -3
- package/dist/opsPresets.d.ts +19 -0
- package/dist/opsRuntime.d.ts +66 -0
- package/dist/opsSinks.d.ts +149 -0
- package/dist/outcomeRecipes.d.ts +18 -0
- package/dist/postgresStore.d.ts +31 -0
- package/dist/queue.d.ts +276 -0
- package/dist/s3Store.d.ts +14 -0
- package/dist/sqliteStore.d.ts +26 -0
- package/dist/testing/index.js +181 -4
- package/dist/trace.d.ts +236 -0
- package/dist/types.d.ts +31 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,6 +73,635 @@ const app = new Elysia()
|
|
|
73
73
|
|
|
74
74
|
`createVoiceMemoryStore()` is dev-only. Real deployments should provide a shared store backed by Redis, Postgres, or equivalent.
|
|
75
75
|
|
|
76
|
+
## Voice Assistants
|
|
77
|
+
|
|
78
|
+
Use `createVoiceAssistant(...)` when you want one product-level surface for a voice agent instead of wiring tools, guardrails, experiments, traces, and ops recipes separately. It returns a standard `onTurn` handler, plus an `ops` object you can pass to `voice(...)`.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import {
|
|
82
|
+
createVoiceAssistant,
|
|
83
|
+
createVoiceExperiment,
|
|
84
|
+
createVoiceFileRuntimeStorage,
|
|
85
|
+
createVoiceMemoryStore,
|
|
86
|
+
createVoiceAgentTool,
|
|
87
|
+
voice
|
|
88
|
+
} from '@absolutejs/voice';
|
|
89
|
+
import { deepgram } from '@absolutejs/voice-deepgram';
|
|
90
|
+
|
|
91
|
+
const runtimeStorage = createVoiceFileRuntimeStorage({
|
|
92
|
+
directory: '.voice-runtime/support'
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const lookupOrder = createVoiceAgentTool({
|
|
96
|
+
name: 'lookup_order',
|
|
97
|
+
description: 'Look up an order by id.',
|
|
98
|
+
execute: async ({ args }) => ({ orderId: args.orderId, status: 'shipped' })
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const assistant = createVoiceAssistant({
|
|
102
|
+
id: 'support',
|
|
103
|
+
artifactPlan: {
|
|
104
|
+
ops: {
|
|
105
|
+
events: runtimeStorage.events,
|
|
106
|
+
reviews: runtimeStorage.reviews,
|
|
107
|
+
tasks: runtimeStorage.tasks
|
|
108
|
+
},
|
|
109
|
+
preset: {
|
|
110
|
+
name: 'support-triage',
|
|
111
|
+
options: {
|
|
112
|
+
queue: 'support-triage'
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
experiment: createVoiceExperiment({
|
|
117
|
+
id: 'support-prompt',
|
|
118
|
+
variants: [
|
|
119
|
+
{ id: 'baseline', weight: 1 },
|
|
120
|
+
{
|
|
121
|
+
id: 'direct',
|
|
122
|
+
weight: 1,
|
|
123
|
+
system: 'You are concise, practical, and resolve the caller quickly.'
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
}),
|
|
127
|
+
guardrails: {
|
|
128
|
+
beforeTurn: ({ turn }) =>
|
|
129
|
+
turn.text.toLowerCase().includes('human')
|
|
130
|
+
? { escalate: { reason: 'caller requested a human' } }
|
|
131
|
+
: undefined
|
|
132
|
+
},
|
|
133
|
+
model: {
|
|
134
|
+
async generate({ messages, tools }) {
|
|
135
|
+
return {
|
|
136
|
+
assistantText: `I can help. Available tools: ${tools.map((tool) => tool.name).join(', ')}`
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
system: 'You are a support voice assistant.',
|
|
141
|
+
tools: [lookupOrder],
|
|
142
|
+
trace: runtimeStorage.traces
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
voice({
|
|
146
|
+
path: '/voice',
|
|
147
|
+
session: createVoiceMemoryStore(),
|
|
148
|
+
stt: deepgram({ apiKey: process.env.DEEPGRAM_API_KEY! }),
|
|
149
|
+
trace: runtimeStorage.traces,
|
|
150
|
+
ops: assistant.ops,
|
|
151
|
+
onTurn: assistant.onTurn,
|
|
152
|
+
onComplete: async () => {}
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Assistant experiments are deterministic by session id, so a caller stays on the same variant for a call. Variants can change the model, system prompt, tools, and tool-round budget; guardrails can block a turn before model execution or rewrite the returned `VoiceRouteResult`.
|
|
157
|
+
|
|
158
|
+
## Agent Tools And Squads
|
|
159
|
+
|
|
160
|
+
For assistant-style products, use `createVoiceAgent(...)` as the `onTurn` handler. The agent layer is provider-neutral: plug in any model adapter, register server-side tools, and return normal voice route results like `assistantText`, `transfer`, `escalate`, or `complete`.
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
import {
|
|
164
|
+
createVoiceAgent,
|
|
165
|
+
createVoiceAgentSquad,
|
|
166
|
+
createVoiceAgentTool,
|
|
167
|
+
createVoiceMemoryStore,
|
|
168
|
+
voice
|
|
169
|
+
} from '@absolutejs/voice';
|
|
170
|
+
import { deepgram } from '@absolutejs/voice-deepgram';
|
|
171
|
+
|
|
172
|
+
const lookupOrder = createVoiceAgentTool({
|
|
173
|
+
name: 'lookup_order',
|
|
174
|
+
description: 'Look up an order by id.',
|
|
175
|
+
parameters: {
|
|
176
|
+
type: 'object',
|
|
177
|
+
properties: {
|
|
178
|
+
orderId: { type: 'string' }
|
|
179
|
+
},
|
|
180
|
+
required: ['orderId']
|
|
181
|
+
},
|
|
182
|
+
execute: async ({ args }) => {
|
|
183
|
+
return { orderId: args.orderId, status: 'shipped' };
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const supportAgent = createVoiceAgent({
|
|
188
|
+
id: 'support',
|
|
189
|
+
system: 'You are a concise support voice agent.',
|
|
190
|
+
tools: [lookupOrder],
|
|
191
|
+
model: {
|
|
192
|
+
async generate({ messages, tools }) {
|
|
193
|
+
// Call your LLM provider here. If it returns tool calls, AbsoluteJS
|
|
194
|
+
// executes them and calls the model again with tool results.
|
|
195
|
+
return {
|
|
196
|
+
assistantText: `I can help. Available tools: ${tools.map((tool) => tool.name).join(', ')}`
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const billingAgent = createVoiceAgent({
|
|
203
|
+
id: 'billing',
|
|
204
|
+
system: 'You handle billing questions.',
|
|
205
|
+
model: {
|
|
206
|
+
async generate() {
|
|
207
|
+
return { assistantText: 'I can help with billing.' };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const frontDesk = createVoiceAgentSquad({
|
|
213
|
+
id: 'front-desk',
|
|
214
|
+
defaultAgentId: 'support',
|
|
215
|
+
agents: [supportAgent, billingAgent]
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
voice({
|
|
219
|
+
path: '/voice',
|
|
220
|
+
session: createVoiceMemoryStore(),
|
|
221
|
+
stt: deepgram({ apiKey: process.env.DEEPGRAM_API_KEY! }),
|
|
222
|
+
onTurn: frontDesk.onTurn,
|
|
223
|
+
onComplete: async () => {}
|
|
224
|
+
});
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`createVoiceAgentSquad(...)` gives you squad-style specialization without locking your app into a hosted voice platform. An agent can return `handoff: { targetAgentId: 'billing' }`; the squad records the handoff, runs the target agent on the same turn, and still returns a standard `VoiceRouteResult`.
|
|
228
|
+
|
|
229
|
+
## Traces And Replay
|
|
230
|
+
|
|
231
|
+
Use trace stores when you want every call to be inspectable outside a hosted platform. Trace events are append-only records for model passes, tool calls, handoffs, agent results, call lifecycle, turn timing, errors, and cost telemetry.
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
import {
|
|
235
|
+
buildVoiceTraceReplay,
|
|
236
|
+
createVoiceAgent,
|
|
237
|
+
createVoiceFileRuntimeStorage,
|
|
238
|
+
createVoiceRedisTaskLeaseCoordinator,
|
|
239
|
+
createVoiceTraceHTTPSink,
|
|
240
|
+
createVoiceTraceSinkStore,
|
|
241
|
+
createVoiceTraceSinkDeliveryWorker,
|
|
242
|
+
exportVoiceTrace,
|
|
243
|
+
pruneVoiceTraceEvents,
|
|
244
|
+
voice
|
|
245
|
+
} from '@absolutejs/voice';
|
|
246
|
+
import { deepgram } from '@absolutejs/voice-deepgram';
|
|
247
|
+
|
|
248
|
+
const runtimeStorage = createVoiceFileRuntimeStorage({
|
|
249
|
+
directory: '.voice-runtime/support'
|
|
250
|
+
});
|
|
251
|
+
const redisLeases = createVoiceRedisTaskLeaseCoordinator({
|
|
252
|
+
url: process.env.REDIS_URL
|
|
253
|
+
});
|
|
254
|
+
const trace = createVoiceTraceSinkStore({
|
|
255
|
+
store: runtimeStorage.traces,
|
|
256
|
+
deliveryQueue: runtimeStorage.traceDeliveries,
|
|
257
|
+
redact: true,
|
|
258
|
+
sinks: [
|
|
259
|
+
createVoiceTraceHTTPSink({
|
|
260
|
+
id: 'warehouse',
|
|
261
|
+
url: process.env.TRACE_WAREHOUSE_URL!
|
|
262
|
+
})
|
|
263
|
+
]
|
|
264
|
+
});
|
|
265
|
+
const traceSinkWorker = createVoiceTraceSinkDeliveryWorker({
|
|
266
|
+
deliveries: runtimeStorage.traceDeliveries,
|
|
267
|
+
leases: redisLeases,
|
|
268
|
+
redact: true,
|
|
269
|
+
sinks: [
|
|
270
|
+
createVoiceTraceHTTPSink({
|
|
271
|
+
id: 'warehouse',
|
|
272
|
+
url: process.env.TRACE_WAREHOUSE_URL!
|
|
273
|
+
})
|
|
274
|
+
],
|
|
275
|
+
workerId: 'trace-sink-worker'
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const supportAgent = createVoiceAgent({
|
|
279
|
+
id: 'support',
|
|
280
|
+
trace,
|
|
281
|
+
model: {
|
|
282
|
+
async generate() {
|
|
283
|
+
return { assistantText: 'How can I help?' };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
voice({
|
|
289
|
+
path: '/voice',
|
|
290
|
+
session: runtimeStorage.session,
|
|
291
|
+
stt: deepgram({ apiKey: process.env.DEEPGRAM_API_KEY! }),
|
|
292
|
+
trace,
|
|
293
|
+
onTurn: supportAgent.onTurn,
|
|
294
|
+
onComplete: async () => {}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const replay = await exportVoiceTrace({
|
|
298
|
+
store: runtimeStorage.traces,
|
|
299
|
+
filter: {
|
|
300
|
+
sessionId: 'session-123'
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const report = buildVoiceTraceReplay(replay.events, {
|
|
305
|
+
redact: true,
|
|
306
|
+
title: 'Support call session-123'
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
console.log(report.summary);
|
|
310
|
+
console.log(report.evaluation.pass);
|
|
311
|
+
await Bun.write('trace.html', report.html);
|
|
312
|
+
|
|
313
|
+
await pruneVoiceTraceEvents({
|
|
314
|
+
store: runtimeStorage.traces,
|
|
315
|
+
before: Date.now() - 30 * 24 * 60 * 60 * 1000
|
|
316
|
+
});
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
`createVoiceMemoryTraceEventStore(...)`, `createVoiceFileTraceEventStore(...)`, `createVoiceSQLiteTraceEventStore(...)`, and `createVoicePostgresTraceEventStore(...)` all implement the same `VoiceTraceEventStore` contract. File, SQLite, and Postgres runtime storage expose `runtimeStorage.traces` and `runtimeStorage.traceDeliveries` alongside sessions, reviews, tasks, events, and external object mappings. Passing `trace` to `voice(...)` records session lifecycle, transcript, committed-turn, assistant, cost, and error events; passing it to agents records model passes, tools, results, and handoffs.
|
|
320
|
+
|
|
321
|
+
For self-hosted QA and support workflows, use `summarizeVoiceTrace(...)`, `evaluateVoiceTrace(...)`, `renderVoiceTraceMarkdown(...)`, `renderVoiceTraceHTML(...)`, or `buildVoiceTraceReplay(...)`. They turn raw trace events into portable artifacts you can attach to tickets, inspect locally, or fail in CI when a call has missing transcripts, missing turns, tool errors, session errors, or excessive handoffs.
|
|
322
|
+
|
|
323
|
+
For observability pipelines, wrap any trace store with `createVoiceTraceSinkStore(...)` and pass sinks such as `createVoiceTraceHTTPSink(...)`. The wrapper still writes to your normal file, SQLite, or Postgres store, then fans out appended events to your warehouse, logs, S3 bridge, or analytics endpoint. Use `awaitDelivery: true` only when you want trace delivery to block append completion. For durable delivery, pass `deliveryQueue` and run `createVoiceTraceSinkDeliveryWorker(...)` or `createVoiceTraceSinkDeliveryWorkerLoop(...)`; the worker uses the same Redis lease/idempotency primitives as ops workers and supports retries plus dead-letter stores.
|
|
324
|
+
|
|
325
|
+
When traces may leave your private runtime, pass `redact: true` or a redaction config to `exportVoiceTrace(...)`, `renderVoiceTraceMarkdown(...)`, `renderVoiceTraceHTML(...)`, or `buildVoiceTraceReplay(...)`. The built-in redactor scrubs common email addresses, phone numbers, and sensitive keys like `token`, `secret`, `password`, `apiKey`, `authorization`, `phone`, and `email`; you can pass custom keys or replacement text for stricter policies.
|
|
326
|
+
|
|
327
|
+
For retention jobs, `pruneVoiceTraceEvents(...)` works against any trace store. Use `dryRun: true` before deleting, filter by session, trace, scenario, turn, or event type, cap each run with `limit`, or keep only the newest N matching events with `keepNewest`.
|
|
328
|
+
|
|
329
|
+
## Production Voice Ops
|
|
330
|
+
|
|
331
|
+
The recommended production pattern is:
|
|
332
|
+
|
|
333
|
+
- persistent session storage
|
|
334
|
+
- built-in review recording
|
|
335
|
+
- built-in task creation from call outcomes
|
|
336
|
+
- built-in integration event recording
|
|
337
|
+
|
|
338
|
+
The simplest durable local setup uses `createVoiceFileRuntimeStorage(...)` plus `voice({ ops })`:
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
import { Elysia } from 'elysia';
|
|
342
|
+
import {
|
|
343
|
+
createVoiceCRMActivitySink,
|
|
344
|
+
createVoiceFileRuntimeStorage,
|
|
345
|
+
createVoiceHelpdeskTicketSink,
|
|
346
|
+
resolveVoiceOutcomeRecipe,
|
|
347
|
+
voice
|
|
348
|
+
} from '@absolutejs/voice';
|
|
349
|
+
import { deepgram } from '@absolutejs/voice-deepgram';
|
|
350
|
+
|
|
351
|
+
const runtimeStorage = createVoiceFileRuntimeStorage({
|
|
352
|
+
directory: '.voice-runtime/support'
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const app = new Elysia().use(
|
|
356
|
+
voice({
|
|
357
|
+
path: '/voice',
|
|
358
|
+
preset: 'reliability',
|
|
359
|
+
session: runtimeStorage.session,
|
|
360
|
+
stt: deepgram({
|
|
361
|
+
apiKey: process.env.DEEPGRAM_API_KEY!,
|
|
362
|
+
model: 'flux-general-en'
|
|
363
|
+
}),
|
|
364
|
+
async onTurn({ turn }) {
|
|
365
|
+
if (turn.text.toLowerCase().includes('billing')) {
|
|
366
|
+
return {
|
|
367
|
+
assistantText: 'Transferring to billing.',
|
|
368
|
+
transfer: {
|
|
369
|
+
reason: 'caller-requested-transfer',
|
|
370
|
+
target: 'billing'
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
assistantText: `You said: ${turn.text}`
|
|
377
|
+
};
|
|
378
|
+
},
|
|
379
|
+
onComplete: async () => {},
|
|
380
|
+
ops: {
|
|
381
|
+
...resolveVoiceOutcomeRecipe('support-triage', {
|
|
382
|
+
assignee: 'support-oncall',
|
|
383
|
+
queue: 'support-triage'
|
|
384
|
+
}),
|
|
385
|
+
reviews: runtimeStorage.reviews,
|
|
386
|
+
tasks: runtimeStorage.tasks,
|
|
387
|
+
events: runtimeStorage.events,
|
|
388
|
+
webhook: {
|
|
389
|
+
url: process.env.VOICE_OPS_WEBHOOK_URL!,
|
|
390
|
+
retries: 2,
|
|
391
|
+
backoffMs: 500,
|
|
392
|
+
signingSecret: process.env.VOICE_OPS_WEBHOOK_SECRET
|
|
393
|
+
},
|
|
394
|
+
sinks: [
|
|
395
|
+
createVoiceHelpdeskTicketSink({
|
|
396
|
+
id: 'helpdesk',
|
|
397
|
+
url: process.env.HELPDESK_SYNC_URL!
|
|
398
|
+
}),
|
|
399
|
+
createVoiceCRMActivitySink({
|
|
400
|
+
id: 'crm',
|
|
401
|
+
url: process.env.CRM_SYNC_URL!
|
|
402
|
+
})
|
|
403
|
+
]
|
|
404
|
+
}
|
|
405
|
+
})
|
|
406
|
+
);
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
That gives you:
|
|
410
|
+
|
|
411
|
+
- persisted sessions under `runtimeStorage.session`
|
|
412
|
+
- persisted review artifacts under `runtimeStorage.reviews`
|
|
413
|
+
- persisted follow-up tasks under `runtimeStorage.tasks`
|
|
414
|
+
- persisted integration events under `runtimeStorage.events`
|
|
415
|
+
- persisted vendor object mappings under `runtimeStorage.externalObjects`
|
|
416
|
+
- built-in webhook delivery with persisted delivery status on each event
|
|
417
|
+
- built-in sink fanout with per-sink delivery metadata on each event
|
|
418
|
+
|
|
419
|
+
If you need richer review artifacts, pass `ops.buildReview(...)`. If you need custom task routing, pass `ops.createTaskFromReview(...)`. If you need external sync side effects inside your app, use `ops.onEvent(...)`. If you want built-in outbound delivery, use `ops.webhook`. If you want core-managed CRM/helpdesk fanout, use `ops.sinks` with `createVoiceIntegrationHTTPSink(...)`, `createVoiceHelpdeskTicketSink(...)`, or `createVoiceCRMActivitySink(...)`.
|
|
420
|
+
|
|
421
|
+
For fast production defaults, spread `resolveVoiceOutcomeRecipe(...)` into `ops`. Built-in recipes cover `appointment-booking`, `lead-qualification`, `support-triage`, `voicemail-callback`, and `warm-transfer`; each returns task creation, SLA policies, and urgent routing rules while staying fully self-hosted.
|
|
422
|
+
|
|
423
|
+
For packaged external systems, core now also includes:
|
|
424
|
+
|
|
425
|
+
- `createVoiceZendeskTicketSink(...)`
|
|
426
|
+
- `createVoiceZendeskTicketUpdateSink(...)`
|
|
427
|
+
- `createVoiceZendeskTicketSyncSinks(...)`
|
|
428
|
+
- `createVoiceHubSpotTaskSink(...)`
|
|
429
|
+
- `createVoiceHubSpotTaskUpdateSink(...)`
|
|
430
|
+
- `createVoiceHubSpotTaskSyncSinks(...)`
|
|
431
|
+
- `createVoiceLinearIssueSink(...)`
|
|
432
|
+
- `createVoiceLinearIssueUpdateSink(...)`
|
|
433
|
+
- `createVoiceLinearIssueSyncSinks(...)`
|
|
434
|
+
|
|
435
|
+
Those adapters stick to the documented-safe request shapes:
|
|
436
|
+
|
|
437
|
+
- Zendesk: `POST /api/v2/tickets`
|
|
438
|
+
- Zendesk updates: `PUT /api/v2/tickets/{ticketId}`
|
|
439
|
+
- HubSpot: `POST /crm/v3/objects/tasks`
|
|
440
|
+
- HubSpot updates: `PATCH /crm/v3/objects/tasks/{taskId}`
|
|
441
|
+
- Linear: `issueCreate` over `https://api.linear.app/graphql`
|
|
442
|
+
- Linear updates: `issueUpdate` over `https://api.linear.app/graphql`
|
|
443
|
+
|
|
444
|
+
Create sinks can persist vendor object ids into `runtimeStorage.externalObjects` when you pass `externalObjects` to the adapter. Update sinks first check explicit event payload ids like `zendeskTicketId`, `hubspotTaskId`, or `linearIssueId`, then resolver callbacks like `ticketId`, `taskId`, or `issueId`, then the external object map. If no external id can be resolved, the sink records a skipped delivery instead of accidentally treating an internal AbsoluteJS task id as a vendor object id.
|
|
445
|
+
|
|
446
|
+
Use the `*SyncSinks(...)` helpers when you want create/update parity without hand-wiring two adapters. They return a pair of sinks: a create sink for creation events and an update sink for `task.updated` / `task.sla_breached`, sharing the same credentials, fetch options, and `externalObjects` mapping store.
|
|
447
|
+
|
|
448
|
+
If you want durable non-file runtime storage under Bun, use `createVoiceSQLiteRuntimeStorage(...)` with the same `ops` shape:
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
import { createVoiceSQLiteRuntimeStorage, voice } from '@absolutejs/voice';
|
|
452
|
+
|
|
453
|
+
const runtimeStorage = createVoiceSQLiteRuntimeStorage({
|
|
454
|
+
path: '.voice-runtime/support.sqlite'
|
|
455
|
+
});
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
This uses Bun's native `bun:sqlite` driver directly.
|
|
459
|
+
|
|
460
|
+
If you want production-friendly shared storage, use `createVoicePostgresRuntimeStorage(...)`:
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
import { createVoicePostgresRuntimeStorage, voice } from '@absolutejs/voice';
|
|
464
|
+
|
|
465
|
+
const runtimeStorage = createVoicePostgresRuntimeStorage({
|
|
466
|
+
connectionString: process.env.DATABASE_URL!,
|
|
467
|
+
schemaName: 'voice_ops',
|
|
468
|
+
tablePrefix: 'support'
|
|
469
|
+
});
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
This uses Bun's native `Bun.SQL` client for PostgreSQL.
|
|
473
|
+
|
|
474
|
+
File, SQLite, and Postgres runtime storage expose the same core surfaces: `session`, `reviews`, `tasks`, `events`, and `externalObjects`. Vendor create/update sink mapping works the same way across local demos and production deployments.
|
|
475
|
+
|
|
476
|
+
If you need worker coordination for follow-up tasks, use Bun's native Redis client through `createVoiceRedisTaskLeaseCoordinator(...)`:
|
|
477
|
+
|
|
478
|
+
```ts
|
|
479
|
+
import { createVoiceRedisTaskLeaseCoordinator } from '@absolutejs/voice';
|
|
480
|
+
|
|
481
|
+
const leases = createVoiceRedisTaskLeaseCoordinator({
|
|
482
|
+
url: process.env.REDIS_URL,
|
|
483
|
+
keyPrefix: 'voice:ops'
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const claimed = await leases.claim({
|
|
487
|
+
taskId: 'task-123',
|
|
488
|
+
workerId: 'worker-a',
|
|
489
|
+
leaseMs: 30_000
|
|
490
|
+
});
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
For durable redelivery and idempotent event processing, combine that with `createVoiceRedisIdempotencyStore(...)` and `createVoiceWebhookDeliveryWorker(...)`.
|
|
494
|
+
|
|
495
|
+
If you want a long-running worker loop, use `createVoiceWebhookDeliveryWorkerLoop(...)` and attach a dead-letter store for repeatedly failing events.
|
|
496
|
+
|
|
497
|
+
If you need operator task workers in core, use `createVoiceOpsTaskWorker(...)` for lease-backed claim/heartbeat/complete/requeue flows, or `createVoiceOpsTaskProcessorWorker(...)` when you want a handler-driven queue that records failures, requeues retries, and dead-letters tasks after repeated errors.
|
|
498
|
+
|
|
499
|
+
For task queue observability, use `summarizeVoiceOpsTaskQueue(...)` to report claimed/unclaimed counts, retry-eligible tasks, overdue work, assignee/claim ownership, and dead-letter totals from the same persisted task stores.
|
|
500
|
+
|
|
501
|
+
If you want assignee and worker throughput metrics directly from stored task history, use `summarizeVoiceOpsTaskAnalytics(...)`. It derives:
|
|
502
|
+
|
|
503
|
+
- aging buckets (`fresh`, `aging`, `due-soon`, `overdue`, `stale`)
|
|
504
|
+
- assignee backlog and average completion time
|
|
505
|
+
- worker claim / heartbeat / failure / completion counts
|
|
506
|
+
- total overdue and completed workload
|
|
507
|
+
|
|
508
|
+
If you want outcome-driven SLAs in core, set `ops.taskPolicies` or `ops.resolveTaskPolicy(...)`. Tasks can now carry:
|
|
509
|
+
|
|
510
|
+
- `priority`
|
|
511
|
+
- `dueAt`
|
|
512
|
+
- `policyName`
|
|
513
|
+
- `processingAttempts`
|
|
514
|
+
- `processingError`
|
|
515
|
+
- `deadLetteredAt`
|
|
516
|
+
|
|
517
|
+
The built-in default policies already bias toward real ops behavior:
|
|
518
|
+
|
|
519
|
+
- `escalated` -> urgent, short SLA
|
|
520
|
+
- `failed` -> high priority review
|
|
521
|
+
- `voicemail` -> callback SLA
|
|
522
|
+
- `no-answer` -> retry SLA
|
|
523
|
+
- `transferred` -> verification SLA
|
|
524
|
+
|
|
525
|
+
Policies can also set:
|
|
526
|
+
|
|
527
|
+
- `assignee`
|
|
528
|
+
- `queue`
|
|
529
|
+
- `priority`
|
|
530
|
+
- `dueInMs`
|
|
531
|
+
- `recommendedAction`
|
|
532
|
+
|
|
533
|
+
If you need routing beyond static outcome policies, use `ops.taskAssignmentRules` or `ops.resolveTaskAssignment(...)`. Assignment rules run after task policy resolution, so you can do things like:
|
|
534
|
+
|
|
535
|
+
- route urgent tasks to an on-call queue
|
|
536
|
+
- move high-priority callbacks into a fast-lane pool
|
|
537
|
+
- escalate specific policy lanes to supervisor ownership
|
|
538
|
+
|
|
539
|
+
If you want SLA follow-up automation in core, use `createVoiceOpsRuntime(...).checkSLA()` or configure `sla.followUpTask` on the runtime. Overdue tasks can now:
|
|
540
|
+
|
|
541
|
+
- be marked once with `slaBreachedAt`
|
|
542
|
+
- emit a portable `task.sla_breached` integration event
|
|
543
|
+
- create a secondary follow-up task for supervisors or escalation queues
|
|
544
|
+
|
|
545
|
+
If you want one higher-level core surface instead of wiring review recording, webhook workers, task processors, and queue summaries by hand, use `createVoiceOpsRuntime(...)`:
|
|
546
|
+
|
|
547
|
+
```ts
|
|
548
|
+
import {
|
|
549
|
+
createVoiceCRMActivitySink,
|
|
550
|
+
createVoiceFileRuntimeStorage,
|
|
551
|
+
createVoiceHelpdeskTicketSink,
|
|
552
|
+
createVoiceOpsRuntime,
|
|
553
|
+
createVoiceRedisTaskLeaseCoordinator,
|
|
554
|
+
voice
|
|
555
|
+
} from '@absolutejs/voice';
|
|
556
|
+
|
|
557
|
+
const runtimeStorage = createVoiceFileRuntimeStorage({
|
|
558
|
+
dir: '.voice-runtime/support'
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
const ops = {
|
|
562
|
+
reviews: runtimeStorage.reviews,
|
|
563
|
+
tasks: runtimeStorage.tasks,
|
|
564
|
+
events: runtimeStorage.events,
|
|
565
|
+
sinks: [
|
|
566
|
+
createVoiceHelpdeskTicketSink({
|
|
567
|
+
id: 'helpdesk',
|
|
568
|
+
url: process.env.HELPDESK_SYNC_URL!
|
|
569
|
+
}),
|
|
570
|
+
createVoiceCRMActivitySink({
|
|
571
|
+
id: 'crm',
|
|
572
|
+
url: process.env.CRM_SYNC_URL!
|
|
573
|
+
})
|
|
574
|
+
]
|
|
575
|
+
} as const;
|
|
576
|
+
|
|
577
|
+
const opsRuntime = createVoiceOpsRuntime({
|
|
578
|
+
ops,
|
|
579
|
+
sinks: {
|
|
580
|
+
autoStart: true,
|
|
581
|
+
leases: createVoiceRedisTaskLeaseCoordinator({
|
|
582
|
+
url: process.env.REDIS_URL,
|
|
583
|
+
keyPrefix: 'voice:ops:sinks'
|
|
584
|
+
}),
|
|
585
|
+
maxFailures: 3,
|
|
586
|
+
workerId: 'ops-sink-worker'
|
|
587
|
+
},
|
|
588
|
+
tasks: {
|
|
589
|
+
autoStart: true,
|
|
590
|
+
leases: createVoiceRedisTaskLeaseCoordinator({
|
|
591
|
+
url: process.env.REDIS_URL,
|
|
592
|
+
keyPrefix: 'voice:ops:tasks'
|
|
593
|
+
}),
|
|
594
|
+
maxFailures: 3,
|
|
595
|
+
process: async (task) => {
|
|
596
|
+
if (task.kind === 'callback') {
|
|
597
|
+
// hand off to CRM / dialer / queue
|
|
598
|
+
return { action: 'complete' };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return { action: 'requeue', detail: 'Waiting for a downstream system.' };
|
|
602
|
+
},
|
|
603
|
+
workerId: 'ops-task-worker'
|
|
604
|
+
},
|
|
605
|
+
webhooks: {
|
|
606
|
+
autoStart: true,
|
|
607
|
+
leases: createVoiceRedisTaskLeaseCoordinator({
|
|
608
|
+
url: process.env.REDIS_URL,
|
|
609
|
+
keyPrefix: 'voice:ops:events'
|
|
610
|
+
}),
|
|
611
|
+
retries: 2,
|
|
612
|
+
signingSecret: process.env.VOICE_OPS_WEBHOOK_SECRET,
|
|
613
|
+
url: process.env.VOICE_OPS_WEBHOOK_URL!,
|
|
614
|
+
workerId: 'ops-webhook-worker'
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
opsRuntime.start();
|
|
619
|
+
|
|
620
|
+
app.use(
|
|
621
|
+
voice({
|
|
622
|
+
path: '/voice',
|
|
623
|
+
ops
|
|
624
|
+
})
|
|
625
|
+
);
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
That gives you:
|
|
629
|
+
|
|
630
|
+
- one portable `ops` config for review/task/event recording
|
|
631
|
+
- built-in sink fanout plus sink redelivery workers
|
|
632
|
+
- built-in webhook delivery workers
|
|
633
|
+
- built-in task processor workers
|
|
634
|
+
- unified `tick()`, `start()`, `stop()`, and `summarize()` controls
|
|
635
|
+
- one queue/runtime surface to test and operate
|
|
636
|
+
|
|
637
|
+
If you want opinionated queue routing without handcrafting every assignee/queue/SLA rule, start from `resolveVoiceOpsPreset(...)` and spread the result into your ops runtime:
|
|
638
|
+
|
|
639
|
+
```ts
|
|
640
|
+
import { resolveVoiceOpsPreset } from '@absolutejs/voice';
|
|
641
|
+
|
|
642
|
+
const opsPreset = resolveVoiceOpsPreset('support-default');
|
|
643
|
+
|
|
644
|
+
const opsRuntime = createVoiceOpsRuntime({
|
|
645
|
+
ops: {
|
|
646
|
+
reviews: runtimeStorage.reviews,
|
|
647
|
+
tasks: runtimeStorage.tasks,
|
|
648
|
+
events: runtimeStorage.events,
|
|
649
|
+
taskPolicies: opsPreset.taskPolicies
|
|
650
|
+
},
|
|
651
|
+
sla: opsPreset.sla
|
|
652
|
+
});
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
Built-in presets:
|
|
656
|
+
|
|
657
|
+
- `support-default`
|
|
658
|
+
- `sales-default`
|
|
659
|
+
- `collections-default`
|
|
660
|
+
|
|
661
|
+
Those presets include both:
|
|
662
|
+
|
|
663
|
+
- `taskPolicies`
|
|
664
|
+
- `assignmentRules`
|
|
665
|
+
|
|
666
|
+
If you want larger review artifacts in object storage instead of a local or SQL store, use Bun's native S3 client through `createVoiceS3ReviewStore(...)`.
|
|
667
|
+
|
|
668
|
+
## Production Checklist
|
|
669
|
+
|
|
670
|
+
Use this as the default deployment checklist for a real voice app:
|
|
671
|
+
|
|
672
|
+
- Storage:
|
|
673
|
+
use a shared session store for `session`
|
|
674
|
+
- Runtime ops:
|
|
675
|
+
enable `ops.reviews`, `ops.tasks`, and `ops.events`
|
|
676
|
+
- Review path:
|
|
677
|
+
make stored review artifacts visible somewhere operators can inspect quickly
|
|
678
|
+
- Task path:
|
|
679
|
+
turn non-happy outcomes like `transferred`, `escalated`, `voicemail`, `no-answer`, and `failed` into follow-up work
|
|
680
|
+
- Task policy:
|
|
681
|
+
set `ops.taskPolicies` or `ops.resolveTaskPolicy(...)` so follow-up work gets real priorities and deadlines instead of ad hoc app rules
|
|
682
|
+
- Worker path:
|
|
683
|
+
run Redis-leased task workers for follow-up ops and keep dead-letter queues for tasks that repeatedly fail downstream processing
|
|
684
|
+
- Event path:
|
|
685
|
+
persist `ops.events`, enable `ops.webhook` for outbound delivery, and reserve `ops.onEvent(...)` for app-local side effects
|
|
686
|
+
- STT:
|
|
687
|
+
use the adapter/model pair you have actually benchmarked for the channel you are shipping
|
|
688
|
+
- PSTN:
|
|
689
|
+
prefer the telephony path you have validated live, and keep channel-specific settings in presets instead of ad hoc script overrides
|
|
690
|
+
- Correction:
|
|
691
|
+
keep correction deterministic and domain-safe; do not ship benchmark-shaped seeded aliases as your default public path
|
|
692
|
+
- Observability:
|
|
693
|
+
capture first partial, first commit, first outbound audio, barge-in stop, disposition, and per-turn errors
|
|
694
|
+
- QA:
|
|
695
|
+
run repeated live benchmarks for the channel you care about, not just single-pass smoke checks
|
|
696
|
+
|
|
697
|
+
For the local file-backed starter path, the minimum production-shaped stack is:
|
|
698
|
+
|
|
699
|
+
- `createVoiceFileRuntimeStorage(...)`
|
|
700
|
+
- `voice({ session: runtimeStorage.session, ops: { reviews, tasks, events } })`
|
|
701
|
+
- one review UI
|
|
702
|
+
- one task queue UI
|
|
703
|
+
- one integration-event sink
|
|
704
|
+
|
|
76
705
|
## TTS
|
|
77
706
|
|
|
78
707
|
`@absolutejs/voice` now supports optional assistant audio streaming on the same session path. If you provide a `tts` adapter, `assistantText` responses are still sent as text, and the synthesized PCM chunks are streamed as `audio` messages alongside them.
|