@pikku/core 0.12.74 → 0.12.78
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/CHANGELOG.md +397 -0
- package/dist/column-form.d.ts +32 -0
- package/dist/column-form.js +42 -0
- package/dist/crypto-utils.d.ts +15 -4
- package/dist/crypto-utils.js +18 -2
- package/dist/data-classification.d.ts +44 -0
- package/dist/function/functions.types.d.ts +23 -10
- package/dist/function/index.d.ts +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +3 -1
- package/dist/middleware/auth-bearer.js +2 -1
- package/dist/middleware/remote-auth.js +1 -1
- package/dist/remote.js +1 -1
- package/dist/secret-value.d.ts +56 -0
- package/dist/secret-value.js +46 -0
- package/dist/services/audit-service.d.ts +74 -4
- package/dist/services/audit-service.js +7 -5
- package/dist/services/credential-wire-service.d.ts +5 -0
- package/dist/services/credential-wire-service.js +9 -1
- package/dist/services/email-service.d.ts +2 -1
- package/dist/services/in-memory-workflow-service.d.ts +1 -0
- package/dist/services/in-memory-workflow-service.js +20 -0
- package/dist/services/index.d.ts +3 -3
- package/dist/services/index.js +1 -1
- package/dist/services/local-content-request-handler.d.ts +29 -0
- package/dist/services/local-content-request-handler.js +176 -0
- package/dist/services/local-secrets.d.ts +4 -3
- package/dist/services/local-secrets.js +7 -3
- package/dist/services/logger.d.ts +22 -5
- package/dist/services/queue-webhook-service.js +1 -1
- package/dist/services/scoped-secret-service.d.ts +4 -3
- package/dist/services/secret-service.d.ts +8 -3
- package/dist/services/typed-secret-service.d.ts +5 -4
- package/dist/services/webhook-service.d.ts +2 -1
- package/dist/testing/service-tests.js +6 -6
- package/dist/types/core.types.d.ts +25 -4
- package/dist/wirings/ai-agent/ai-agent-agui.js +13 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +7 -1
- package/dist/wirings/ai-agent/ai-agent-runner.js +14 -2
- package/dist/wirings/ai-agent/ai-agent-stream.js +27 -1
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +40 -0
- package/dist/wirings/ai-agent/index.d.ts +1 -1
- package/dist/wirings/ai-agent/index.js +1 -1
- package/dist/wirings/ai-agent/voice-input.d.ts +20 -0
- package/dist/wirings/ai-agent/voice-input.js +44 -9
- package/dist/wirings/ai-agent/voice-output.d.ts +15 -0
- package/dist/wirings/ai-agent/voice-output.js +10 -1
- package/dist/wirings/cli/channel/cli-raw-client-runner.d.ts +21 -3
- package/dist/wirings/cli/channel/cli-raw-client-runner.js +13 -5
- package/dist/wirings/cli/channel/index.d.ts +1 -0
- package/dist/wirings/persona/define-personas.d.ts +4 -0
- package/dist/wirings/persona/define-personas.js +4 -0
- package/dist/wirings/persona/persona.types.d.ts +11 -0
- package/dist/wirings/queue/queue-identity.js +2 -1
- package/dist/wirings/queue/queue.types.d.ts +2 -1
- package/dist/wirings/queue/signed-queue-service.d.ts +2 -1
- package/dist/wirings/rpc/remote-addon-auth.d.ts +2 -1
- package/dist/wirings/rpc/remote-addon-auth.js +6 -2
- package/dist/wirings/virtual-user/index.d.ts +3 -0
- package/dist/wirings/virtual-user/index.js +2 -0
- package/dist/wirings/virtual-user/prepare-virtual-user-run.d.ts +54 -0
- package/dist/wirings/virtual-user/prepare-virtual-user-run.js +49 -0
- package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +90 -0
- package/dist/wirings/virtual-user/virtual-user-run-store.js +1 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +14 -10
- package/dist/wirings/workflow/pikku-scenario-service.js +1 -2
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +35 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +51 -0
- package/dist/wirings/workflow/scenario-prose.js +1 -1
- package/dist/wirings/workflow/scenario-step.types.d.ts +13 -7
- package/dist/wirings/workflow/workflow.types.d.ts +7 -0
- package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +48 -0
- package/knowledge/decisions/internals/core-column-form-is-an-axis-of-its-own.md +84 -0
- package/knowledge/decisions/internals/core-data-classification-brand-is-an-optional-property.md +9 -2
- package/knowledge/decisions/internals/index.md +4 -0
- package/knowledge/decisions/internals/one-project-shape-check-two-validators.md +53 -0
- package/knowledge/decisions/internals/scenarios-live-in-files-named-for-them.md +48 -0
- package/knowledge/decisions/internals/validate-checks-personas-through-a-shared-module.md +43 -0
- package/package.json +3 -2
- package/src/column-form.test.ts +97 -0
- package/src/column-form.ts +58 -0
- package/src/crypto-utils.ts +25 -6
- package/src/data-classification.ts +44 -0
- package/src/function/functions.types.ts +47 -10
- package/src/function/index.ts +1 -0
- package/src/index.ts +24 -2
- package/src/middleware/auth-bearer.test.ts +3 -2
- package/src/middleware/auth-bearer.ts +2 -1
- package/src/middleware/remote-auth.test.ts +2 -1
- package/src/middleware/remote-auth.ts +1 -1
- package/src/remote.test.ts +2 -1
- package/src/remote.ts +1 -1
- package/src/secret-value.test.ts +204 -0
- package/src/secret-value.ts +111 -0
- package/src/services/audit-service.ts +87 -9
- package/src/services/credential-wire-service.ts +9 -1
- package/src/services/email-service.ts +3 -1
- package/src/services/in-memory-workflow-service.ts +29 -0
- package/src/services/index.ts +3 -3
- package/src/services/local-content-request-handler.test.ts +202 -0
- package/src/services/local-content-request-handler.ts +267 -0
- package/src/services/local-secrets.test.ts +20 -5
- package/src/services/local-secrets.ts +15 -7
- package/src/services/logger.ts +27 -7
- package/src/services/queue-webhook-service.test.ts +2 -1
- package/src/services/queue-webhook-service.ts +1 -1
- package/src/services/scoped-secret-service.ts +4 -3
- package/src/services/secret-service.ts +8 -3
- package/src/services/typed-secret-service.ts +11 -7
- package/src/services/webhook-service.ts +4 -1
- package/src/testing/service-tests.ts +6 -6
- package/src/types/core.types.ts +25 -4
- package/src/wirings/ai-agent/ai-agent-agui.test.ts +16 -0
- package/src/wirings/ai-agent/ai-agent-agui.ts +14 -1
- package/src/wirings/ai-agent/ai-agent-prepare.ts +7 -1
- package/src/wirings/ai-agent/ai-agent-runner.ts +18 -2
- package/src/wirings/ai-agent/ai-agent-stream.ts +32 -1
- package/src/wirings/ai-agent/ai-agent.types.ts +45 -1
- package/src/wirings/ai-agent/index.ts +2 -0
- package/src/wirings/ai-agent/voice-input.test.ts +65 -0
- package/src/wirings/ai-agent/voice-input.ts +48 -9
- package/src/wirings/ai-agent/voice-output.test.ts +91 -1
- package/src/wirings/ai-agent/voice-output.ts +28 -1
- package/src/wirings/cli/channel/cli-raw-client-runner.ts +39 -9
- package/src/wirings/cli/channel/index.ts +4 -0
- package/src/wirings/persona/define-personas.ts +4 -0
- package/src/wirings/persona/persona.types.ts +11 -0
- package/src/wirings/queue/queue-identity.test.ts +2 -1
- package/src/wirings/queue/queue-identity.ts +4 -1
- package/src/wirings/queue/queue.types.ts +6 -1
- package/src/wirings/queue/signed-queue-service.ts +2 -1
- package/src/wirings/rpc/remote-addon-auth.ts +8 -3
- package/src/wirings/rpc/rpc-runner.test.ts +6 -4
- package/src/wirings/virtual-user/index.ts +12 -0
- package/src/wirings/virtual-user/prepare-virtual-user-run.test.ts +115 -0
- package/src/wirings/virtual-user/prepare-virtual-user-run.ts +95 -0
- package/src/wirings/virtual-user/virtual-user-run-store.ts +98 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +14 -16
- package/src/wirings/workflow/pikku-scenario-service.ts +1 -10
- package/src/wirings/workflow/pikku-workflow-service.ts +67 -0
- package/src/wirings/workflow/scenario-prose.test.ts +5 -7
- package/src/wirings/workflow/scenario-prose.ts +1 -1
- package/src/wirings/workflow/scenario-service.test.ts +0 -1
- package/src/wirings/workflow/scenario-step.test.ts +4 -5
- package/src/wirings/workflow/scenario-step.types.ts +13 -7
- package/src/wirings/workflow/scenario-surface.test.ts +6 -5
- package/src/wirings/workflow/workflow-stalled-recovery.test.ts +106 -0
- package/src/wirings/workflow/workflow.types.ts +7 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/tsconfig.type-tests.json +12 -0
|
@@ -70,13 +70,35 @@ async function fetchAsUint8Array(
|
|
|
70
70
|
return new Uint8Array(buffer)
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Shared-notes key recording whether the user's turn arrived as audio.
|
|
75
|
+
*
|
|
76
|
+
* Written here because this is the last point at which it is knowable: the
|
|
77
|
+
* transcript that replaces the audio is indistinguishable from something typed.
|
|
78
|
+
* Read by `voiceOutput` to decide whether to answer aloud.
|
|
79
|
+
*
|
|
80
|
+
* Absent — rather than `false` — when this middleware is not wired at all, which
|
|
81
|
+
* is what lets `voiceOutput` still speak for a caller that has no voice input.
|
|
82
|
+
*/
|
|
83
|
+
export const SPOKEN_TURN = 'voice:spokenTurn'
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Shared-notes key holding what the user was heard to say, when they spoke.
|
|
87
|
+
*
|
|
88
|
+
* Absent on a typed turn. Read by the stream wiring, which forwards it to the
|
|
89
|
+
* client as a `transcript` event — a voice client has no idea what its own
|
|
90
|
+
* audio said, and without this the user's turn shows up in the UI as an empty
|
|
91
|
+
* bubble followed by an answer to a question they cannot see.
|
|
92
|
+
*/
|
|
93
|
+
export const SPOKEN_TRANSCRIPT = 'voice:transcript'
|
|
94
|
+
|
|
73
95
|
export const voiceInput = (config?: {
|
|
74
96
|
language?: string
|
|
75
97
|
model?: string
|
|
76
98
|
allowedAudioHosts?: string[]
|
|
77
99
|
}) =>
|
|
78
100
|
pikkuAIMiddleware({
|
|
79
|
-
modifyInput: async (services, { messages, instructions }) => {
|
|
101
|
+
modifyInput: async (services, { messages, instructions, shared }) => {
|
|
80
102
|
const aiAgentRunner = (
|
|
81
103
|
services as {
|
|
82
104
|
aiAgentRunner?: AIAgentRunnerService
|
|
@@ -85,19 +107,22 @@ export const voiceInput = (config?: {
|
|
|
85
107
|
if (!aiAgentRunner?.transcribe) return { messages, instructions }
|
|
86
108
|
|
|
87
109
|
const last = messages[messages.length - 1]
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const parts = last.content as AIContentPart[]
|
|
93
|
-
if (!parts) return { messages, instructions }
|
|
110
|
+
const parts =
|
|
111
|
+
last?.role === 'user' && typeof last.content !== 'string'
|
|
112
|
+
? (last.content as AIContentPart[] | undefined)
|
|
113
|
+
: undefined
|
|
94
114
|
|
|
95
|
-
const hasAudio = parts
|
|
115
|
+
const hasAudio = !!parts?.some(
|
|
96
116
|
(p) => p.type === 'file' && !!p.mediaType?.startsWith('audio/')
|
|
97
117
|
)
|
|
98
|
-
|
|
118
|
+
// Recorded on every turn this middleware sees, spoken or not, so that a
|
|
119
|
+
// typed turn is an explicit `false` downstream rather than a silence that
|
|
120
|
+
// could equally mean nobody was asked.
|
|
121
|
+
shared[SPOKEN_TURN] = hasAudio
|
|
122
|
+
if (!hasAudio || !parts) return { messages, instructions }
|
|
99
123
|
|
|
100
124
|
const updatedContent: AIContentPart[] = []
|
|
125
|
+
const heard: string[] = []
|
|
101
126
|
for (const p of parts) {
|
|
102
127
|
if (!(p.type === 'file' && p.mediaType?.startsWith('audio/'))) {
|
|
103
128
|
updatedContent.push(p)
|
|
@@ -111,6 +136,9 @@ export const voiceInput = (config?: {
|
|
|
111
136
|
const audioData = p.data
|
|
112
137
|
? base64ToUint8Array(p.data)
|
|
113
138
|
: await fetchAsUint8Array(p.url!, config.allowedAudioHosts)
|
|
139
|
+
if (audioData.byteLength > MAX_AUDIO_SIZE) {
|
|
140
|
+
throw new Error('Audio file exceeds maximum size')
|
|
141
|
+
}
|
|
114
142
|
const result = await aiAgentRunner.transcribe({
|
|
115
143
|
model: config.model,
|
|
116
144
|
audio: audioData,
|
|
@@ -128,6 +156,7 @@ export const voiceInput = (config?: {
|
|
|
128
156
|
// model will answer, and what it answers is a guess about what it could
|
|
129
157
|
// not hear.
|
|
130
158
|
if (readsAsNonSpeech(result)) continue
|
|
159
|
+
heard.push(result.text)
|
|
131
160
|
updatedContent.push({ type: 'text' as const, text: result.text })
|
|
132
161
|
}
|
|
133
162
|
|
|
@@ -135,6 +164,16 @@ export const voiceInput = (config?: {
|
|
|
135
164
|
// to send, and a message with no content is not a question.
|
|
136
165
|
if (updatedContent.length === 0) throw new NoSpeechDetectedError()
|
|
137
166
|
|
|
167
|
+
// Only when something was actually heard. A turn can carry audio that all
|
|
168
|
+
// reads as non-speech and still have content — an image with a silent
|
|
169
|
+
// caption clip — which leaves nothing above to throw. Recording `''` here
|
|
170
|
+
// would send a transcript event saying the user said nothing, and a client
|
|
171
|
+
// that tells "not transcribed yet" from "transcribed" by the key being
|
|
172
|
+
// absent would render the turn as an empty bubble rather than a pending one.
|
|
173
|
+
if (heard.length > 0) {
|
|
174
|
+
shared[SPOKEN_TRANSCRIPT] = heard.join(' ')
|
|
175
|
+
}
|
|
176
|
+
|
|
138
177
|
return {
|
|
139
178
|
messages: [
|
|
140
179
|
...messages.slice(0, -1),
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
voiceForText,
|
|
8
8
|
} from './voice-output.js'
|
|
9
9
|
import type { AIStreamEvent } from './ai-agent.types.js'
|
|
10
|
+
import { SPOKEN_TURN } from './voice-input.js'
|
|
10
11
|
|
|
11
12
|
const KOKORO_SCRIPTS = ['latin', 'devanagari', 'han', 'kana']
|
|
12
13
|
|
|
@@ -33,7 +34,8 @@ const deferred = () => {
|
|
|
33
34
|
*/
|
|
34
35
|
const createStream = (
|
|
35
36
|
mw: ReturnType<typeof voiceOutput>,
|
|
36
|
-
services: unknown
|
|
37
|
+
services: unknown,
|
|
38
|
+
shared: Record<string, unknown> = {}
|
|
37
39
|
) => {
|
|
38
40
|
const state: Record<string, unknown> = {}
|
|
39
41
|
const allEvents: AIStreamEvent[] = []
|
|
@@ -54,6 +56,7 @@ const createStream = (
|
|
|
54
56
|
event,
|
|
55
57
|
allEvents,
|
|
56
58
|
state,
|
|
59
|
+
shared,
|
|
57
60
|
emit: next,
|
|
58
61
|
})
|
|
59
62
|
if (result == null) return
|
|
@@ -314,6 +317,93 @@ describe('voiceOutput', () => {
|
|
|
314
317
|
])
|
|
315
318
|
})
|
|
316
319
|
|
|
320
|
+
test('a typed turn is answered in text only', async () => {
|
|
321
|
+
// The cost case. One agent serves both kinds of caller, so a reply to
|
|
322
|
+
// something typed must not quietly synthesize speech nobody asked to hear
|
|
323
|
+
// — it is billed per sentence, on every turn.
|
|
324
|
+
const asked: string[] = []
|
|
325
|
+
const services = {
|
|
326
|
+
aiAgentRunner: {
|
|
327
|
+
generateSpeech: async ({ text }: { text: string }) => {
|
|
328
|
+
asked.push(text)
|
|
329
|
+
return speechFor(text)
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
}
|
|
333
|
+
const stream = createStream(voiceOutput({ model: 'mock/tts' }), services, {
|
|
334
|
+
[SPOKEN_TURN]: false,
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
await stream.send({ type: 'text-delta', text: 'Added it.' })
|
|
338
|
+
await stream.send({ type: 'done' })
|
|
339
|
+
|
|
340
|
+
assert.deepEqual(asked, [])
|
|
341
|
+
assert.deepEqual(stream.types(), ['text-delta', 'done'])
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
test('a spoken turn is answered aloud', async () => {
|
|
345
|
+
const asked: string[] = []
|
|
346
|
+
const services = {
|
|
347
|
+
aiAgentRunner: {
|
|
348
|
+
generateSpeech: async ({ text }: { text: string }) => {
|
|
349
|
+
asked.push(text)
|
|
350
|
+
return speechFor(text)
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
const stream = createStream(voiceOutput({ model: 'mock/tts' }), services, {
|
|
355
|
+
[SPOKEN_TURN]: true,
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
await stream.send({ type: 'text-delta', text: 'Added it.' })
|
|
359
|
+
await stream.send({ type: 'done' })
|
|
360
|
+
|
|
361
|
+
assert.deepEqual(asked, ['Added it.'])
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
test('`always` speaks a typed turn too', async () => {
|
|
365
|
+
// Read-aloud modes exist, and for them speech is not a reply to speech.
|
|
366
|
+
const asked: string[] = []
|
|
367
|
+
const services = {
|
|
368
|
+
aiAgentRunner: {
|
|
369
|
+
generateSpeech: async ({ text }: { text: string }) => {
|
|
370
|
+
asked.push(text)
|
|
371
|
+
return speechFor(text)
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
}
|
|
375
|
+
const stream = createStream(
|
|
376
|
+
voiceOutput({ model: 'mock/tts', always: true }),
|
|
377
|
+
services,
|
|
378
|
+
{ [SPOKEN_TURN]: false }
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
await stream.send({ type: 'text-delta', text: 'Added it.' })
|
|
382
|
+
await stream.send({ type: 'done' })
|
|
383
|
+
|
|
384
|
+
assert.deepEqual(asked, ['Added it.'])
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
test('speaks when nothing reported how the turn arrived', async () => {
|
|
388
|
+
// voiceOutput without voiceInput: nobody set the note, so there is no
|
|
389
|
+
// evidence the turn was typed and the pre-existing behaviour stands.
|
|
390
|
+
const asked: string[] = []
|
|
391
|
+
const services = {
|
|
392
|
+
aiAgentRunner: {
|
|
393
|
+
generateSpeech: async ({ text }: { text: string }) => {
|
|
394
|
+
asked.push(text)
|
|
395
|
+
return speechFor(text)
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
}
|
|
399
|
+
const stream = createStream(voiceOutput({ model: 'mock/tts' }), services)
|
|
400
|
+
|
|
401
|
+
await stream.send({ type: 'text-delta', text: 'Added it.' })
|
|
402
|
+
await stream.send({ type: 'done' })
|
|
403
|
+
|
|
404
|
+
assert.deepEqual(asked, ['Added it.'])
|
|
405
|
+
})
|
|
406
|
+
|
|
317
407
|
test('a script with no voice mapped is still refused, not mis-voiced', async () => {
|
|
318
408
|
const asked: string[] = []
|
|
319
409
|
const services = {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AIAgentRunnerService } from '../../services/ai-agent-runner-service.js'
|
|
2
2
|
import { pikkuAIMiddleware } from '../../types/core.types.js'
|
|
3
|
+
import { SPOKEN_TURN } from './voice-input.js'
|
|
3
4
|
|
|
4
5
|
type VoiceOutputState = {
|
|
5
6
|
textBuffer?: string
|
|
@@ -178,15 +179,40 @@ export const voiceOutput = (config?: {
|
|
|
178
179
|
* the right default for a provider that genuinely is multilingual.
|
|
179
180
|
*/
|
|
180
181
|
speakableScripts?: SpeakableScripts
|
|
182
|
+
/**
|
|
183
|
+
* Speak every reply, including answers to turns that were typed.
|
|
184
|
+
*
|
|
185
|
+
* The default is to speak only what was spoken to. An agent is usually one
|
|
186
|
+
* agent serving both kinds of caller — the same thread is typed at from a
|
|
187
|
+
* desk and talked to from a phone — and synthesizing a reply nobody is
|
|
188
|
+
* listening to costs real money on every sentence of every turn. Whether the
|
|
189
|
+
* user is in a call is not something the client needs to declare: sending
|
|
190
|
+
* audio is what saying so looks like.
|
|
191
|
+
*
|
|
192
|
+
* Set this for the cases where speech is not a reply to speech at all — a
|
|
193
|
+
* read-aloud accessibility mode, a kiosk, anything whose whole output is
|
|
194
|
+
* meant to be heard.
|
|
195
|
+
*/
|
|
196
|
+
always?: boolean
|
|
181
197
|
}) =>
|
|
182
198
|
pikkuAIMiddleware<VoiceOutputState>({
|
|
183
|
-
modifyOutputStream: async (
|
|
199
|
+
modifyOutputStream: async (
|
|
200
|
+
services,
|
|
201
|
+
{ event, state, shared, emit, signal }
|
|
202
|
+
) => {
|
|
184
203
|
const { aiAgentRunner, logger } = services as {
|
|
185
204
|
aiAgentRunner?: AIAgentRunnerService
|
|
186
205
|
logger?: { error: (message: string) => void }
|
|
187
206
|
}
|
|
188
207
|
if (!aiAgentRunner?.generateSpeech) return event
|
|
189
208
|
|
|
209
|
+
// Only an explicit `false` silences the reply. `voiceInput` sets this on
|
|
210
|
+
// every turn it handles, so `false` means a real user really typed; the
|
|
211
|
+
// key being absent means nothing reported either way — no voice input is
|
|
212
|
+
// wired — and that caller's replies are spoken exactly as they were before
|
|
213
|
+
// this option existed.
|
|
214
|
+
if (!config?.always && shared[SPOKEN_TURN] === false) return event
|
|
215
|
+
|
|
190
216
|
/**
|
|
191
217
|
* Queue a finished sentence for speech.
|
|
192
218
|
*
|
|
@@ -261,6 +287,7 @@ export const voiceOutput = (config?: {
|
|
|
261
287
|
type: 'audio-delta',
|
|
262
288
|
data: bufferToBase64(result.audio.bytes),
|
|
263
289
|
format: result.audio.format,
|
|
290
|
+
text,
|
|
264
291
|
})
|
|
265
292
|
})
|
|
266
293
|
}
|
|
@@ -7,18 +7,48 @@ import type {
|
|
|
7
7
|
Capabilities,
|
|
8
8
|
} from '../../channel/channel-rpc.js'
|
|
9
9
|
import { approverForMode, takeApprovalFlags } from './cli-approval.js'
|
|
10
|
-
import type {
|
|
10
|
+
import type { Logger } from '../../../services/logger.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Everything a renderer gets when the command ran on the server: a logger, and
|
|
14
|
+
* nothing else. There is no container on this side of the socket to resolve a
|
|
15
|
+
* service from, so this is the whole of it rather than a subset of
|
|
16
|
+
* `CoreSingletonServices` — which requires `config`, `variables` and `secrets`
|
|
17
|
+
* the client does not have and cannot invent.
|
|
18
|
+
*/
|
|
19
|
+
export type ClientCLIRenderServices = { logger: Logger }
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A renderer that runs on the client of a CLI-over-channel connection.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately not `CorePikkuCLIRender`: that type's `Services` parameter is
|
|
25
|
+
* constrained to `CoreSingletonServices`, so the honest logger-only shape
|
|
26
|
+
* cannot be spelled through it.
|
|
27
|
+
*/
|
|
28
|
+
export type CorePikkuCLIClientRender<Data> = (
|
|
29
|
+
services: ClientCLIRenderServices,
|
|
30
|
+
data: Data,
|
|
31
|
+
session?: undefined
|
|
32
|
+
) => void | Promise<void>
|
|
11
33
|
|
|
12
34
|
/** The normal case once the server owns the command tree. */
|
|
13
|
-
const defaultJSONRenderer:
|
|
35
|
+
const defaultJSONRenderer: CorePikkuCLIClientRender<any> = (_services, data) => {
|
|
14
36
|
console.log(JSON.stringify(data))
|
|
15
37
|
}
|
|
16
38
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
39
|
+
// `console` is the whole logger a CLI wants — its output is the command's output.
|
|
40
|
+
// `setLevel` is a no-op rather than an omission because `Logger` requires it and
|
|
41
|
+
// there is no level to set: a renderer writes what the user asked to see.
|
|
42
|
+
const clientServices: ClientCLIRenderServices = {
|
|
43
|
+
logger: {
|
|
44
|
+
info: (...args: any[]) => console.info(...(args as [any])),
|
|
45
|
+
warn: (...args: any[]) => console.warn(...(args as [any])),
|
|
46
|
+
error: (...args: any[]) => console.error(...(args as [any])),
|
|
47
|
+
debug: (...args: any[]) => console.debug(...(args as [any])),
|
|
48
|
+
trace: (...args: any[]) => console.trace(...(args as [any])),
|
|
49
|
+
setLevel: () => {},
|
|
50
|
+
},
|
|
51
|
+
}
|
|
22
52
|
|
|
23
53
|
/**
|
|
24
54
|
* Runs a CLI command entirely on the server. Unlike `executeCLIViaChannel`,
|
|
@@ -40,8 +70,8 @@ export async function executeRawCLIViaChannel({
|
|
|
40
70
|
}: {
|
|
41
71
|
pikkuWS: any // CorePikkuWebsocket instance
|
|
42
72
|
args?: string[]
|
|
43
|
-
renderers?: Record<string,
|
|
44
|
-
defaultRenderer?:
|
|
73
|
+
renderers?: Record<string, CorePikkuCLIClientRender<any>>
|
|
74
|
+
defaultRenderer?: CorePikkuCLIClientRender<any>
|
|
45
75
|
capabilities?: Capabilities
|
|
46
76
|
approve?: ApprovalRequester
|
|
47
77
|
}): Promise<number> {
|
|
@@ -2,6 +2,10 @@ export { executeCLIViaChannel } from './cli-channel-runner.js'
|
|
|
2
2
|
export { handleRawCLI } from './cli-raw-channel-runner.js'
|
|
3
3
|
export type { RawCLIFrame, RawCLIResult } from './cli-raw-channel-runner.js'
|
|
4
4
|
export { executeRawCLIViaChannel } from './cli-raw-client-runner.js'
|
|
5
|
+
export type {
|
|
6
|
+
ClientCLIRenderServices,
|
|
7
|
+
CorePikkuCLIClientRender,
|
|
8
|
+
} from './cli-raw-client-runner.js'
|
|
5
9
|
export {
|
|
6
10
|
APPROVAL_FLAGS,
|
|
7
11
|
approverForMode,
|
|
@@ -11,6 +11,10 @@ import type { CorePersonas } from './persona.types.js'
|
|
|
11
11
|
* running: a persona that exists only to be acted upon (the account an admin
|
|
12
12
|
* bans, the colleague a document is shared with) is seeded and never run.
|
|
13
13
|
*
|
|
14
|
+
* Exactly one `definePersonas(...)` is allowed per codebase, so there is one
|
|
15
|
+
* place to read the cast from and one place to add to. A second call — even in
|
|
16
|
+
* the same file — fails the build.
|
|
17
|
+
*
|
|
14
18
|
* @example
|
|
15
19
|
* ```typescript
|
|
16
20
|
* definePersonas({
|
|
@@ -38,6 +38,16 @@ export type CorePersona = {
|
|
|
38
38
|
name: string
|
|
39
39
|
jobTitle?: string
|
|
40
40
|
description?: string
|
|
41
|
+
/**
|
|
42
|
+
* A picture of this person, as any URL a browser can load: a file your app
|
|
43
|
+
* serves, an asset on a CDN, a Gravatar link.
|
|
44
|
+
*
|
|
45
|
+
* Optional because it is decoration. Omitted, the console draws a colour and
|
|
46
|
+
* an icon derived from the persona's id, which is stable, needs no network and
|
|
47
|
+
* is more legible than the identicon a synthetic address would resolve to —
|
|
48
|
+
* which is also why nothing is derived here.
|
|
49
|
+
*/
|
|
50
|
+
avatarUrl?: string
|
|
41
51
|
/**
|
|
42
52
|
* The roles this person holds. Only system roles may be named — a custom
|
|
43
53
|
* role can be deleted from the console, so a persona pinned to one silently
|
|
@@ -112,6 +122,7 @@ export type PersonaMeta = {
|
|
|
112
122
|
name: string
|
|
113
123
|
jobTitle?: string
|
|
114
124
|
description?: string
|
|
125
|
+
avatarUrl?: string
|
|
115
126
|
roles: string[]
|
|
116
127
|
personality?: string
|
|
117
128
|
goals: string[]
|
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
QueueJob,
|
|
22
22
|
QueueService,
|
|
23
23
|
} from './queue.types.js'
|
|
24
|
+
import { createSecretValue } from '../../secret-value.js'
|
|
24
25
|
|
|
25
26
|
const QUEUE_NAME = 'identity-queue'
|
|
26
27
|
const SECRET = 'a'.repeat(48)
|
|
@@ -49,7 +50,7 @@ const createSecretService = (secrets: Record<string, string>) =>
|
|
|
49
50
|
if (value === undefined) {
|
|
50
51
|
throw new Error(`Secret not found: ${key}`)
|
|
51
52
|
}
|
|
52
|
-
return value
|
|
53
|
+
return createSecretValue(value)
|
|
53
54
|
},
|
|
54
55
|
hasSecret: async (key: string) => secrets[key] !== undefined,
|
|
55
56
|
setSecret: async () => {},
|
|
@@ -59,7 +59,10 @@ export const getQueueIdentitySecret = async (
|
|
|
59
59
|
secrets?: SecretService
|
|
60
60
|
): Promise<string | undefined> => {
|
|
61
61
|
try {
|
|
62
|
-
return (
|
|
62
|
+
return (
|
|
63
|
+
(await secrets?.getSecret(QUEUE_IDENTITY_SECRET_NAME))?.reveal() ||
|
|
64
|
+
undefined
|
|
65
|
+
)
|
|
63
66
|
} catch {
|
|
64
67
|
return undefined
|
|
65
68
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CommonWireMeta } from '../../types/core.types.js'
|
|
2
2
|
import type { CorePikkuFunctionConfig } from '../../function/functions.types.js'
|
|
3
3
|
import type { QueueConfigMapping } from './validate-worker-config.js'
|
|
4
|
+
import type { Safe } from '../../secret-value.js'
|
|
4
5
|
|
|
5
6
|
export interface PikkuWorkerConfig {
|
|
6
7
|
name?: string
|
|
@@ -107,7 +108,11 @@ export interface JobOptions {
|
|
|
107
108
|
export interface QueueService {
|
|
108
109
|
readonly supportsResults: boolean
|
|
109
110
|
|
|
110
|
-
add<T>(
|
|
111
|
+
add<T>(
|
|
112
|
+
queueName: string,
|
|
113
|
+
data: Safe<T>,
|
|
114
|
+
options?: JobOptions
|
|
115
|
+
): Promise<string>
|
|
111
116
|
|
|
112
117
|
getJob<T, R>(queueName: string, jobId: string): Promise<QueueJob<T, R> | null>
|
|
113
118
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Safe } from '../../secret-value.js'
|
|
1
2
|
import type { Logger } from '../../services/logger.js'
|
|
2
3
|
import type { SecretService } from '../../services/secret-service.js'
|
|
3
4
|
import {
|
|
@@ -24,7 +25,7 @@ export class SignedQueueService implements QueueService {
|
|
|
24
25
|
|
|
25
26
|
public async add<T>(
|
|
26
27
|
queueName: string,
|
|
27
|
-
data: T
|
|
28
|
+
data: Safe<T>,
|
|
28
29
|
options?: JobOptions
|
|
29
30
|
): Promise<string> {
|
|
30
31
|
const pikkuUserId = options?.pikkuUserId
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CoreServices, PikkuRawWire } from '../../types/core.types.js'
|
|
2
2
|
import { PikkuError, addError } from '../../errors/error-handler.js'
|
|
3
|
+
import { isSecretValue, type SecretValue } from '../../secret-value.js'
|
|
3
4
|
|
|
4
5
|
export type RemoteAddonAuthBinding =
|
|
5
6
|
| { credentialId: string }
|
|
@@ -8,7 +9,7 @@ export type RemoteAddonAuthBinding =
|
|
|
8
9
|
resolve: (
|
|
9
10
|
services: CoreServices,
|
|
10
11
|
wire: PikkuRawWire
|
|
11
|
-
) => string | Promise<string
|
|
12
|
+
) => string | SecretValue<string> | Promise<string | SecretValue<string>>
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export class RemoteAddonAuthError extends PikkuError {
|
|
@@ -49,9 +50,13 @@ export async function resolveRemoteAddonToken(
|
|
|
49
50
|
token = await auth.resolve(services, wire)
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
// The wire is where a secret is meant to end up, so unwrap it here rather
|
|
54
|
+
// than at each branch — `resolve` and `getCredential` may hand one back too.
|
|
55
|
+
const resolved = isSecretValue(token) ? token.reveal() : token
|
|
56
|
+
|
|
57
|
+
if (resolved === null || resolved === undefined || resolved === '') {
|
|
53
58
|
throw new RemoteAddonAuthError(namespace, 'resolved token was empty')
|
|
54
59
|
}
|
|
55
60
|
|
|
56
|
-
return String(
|
|
61
|
+
return String(resolved)
|
|
57
62
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { RemoteAddonAuthError } from './remote-addon-auth.js'
|
|
14
14
|
import { wireAddon } from './wire-addon.js'
|
|
15
15
|
import { wireRemoteAddon } from './wire-remote-addon.js'
|
|
16
|
+
import { createSecretValue } from '../../secret-value.js'
|
|
16
17
|
|
|
17
18
|
const createLogger = () => ({
|
|
18
19
|
debug: () => {},
|
|
@@ -690,7 +691,7 @@ describe('ContextAwareRPCService.remote', () => {
|
|
|
690
691
|
|
|
691
692
|
describe('multi-instance addons', () => {
|
|
692
693
|
const createSecretService = () => ({
|
|
693
|
-
getSecret: async (key: string) => `secret:${key}
|
|
694
|
+
getSecret: async (key: string) => createSecretValue(`secret:${key}`),
|
|
694
695
|
hasSecret: async () => true,
|
|
695
696
|
setSecret: async () => {},
|
|
696
697
|
deleteSecret: async () => {},
|
|
@@ -701,7 +702,7 @@ describe('multi-instance addons', () => {
|
|
|
701
702
|
const createdServices: any[] = []
|
|
702
703
|
pikkuState('@addon/slack', 'package', 'factories', {
|
|
703
704
|
createSingletonServices: async (_config: any, parent: any) => {
|
|
704
|
-
const token = await parent.secrets.getSecret('slackToken')
|
|
705
|
+
const token = (await parent.secrets.getSecret('slackToken')).reveal()
|
|
705
706
|
const svc = { logger: createLogger(), token }
|
|
706
707
|
createdServices.push(svc)
|
|
707
708
|
return svc
|
|
@@ -748,7 +749,7 @@ describe('multi-instance addons', () => {
|
|
|
748
749
|
factoryCalls++
|
|
749
750
|
return {
|
|
750
751
|
logger: createLogger(),
|
|
751
|
-
token: await parent.secrets.getSecret('slackToken'),
|
|
752
|
+
token: (await parent.secrets.getSecret('slackToken')).reveal(),
|
|
752
753
|
}
|
|
753
754
|
},
|
|
754
755
|
} as never)
|
|
@@ -854,7 +855,8 @@ describe('wireRemoteAddon dispatch', () => {
|
|
|
854
855
|
|
|
855
856
|
const secretService = () =>
|
|
856
857
|
({
|
|
857
|
-
getSecret: async (key: string) =>
|
|
858
|
+
getSecret: async (key: string) =>
|
|
859
|
+
createSecretValue(`secret-value-for-${key}`),
|
|
858
860
|
getSecrets: async () => ({}),
|
|
859
861
|
hasSecret: async () => true,
|
|
860
862
|
setSecret: async () => {},
|
|
@@ -31,12 +31,24 @@ export type {
|
|
|
31
31
|
VirtualUserTally,
|
|
32
32
|
VirtualUserTarget,
|
|
33
33
|
} from './virtual-user.types.js'
|
|
34
|
+
export { PRODUCTION_DISPOSITION } from './virtual-user.types.js'
|
|
34
35
|
export {
|
|
35
36
|
runVirtualUser,
|
|
36
37
|
rememberIds,
|
|
37
38
|
type RunVirtualUserParams,
|
|
38
39
|
type VirtualUserCallContext,
|
|
39
40
|
} from './run-virtual-user.js'
|
|
41
|
+
export {
|
|
42
|
+
personaScopes,
|
|
43
|
+
prepareVirtualUserRun,
|
|
44
|
+
type VirtualUserPreparation,
|
|
45
|
+
} from './prepare-virtual-user-run.js'
|
|
46
|
+
export type {
|
|
47
|
+
VirtualUserRunOutcome,
|
|
48
|
+
VirtualUserRunRecord,
|
|
49
|
+
VirtualUserRunStart,
|
|
50
|
+
VirtualUserRunStore,
|
|
51
|
+
} from './virtual-user-run-store.js'
|
|
40
52
|
export {
|
|
41
53
|
DISPOSITIONS,
|
|
42
54
|
dispositionProfile,
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {
|
|
4
|
+
personaScopes,
|
|
5
|
+
prepareVirtualUserRun,
|
|
6
|
+
} from './prepare-virtual-user-run.js'
|
|
7
|
+
|
|
8
|
+
const ROLE_DEFINITIONS = [
|
|
9
|
+
{ name: 'editor', scopes: ['docs:write', 'docs:read'] },
|
|
10
|
+
{ name: 'viewer', scopes: ['docs:read'] },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const FUNCTIONS_META = {
|
|
14
|
+
listDocs: { pikkuFuncName: 'listDocs', services: [], expose: true },
|
|
15
|
+
deleteDoc: { pikkuFuncName: 'deleteDoc', services: [], expose: true },
|
|
16
|
+
} as any
|
|
17
|
+
|
|
18
|
+
describe('personaScopes', () => {
|
|
19
|
+
test('expands the roles a persona declares into the scopes functions check', () => {
|
|
20
|
+
const scopes = personaScopes(
|
|
21
|
+
{ roles: ['editor'] },
|
|
22
|
+
{
|
|
23
|
+
editor: ['docs:write', 'docs:read'],
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
assert.deepEqual(scopes, ['docs:read', 'docs:write'])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('a persona with no roles holds no scopes', () => {
|
|
30
|
+
assert.deepEqual(personaScopes({}, { editor: ['docs:write'] }), [])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// Two roles granting the same scope is ordinary, and the catalogue narrowing
|
|
34
|
+
// downstream compares scopes as a set.
|
|
35
|
+
test('de-duplicates overlapping roles', () => {
|
|
36
|
+
const scopes = personaScopes(
|
|
37
|
+
{ roles: ['editor', 'viewer'] },
|
|
38
|
+
{
|
|
39
|
+
editor: ['docs:write', 'docs:read'],
|
|
40
|
+
viewer: ['docs:read'],
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
assert.deepEqual(scopes, ['docs:read', 'docs:write'])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// An undeclared role granting everything would be the worst possible default.
|
|
47
|
+
test('an unknown role grants nothing', () => {
|
|
48
|
+
assert.deepEqual(personaScopes({ roles: ['ghost'] }, {}), [])
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('prepareVirtualUserRun', () => {
|
|
53
|
+
// The CLI reads the inspector state, which holds an array; the scaffolded RPC
|
|
54
|
+
// reads metaService, which hands the same definitions back keyed by name. Both
|
|
55
|
+
// callers must land on the same scopes or the same persona and seed explore a
|
|
56
|
+
// different API depending on how the run was started.
|
|
57
|
+
test('accepts role definitions as an array or keyed by name, identically', () => {
|
|
58
|
+
const asArray = prepareVirtualUserRun({
|
|
59
|
+
persona: { roles: ['editor'] },
|
|
60
|
+
functionsMeta: FUNCTIONS_META,
|
|
61
|
+
systemRoles: ROLE_DEFINITIONS as any,
|
|
62
|
+
})
|
|
63
|
+
const asRecord = prepareVirtualUserRun({
|
|
64
|
+
persona: { roles: ['editor'] },
|
|
65
|
+
functionsMeta: FUNCTIONS_META,
|
|
66
|
+
systemRoles: {
|
|
67
|
+
editor: ROLE_DEFINITIONS[0],
|
|
68
|
+
viewer: ROLE_DEFINITIONS[1],
|
|
69
|
+
} as any,
|
|
70
|
+
})
|
|
71
|
+
assert.deepEqual(asArray.scopes, asRecord.scopes)
|
|
72
|
+
assert.deepEqual(asArray.scopes, ['docs:read', 'docs:write'])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('derives the catalogue from the function meta', () => {
|
|
76
|
+
const { catalogue } = prepareVirtualUserRun({
|
|
77
|
+
persona: {},
|
|
78
|
+
functionsMeta: FUNCTIONS_META,
|
|
79
|
+
})
|
|
80
|
+
assert.deepEqual(catalogue.map((entry) => entry.name).sort(), [
|
|
81
|
+
'deleteDoc',
|
|
82
|
+
'listDocs',
|
|
83
|
+
])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// An agent is reached rather than declared: its scopes are checked against the
|
|
87
|
+
// session, so a persona must find the specialists its roles unlock and no
|
|
88
|
+
// others.
|
|
89
|
+
test('narrows agents to the ones the persona s scopes reach', () => {
|
|
90
|
+
const agentsMeta = {
|
|
91
|
+
helper: { scopes: [] },
|
|
92
|
+
auditor: { scopes: ['docs:audit'] },
|
|
93
|
+
} as any
|
|
94
|
+
|
|
95
|
+
const { agents } = prepareVirtualUserRun({
|
|
96
|
+
persona: { roles: ['viewer'] },
|
|
97
|
+
functionsMeta: FUNCTIONS_META,
|
|
98
|
+
systemRoles: ROLE_DEFINITIONS as any,
|
|
99
|
+
agentsMeta,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
assert.deepEqual(
|
|
103
|
+
agents.map((agent) => agent.name),
|
|
104
|
+
['helper']
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('a project with no scenarios simply has no intents', () => {
|
|
109
|
+
const { intents } = prepareVirtualUserRun({
|
|
110
|
+
persona: {},
|
|
111
|
+
functionsMeta: FUNCTIONS_META,
|
|
112
|
+
})
|
|
113
|
+
assert.deepEqual(intents, [])
|
|
114
|
+
})
|
|
115
|
+
})
|