@pikku/core 0.12.34 → 0.12.35

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.
@@ -6,6 +6,8 @@ export {
6
6
  } from './ai-agent-helpers.js'
7
7
  export { runAIAgent, resumeAIAgentSync } from './ai-agent-runner.js'
8
8
  export { streamAIAgent, resumeAIAgent } from './ai-agent-stream.js'
9
+ export { voiceInput } from './voice-input.js'
10
+ export { voiceOutput } from './voice-output.js'
9
11
  export {
10
12
  type RunAIAgentParams,
11
13
  type StreamAIAgentOptions,
@@ -0,0 +1,132 @@
1
+ import type { AIAgentRunnerService } from '../../services/ai-agent-runner-service.js'
2
+ import { pikkuAIMiddleware } from '../../types/core.types.js'
3
+ import type { AIContentPart } from './ai-agent.types.js'
4
+
5
+ function base64ToUint8Array(base64: string): Uint8Array {
6
+ const binary = atob(base64)
7
+ const bytes = new Uint8Array(binary.length)
8
+ for (let i = 0; i < binary.length; i++) {
9
+ bytes[i] = binary.charCodeAt(i)
10
+ }
11
+ return bytes
12
+ }
13
+
14
+ const MAX_AUDIO_SIZE = 50 * 1024 * 1024
15
+
16
+ // Portable SSRF guard: @pikku/core runs in edge runtimes (CF Workers) with no
17
+ // Node `dns`, so we cannot resolve hostnames to check for private targets.
18
+ // Reject the obvious internal literals; callers wanting stricter control pass
19
+ // an explicit `allowedAudioHosts` allowlist. (Does not defend against a public
20
+ // hostname that resolves to a private IP / DNS rebinding — out of reach here.)
21
+ function isPrivateHost(hostname: string): boolean {
22
+ const host = hostname.replace(/^\[|\]$/g, '').toLowerCase()
23
+ if (host === 'localhost' || host === '0.0.0.0' || host === '::1') return true
24
+ if (host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd'))
25
+ return true
26
+ const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.\d{1,3}$/)
27
+ if (v4) {
28
+ const [a, b] = [Number(v4[1]), Number(v4[2])]
29
+ if (a === 127 || a === 10 || a === 0) return true
30
+ if (a === 169 && b === 254) return true // link-local incl. cloud metadata
31
+ if (a === 172 && b >= 16 && b <= 31) return true
32
+ if (a === 192 && b === 168) return true
33
+ }
34
+ return false
35
+ }
36
+
37
+ async function fetchAsUint8Array(
38
+ url: string,
39
+ allowedAudioHosts?: string[]
40
+ ): Promise<Uint8Array> {
41
+ const parsed = new URL(url)
42
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
43
+ throw new Error('Only HTTP(S) URLs are supported for audio')
44
+ }
45
+ if (allowedAudioHosts) {
46
+ if (!allowedAudioHosts.includes(parsed.hostname)) {
47
+ throw new Error(`Audio URL host is not allowed: ${parsed.hostname}`)
48
+ }
49
+ } else if (isPrivateHost(parsed.hostname)) {
50
+ throw new Error(
51
+ `Refusing to fetch audio from a private/internal host: ${parsed.hostname}`
52
+ )
53
+ }
54
+ const response = await fetch(url)
55
+ const contentLength = response.headers.get('content-length')
56
+ if (contentLength && parseInt(contentLength, 10) > MAX_AUDIO_SIZE) {
57
+ throw new Error('Audio file exceeds maximum size')
58
+ }
59
+ const buffer = await response.arrayBuffer()
60
+ if (buffer.byteLength > MAX_AUDIO_SIZE) {
61
+ throw new Error('Audio file exceeds maximum size')
62
+ }
63
+ return new Uint8Array(buffer)
64
+ }
65
+
66
+ export const voiceInput = (config?: {
67
+ language?: string
68
+ model?: string
69
+ allowedAudioHosts?: string[]
70
+ }) =>
71
+ pikkuAIMiddleware({
72
+ modifyInput: async (services, { messages, instructions }) => {
73
+ const transcribeAudio = (services as {
74
+ aiAgentRunner?: AIAgentRunnerService
75
+ }).aiAgentRunner?.transcribe
76
+ if (!transcribeAudio) return { messages, instructions }
77
+
78
+ const last = messages[messages.length - 1]
79
+ if (!last || last.role !== 'user' || typeof last.content === 'string') {
80
+ return { messages, instructions }
81
+ }
82
+
83
+ const parts = last.content as AIContentPart[]
84
+ if (!parts) return { messages, instructions }
85
+
86
+ const hasAudio = parts.some(
87
+ (p) => p.type === 'file' && !!p.mediaType?.startsWith('audio/')
88
+ )
89
+ if (!hasAudio) return { messages, instructions }
90
+
91
+ // Process parts in order, replacing each audio part with its transcription
92
+ // in place. Sequential, so no unbounded parallel downloads/transcriptions
93
+ // and original content ordering is preserved.
94
+ const updatedContent: AIContentPart[] = []
95
+ for (const p of parts) {
96
+ if (!(p.type === 'file' && p.mediaType?.startsWith('audio/'))) {
97
+ updatedContent.push(p)
98
+ continue
99
+ }
100
+ if (!config?.model) {
101
+ throw new Error(
102
+ 'voiceInput requires a transcription model (e.g. openai/whisper-1)'
103
+ )
104
+ }
105
+ const audioData = p.data
106
+ ? base64ToUint8Array(p.data)
107
+ : await fetchAsUint8Array(p.url!, config.allowedAudioHosts)
108
+ const result = await transcribeAudio({
109
+ model: config.model,
110
+ audio: audioData,
111
+ ...(config.language
112
+ ? {
113
+ providerOptions: {
114
+ openai: {
115
+ language: config.language,
116
+ },
117
+ },
118
+ }
119
+ : {}),
120
+ })
121
+ updatedContent.push({ type: 'text' as const, text: result.text })
122
+ }
123
+
124
+ return {
125
+ messages: [
126
+ ...messages.slice(0, -1),
127
+ { ...last, content: updatedContent },
128
+ ],
129
+ instructions,
130
+ }
131
+ },
132
+ })
@@ -0,0 +1,134 @@
1
+ import type { AIAgentRunnerService } from '../../services/ai-agent-runner-service.js'
2
+ import { pikkuAIMiddleware } from '../../types/core.types.js'
3
+
4
+ type VoiceOutputState = {
5
+ textBuffer?: string
6
+ }
7
+
8
+ function bufferToBase64(data: Uint8Array): string {
9
+ let binary = ''
10
+ for (let i = 0; i < data.length; i++) {
11
+ binary += String.fromCharCode(data[i]!)
12
+ }
13
+ return btoa(binary)
14
+ }
15
+
16
+ const SENTENCE_BOUNDARY = /[.!?]\s*$/
17
+
18
+ function isSentenceBoundary(text: string): boolean {
19
+ return SENTENCE_BOUNDARY.test(text)
20
+ }
21
+
22
+ async function synthesizeAudio(
23
+ aiAgentRunner: AIAgentRunnerService,
24
+ input: {
25
+ model: string
26
+ text: string
27
+ voice?: string
28
+ format?: string
29
+ instructions?: string
30
+ speed?: number
31
+ language?: string
32
+ }
33
+ ): Promise<{ bytes: Uint8Array; format: string }> {
34
+ const result = await aiAgentRunner.generateSpeech?.({
35
+ model: input.model,
36
+ text: input.text,
37
+ voice: input.voice,
38
+ outputFormat: input.format,
39
+ instructions: input.instructions,
40
+ speed: input.speed,
41
+ language: input.language,
42
+ })
43
+ if (!result) {
44
+ throw new Error(
45
+ 'voiceOutput requires an aiAgentRunner with generateSpeech support'
46
+ )
47
+ }
48
+ // Label chunks with the format the provider actually returned (config.format
49
+ // is only a request), falling back to the requested format then pcm16.
50
+ return {
51
+ bytes: result.audio.uint8Array,
52
+ format: result.audio.format || input.format || 'pcm16',
53
+ }
54
+ }
55
+
56
+ export const voiceOutput = (config?: {
57
+ model?: string
58
+ format?: string
59
+ voice?: string
60
+ instructions?: string
61
+ speed?: number
62
+ language?: string
63
+ }) =>
64
+ pikkuAIMiddleware<VoiceOutputState>({
65
+ modifyOutputStream: async (services, { event, state }) => {
66
+ const aiAgentRunner = (services as {
67
+ aiAgentRunner?: AIAgentRunnerService
68
+ }).aiAgentRunner
69
+ if (!aiAgentRunner?.generateSpeech) return event
70
+
71
+ if (event.type === 'done') {
72
+ const remaining = state.textBuffer ?? ''
73
+ if (remaining) {
74
+ state.textBuffer = ''
75
+ if (!config?.model) {
76
+ throw new Error(
77
+ 'voiceOutput requires a speech model (e.g. openai/tts-1)'
78
+ )
79
+ }
80
+ const audio = await synthesizeAudio(aiAgentRunner, {
81
+ model: config.model,
82
+ text: remaining,
83
+ voice: config?.voice,
84
+ format: config?.format,
85
+ instructions: config?.instructions,
86
+ speed: config?.speed,
87
+ language: config?.language,
88
+ })
89
+ return [
90
+ {
91
+ type: 'audio-delta' as const,
92
+ data: bufferToBase64(audio.bytes),
93
+ format: audio.format,
94
+ },
95
+ { type: 'audio-done' as const },
96
+ event,
97
+ ]
98
+ }
99
+ return [{ type: 'audio-done' as const }, event]
100
+ }
101
+
102
+ if (event.type !== 'text-delta') return event
103
+
104
+ state.textBuffer = `${state.textBuffer ?? ''}${event.text}`
105
+ if (!isSentenceBoundary(state.textBuffer)) return event
106
+
107
+ const text = state.textBuffer
108
+ state.textBuffer = ''
109
+
110
+ if (!config?.model) {
111
+ throw new Error(
112
+ 'voiceOutput requires a speech model (e.g. openai/tts-1)'
113
+ )
114
+ }
115
+ const audio = await synthesizeAudio(aiAgentRunner, {
116
+ model: config.model,
117
+ text,
118
+ voice: config?.voice,
119
+ format: config?.format,
120
+ instructions: config?.instructions,
121
+ speed: config?.speed,
122
+ language: config?.language,
123
+ })
124
+
125
+ return [
126
+ event,
127
+ {
128
+ type: 'audio-delta' as const,
129
+ data: bufferToBase64(audio.bytes),
130
+ format: audio.format,
131
+ },
132
+ ]
133
+ },
134
+ })