@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.
Files changed (150) hide show
  1. package/CHANGELOG.md +397 -0
  2. package/dist/column-form.d.ts +32 -0
  3. package/dist/column-form.js +42 -0
  4. package/dist/crypto-utils.d.ts +15 -4
  5. package/dist/crypto-utils.js +18 -2
  6. package/dist/data-classification.d.ts +44 -0
  7. package/dist/function/functions.types.d.ts +23 -10
  8. package/dist/function/index.d.ts +1 -1
  9. package/dist/index.d.ts +6 -3
  10. package/dist/index.js +3 -1
  11. package/dist/middleware/auth-bearer.js +2 -1
  12. package/dist/middleware/remote-auth.js +1 -1
  13. package/dist/remote.js +1 -1
  14. package/dist/secret-value.d.ts +56 -0
  15. package/dist/secret-value.js +46 -0
  16. package/dist/services/audit-service.d.ts +74 -4
  17. package/dist/services/audit-service.js +7 -5
  18. package/dist/services/credential-wire-service.d.ts +5 -0
  19. package/dist/services/credential-wire-service.js +9 -1
  20. package/dist/services/email-service.d.ts +2 -1
  21. package/dist/services/in-memory-workflow-service.d.ts +1 -0
  22. package/dist/services/in-memory-workflow-service.js +20 -0
  23. package/dist/services/index.d.ts +3 -3
  24. package/dist/services/index.js +1 -1
  25. package/dist/services/local-content-request-handler.d.ts +29 -0
  26. package/dist/services/local-content-request-handler.js +176 -0
  27. package/dist/services/local-secrets.d.ts +4 -3
  28. package/dist/services/local-secrets.js +7 -3
  29. package/dist/services/logger.d.ts +22 -5
  30. package/dist/services/queue-webhook-service.js +1 -1
  31. package/dist/services/scoped-secret-service.d.ts +4 -3
  32. package/dist/services/secret-service.d.ts +8 -3
  33. package/dist/services/typed-secret-service.d.ts +5 -4
  34. package/dist/services/webhook-service.d.ts +2 -1
  35. package/dist/testing/service-tests.js +6 -6
  36. package/dist/types/core.types.d.ts +25 -4
  37. package/dist/wirings/ai-agent/ai-agent-agui.js +13 -1
  38. package/dist/wirings/ai-agent/ai-agent-prepare.js +7 -1
  39. package/dist/wirings/ai-agent/ai-agent-runner.js +14 -2
  40. package/dist/wirings/ai-agent/ai-agent-stream.js +27 -1
  41. package/dist/wirings/ai-agent/ai-agent.types.d.ts +40 -0
  42. package/dist/wirings/ai-agent/index.d.ts +1 -1
  43. package/dist/wirings/ai-agent/index.js +1 -1
  44. package/dist/wirings/ai-agent/voice-input.d.ts +20 -0
  45. package/dist/wirings/ai-agent/voice-input.js +44 -9
  46. package/dist/wirings/ai-agent/voice-output.d.ts +15 -0
  47. package/dist/wirings/ai-agent/voice-output.js +10 -1
  48. package/dist/wirings/cli/channel/cli-raw-client-runner.d.ts +21 -3
  49. package/dist/wirings/cli/channel/cli-raw-client-runner.js +13 -5
  50. package/dist/wirings/cli/channel/index.d.ts +1 -0
  51. package/dist/wirings/persona/define-personas.d.ts +4 -0
  52. package/dist/wirings/persona/define-personas.js +4 -0
  53. package/dist/wirings/persona/persona.types.d.ts +11 -0
  54. package/dist/wirings/queue/queue-identity.js +2 -1
  55. package/dist/wirings/queue/queue.types.d.ts +2 -1
  56. package/dist/wirings/queue/signed-queue-service.d.ts +2 -1
  57. package/dist/wirings/rpc/remote-addon-auth.d.ts +2 -1
  58. package/dist/wirings/rpc/remote-addon-auth.js +6 -2
  59. package/dist/wirings/virtual-user/index.d.ts +3 -0
  60. package/dist/wirings/virtual-user/index.js +2 -0
  61. package/dist/wirings/virtual-user/prepare-virtual-user-run.d.ts +54 -0
  62. package/dist/wirings/virtual-user/prepare-virtual-user-run.js +49 -0
  63. package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +90 -0
  64. package/dist/wirings/virtual-user/virtual-user-run-store.js +1 -0
  65. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +14 -10
  66. package/dist/wirings/workflow/pikku-scenario-service.js +1 -2
  67. package/dist/wirings/workflow/pikku-workflow-service.d.ts +35 -0
  68. package/dist/wirings/workflow/pikku-workflow-service.js +51 -0
  69. package/dist/wirings/workflow/scenario-prose.js +1 -1
  70. package/dist/wirings/workflow/scenario-step.types.d.ts +13 -7
  71. package/dist/wirings/workflow/workflow.types.d.ts +7 -0
  72. package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +48 -0
  73. package/knowledge/decisions/internals/core-column-form-is-an-axis-of-its-own.md +84 -0
  74. package/knowledge/decisions/internals/core-data-classification-brand-is-an-optional-property.md +9 -2
  75. package/knowledge/decisions/internals/index.md +4 -0
  76. package/knowledge/decisions/internals/one-project-shape-check-two-validators.md +53 -0
  77. package/knowledge/decisions/internals/scenarios-live-in-files-named-for-them.md +48 -0
  78. package/knowledge/decisions/internals/validate-checks-personas-through-a-shared-module.md +43 -0
  79. package/package.json +3 -2
  80. package/src/column-form.test.ts +97 -0
  81. package/src/column-form.ts +58 -0
  82. package/src/crypto-utils.ts +25 -6
  83. package/src/data-classification.ts +44 -0
  84. package/src/function/functions.types.ts +47 -10
  85. package/src/function/index.ts +1 -0
  86. package/src/index.ts +24 -2
  87. package/src/middleware/auth-bearer.test.ts +3 -2
  88. package/src/middleware/auth-bearer.ts +2 -1
  89. package/src/middleware/remote-auth.test.ts +2 -1
  90. package/src/middleware/remote-auth.ts +1 -1
  91. package/src/remote.test.ts +2 -1
  92. package/src/remote.ts +1 -1
  93. package/src/secret-value.test.ts +204 -0
  94. package/src/secret-value.ts +111 -0
  95. package/src/services/audit-service.ts +87 -9
  96. package/src/services/credential-wire-service.ts +9 -1
  97. package/src/services/email-service.ts +3 -1
  98. package/src/services/in-memory-workflow-service.ts +29 -0
  99. package/src/services/index.ts +3 -3
  100. package/src/services/local-content-request-handler.test.ts +202 -0
  101. package/src/services/local-content-request-handler.ts +267 -0
  102. package/src/services/local-secrets.test.ts +20 -5
  103. package/src/services/local-secrets.ts +15 -7
  104. package/src/services/logger.ts +27 -7
  105. package/src/services/queue-webhook-service.test.ts +2 -1
  106. package/src/services/queue-webhook-service.ts +1 -1
  107. package/src/services/scoped-secret-service.ts +4 -3
  108. package/src/services/secret-service.ts +8 -3
  109. package/src/services/typed-secret-service.ts +11 -7
  110. package/src/services/webhook-service.ts +4 -1
  111. package/src/testing/service-tests.ts +6 -6
  112. package/src/types/core.types.ts +25 -4
  113. package/src/wirings/ai-agent/ai-agent-agui.test.ts +16 -0
  114. package/src/wirings/ai-agent/ai-agent-agui.ts +14 -1
  115. package/src/wirings/ai-agent/ai-agent-prepare.ts +7 -1
  116. package/src/wirings/ai-agent/ai-agent-runner.ts +18 -2
  117. package/src/wirings/ai-agent/ai-agent-stream.ts +32 -1
  118. package/src/wirings/ai-agent/ai-agent.types.ts +45 -1
  119. package/src/wirings/ai-agent/index.ts +2 -0
  120. package/src/wirings/ai-agent/voice-input.test.ts +65 -0
  121. package/src/wirings/ai-agent/voice-input.ts +48 -9
  122. package/src/wirings/ai-agent/voice-output.test.ts +91 -1
  123. package/src/wirings/ai-agent/voice-output.ts +28 -1
  124. package/src/wirings/cli/channel/cli-raw-client-runner.ts +39 -9
  125. package/src/wirings/cli/channel/index.ts +4 -0
  126. package/src/wirings/persona/define-personas.ts +4 -0
  127. package/src/wirings/persona/persona.types.ts +11 -0
  128. package/src/wirings/queue/queue-identity.test.ts +2 -1
  129. package/src/wirings/queue/queue-identity.ts +4 -1
  130. package/src/wirings/queue/queue.types.ts +6 -1
  131. package/src/wirings/queue/signed-queue-service.ts +2 -1
  132. package/src/wirings/rpc/remote-addon-auth.ts +8 -3
  133. package/src/wirings/rpc/rpc-runner.test.ts +6 -4
  134. package/src/wirings/virtual-user/index.ts +12 -0
  135. package/src/wirings/virtual-user/prepare-virtual-user-run.test.ts +115 -0
  136. package/src/wirings/virtual-user/prepare-virtual-user-run.ts +95 -0
  137. package/src/wirings/virtual-user/virtual-user-run-store.ts +98 -0
  138. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +14 -16
  139. package/src/wirings/workflow/pikku-scenario-service.ts +1 -10
  140. package/src/wirings/workflow/pikku-workflow-service.ts +67 -0
  141. package/src/wirings/workflow/scenario-prose.test.ts +5 -7
  142. package/src/wirings/workflow/scenario-prose.ts +1 -1
  143. package/src/wirings/workflow/scenario-service.test.ts +0 -1
  144. package/src/wirings/workflow/scenario-step.test.ts +4 -5
  145. package/src/wirings/workflow/scenario-step.types.ts +13 -7
  146. package/src/wirings/workflow/scenario-surface.test.ts +6 -5
  147. package/src/wirings/workflow/workflow-stalled-recovery.test.ts +106 -0
  148. package/src/wirings/workflow/workflow.types.ts +7 -0
  149. package/tsconfig.tsbuildinfo +1 -1
  150. 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
- if (!last || last.role !== 'user' || typeof last.content === 'string') {
89
- return { messages, instructions }
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.some(
115
+ const hasAudio = !!parts?.some(
96
116
  (p) => p.type === 'file' && !!p.mediaType?.startsWith('audio/')
97
117
  )
98
- if (!hasAudio) return { messages, instructions }
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 (services, { event, state, emit, signal }) => {
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 { CorePikkuCLIRender } from '../cli.types.js'
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: CorePikkuCLIRender<any> = (_services, data) => {
35
+ const defaultJSONRenderer: CorePikkuCLIClientRender<any> = (_services, data) => {
14
36
  console.log(JSON.stringify(data))
15
37
  }
16
38
 
17
- /**
18
- * The command ran on the server, so there are no services here — but a renderer
19
- * written for local execution can still reach for `services.logger`.
20
- */
21
- const clientServices = { logger: console } as any
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, CorePikkuCLIRender<any>>
44
- defaultRenderer?: CorePikkuCLIRender<any>
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 (await secrets?.getSecret(QUEUE_IDENTITY_SECRET_NAME)) || undefined
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>(queueName: string, data: T, options?: JobOptions): Promise<string>
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
- if (token === null || token === undefined || token === '') {
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(token)
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) => `secret-value-for-${key}`,
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
+ })