@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
@@ -163,6 +163,23 @@ export interface PikkuAIMiddlewareHooks<State extends Record<string, unknown> =
163
163
  modifyInput?: (services: Services, ctx: {
164
164
  messages: AIMessage[];
165
165
  instructions: string;
166
+ /**
167
+ * Notes about this run that every middleware can read and write, as
168
+ * opposed to {@link modifyOutputStream}'s `state`, which is private to
169
+ * one middleware.
170
+ *
171
+ * It exists because middlewares transform the turn for each other, and
172
+ * the transformation can destroy what a later one needed to know. Voice
173
+ * is the case in point: `voiceInput` replaces the user's audio with its
174
+ * transcript, so by the time anything downstream looks, the turn is
175
+ * indistinguishable from one that was typed — and whether it was typed is
176
+ * exactly what `voiceOutput` needs in order to decide whether to answer
177
+ * aloud. Only the middleware that consumed the audio can still say so.
178
+ *
179
+ * Namespace what you put here (`voice:spokenTurn`); it is one bag for
180
+ * everybody. Cleared between runs.
181
+ */
182
+ shared: Record<string, unknown>;
166
183
  }) => Promise<{
167
184
  messages: AIMessage[];
168
185
  instructions: string;
@@ -173,7 +190,10 @@ export interface PikkuAIMiddlewareHooks<State extends Record<string, unknown> =
173
190
  modifyOutputStream?: (services: Services, ctx: {
174
191
  event: AIStreamEvent;
175
192
  allEvents: readonly AIStreamEvent[];
193
+ /** Private to this middleware, for this run. Cross-middleware facts go in `shared`. */
176
194
  state: State;
195
+ /** Per-run notes shared with every middleware — see {@link modifyInput}. */
196
+ shared: Record<string, unknown>;
177
197
  /**
178
198
  * Push an event into the stream *after* this call has returned.
179
199
  *
@@ -412,12 +432,32 @@ export type AIStreamEvent = {
412
432
  type: 'audio-delta';
413
433
  data: string;
414
434
  format: string;
435
+ /**
436
+ * The sentence this audio says.
437
+ *
438
+ * Carried alongside the bytes because a client that gets talked over has
439
+ * to tell the model what the user actually heard, and playback position
440
+ * is the only place that is knowable. Without it a barge-in can stop the
441
+ * sound but not report it, and the next turn is answered as though the
442
+ * whole reply had landed.
443
+ */
444
+ text?: string;
415
445
  agent?: string;
416
446
  session?: string;
417
447
  } | {
418
448
  type: 'audio-done';
419
449
  agent?: string;
420
450
  session?: string;
451
+ } | {
452
+ /**
453
+ * What the user was heard to say, sent once at the start of a spoken
454
+ * turn. Only the server knows this — the client sent audio — and a chat
455
+ * surface needs it to show the user's own message.
456
+ */
457
+ type: 'transcript';
458
+ text: string;
459
+ agent?: string;
460
+ session?: string;
421
461
  } | {
422
462
  type: 'data';
423
463
  name: string;
@@ -2,7 +2,7 @@ export { agent, agentStream, agentResume, agentApprove, agentInterrupt, } from '
2
2
  export { wrapChannelWithAGUI, type AGUIEvent } from './ai-agent-agui.js';
3
3
  export { runAIAgent, resumeAIAgentSync } from './ai-agent-runner.js';
4
4
  export { streamAIAgent, resumeAIAgent, interruptAIAgent, } from './ai-agent-stream.js';
5
- export { voiceInput, readsAsNonSpeech, NoSpeechDetectedError, } from './voice-input.js';
5
+ export { voiceInput, readsAsNonSpeech, NoSpeechDetectedError, SPOKEN_TURN, SPOKEN_TRANSCRIPT, } from './voice-input.js';
6
6
  export { voiceOutput, unspeakableScripts, voiceForText, type SpeakableScripts, } from './voice-output.js';
7
7
  export { AgentInterruptedError, awaitPendingInterruptNote, getInFlightTools, isAbortError, isRunInterruptible, persistOrphanedToolResults, registerInterruptibleRun, signalRunInterrupt, trackInterruptNote, trackToolExecution, } from './ai-agent-interrupt.js';
8
8
  export type { AgentInterruption, AgentInterruptResult, InterruptibleRunHandle, OrphanedToolResult, } from './ai-agent-interrupt.js';
@@ -2,7 +2,7 @@ export { agent, agentStream, agentResume, agentApprove, agentInterrupt, } from '
2
2
  export { wrapChannelWithAGUI } from './ai-agent-agui.js';
3
3
  export { runAIAgent, resumeAIAgentSync } from './ai-agent-runner.js';
4
4
  export { streamAIAgent, resumeAIAgent, interruptAIAgent, } from './ai-agent-stream.js';
5
- export { voiceInput, readsAsNonSpeech, NoSpeechDetectedError, } from './voice-input.js';
5
+ export { voiceInput, readsAsNonSpeech, NoSpeechDetectedError, SPOKEN_TURN, SPOKEN_TRANSCRIPT, } from './voice-input.js';
6
6
  export { voiceOutput, unspeakableScripts, voiceForText, } from './voice-output.js';
7
7
  export { AgentInterruptedError, awaitPendingInterruptNote, getInFlightTools, isAbortError, isRunInterruptible, persistOrphanedToolResults, registerInterruptibleRun, signalRunInterrupt, trackInterruptNote, trackToolExecution, } from './ai-agent-interrupt.js';
8
8
  export { ToolApprovalRequired, ToolCredentialRequired, canAccessThread, isOwnedByPrincipal, sessionPrincipals, threadOwnerConstraint, } from './ai-agent-prepare.js';
@@ -34,6 +34,26 @@ export declare class NoSpeechDetectedError extends Error {
34
34
  export declare const readsAsNonSpeech: (result: {
35
35
  text?: string;
36
36
  }) => boolean;
37
+ /**
38
+ * Shared-notes key recording whether the user's turn arrived as audio.
39
+ *
40
+ * Written here because this is the last point at which it is knowable: the
41
+ * transcript that replaces the audio is indistinguishable from something typed.
42
+ * Read by `voiceOutput` to decide whether to answer aloud.
43
+ *
44
+ * Absent — rather than `false` — when this middleware is not wired at all, which
45
+ * is what lets `voiceOutput` still speak for a caller that has no voice input.
46
+ */
47
+ export declare const SPOKEN_TURN = "voice:spokenTurn";
48
+ /**
49
+ * Shared-notes key holding what the user was heard to say, when they spoke.
50
+ *
51
+ * Absent on a typed turn. Read by the stream wiring, which forwards it to the
52
+ * client as a `transcript` event — a voice client has no idea what its own
53
+ * audio said, and without this the user's turn shows up in the UI as an empty
54
+ * bubble followed by an answer to a question they cannot see.
55
+ */
56
+ export declare const SPOKEN_TRANSCRIPT = "voice:transcript";
37
57
  export declare const voiceInput: (config?: {
38
58
  language?: string;
39
59
  model?: string;
@@ -58,22 +58,44 @@ async function fetchAsUint8Array(url, allowedAudioHosts) {
58
58
  }
59
59
  return new Uint8Array(buffer);
60
60
  }
61
+ /**
62
+ * Shared-notes key recording whether the user's turn arrived as audio.
63
+ *
64
+ * Written here because this is the last point at which it is knowable: the
65
+ * transcript that replaces the audio is indistinguishable from something typed.
66
+ * Read by `voiceOutput` to decide whether to answer aloud.
67
+ *
68
+ * Absent — rather than `false` — when this middleware is not wired at all, which
69
+ * is what lets `voiceOutput` still speak for a caller that has no voice input.
70
+ */
71
+ export const SPOKEN_TURN = 'voice:spokenTurn';
72
+ /**
73
+ * Shared-notes key holding what the user was heard to say, when they spoke.
74
+ *
75
+ * Absent on a typed turn. Read by the stream wiring, which forwards it to the
76
+ * client as a `transcript` event — a voice client has no idea what its own
77
+ * audio said, and without this the user's turn shows up in the UI as an empty
78
+ * bubble followed by an answer to a question they cannot see.
79
+ */
80
+ export const SPOKEN_TRANSCRIPT = 'voice:transcript';
61
81
  export const voiceInput = (config) => pikkuAIMiddleware({
62
- modifyInput: async (services, { messages, instructions }) => {
82
+ modifyInput: async (services, { messages, instructions, shared }) => {
63
83
  const aiAgentRunner = services.aiAgentRunner;
64
84
  if (!aiAgentRunner?.transcribe)
65
85
  return { messages, instructions };
66
86
  const last = messages[messages.length - 1];
67
- if (!last || last.role !== 'user' || typeof last.content === 'string') {
68
- return { messages, instructions };
69
- }
70
- const parts = last.content;
71
- if (!parts)
72
- return { messages, instructions };
73
- const hasAudio = parts.some((p) => p.type === 'file' && !!p.mediaType?.startsWith('audio/'));
74
- if (!hasAudio)
87
+ const parts = last?.role === 'user' && typeof last.content !== 'string'
88
+ ? last.content
89
+ : undefined;
90
+ const hasAudio = !!parts?.some((p) => p.type === 'file' && !!p.mediaType?.startsWith('audio/'));
91
+ // Recorded on every turn this middleware sees, spoken or not, so that a
92
+ // typed turn is an explicit `false` downstream rather than a silence that
93
+ // could equally mean nobody was asked.
94
+ shared[SPOKEN_TURN] = hasAudio;
95
+ if (!hasAudio || !parts)
75
96
  return { messages, instructions };
76
97
  const updatedContent = [];
98
+ const heard = [];
77
99
  for (const p of parts) {
78
100
  if (!(p.type === 'file' && p.mediaType?.startsWith('audio/'))) {
79
101
  updatedContent.push(p);
@@ -85,6 +107,9 @@ export const voiceInput = (config) => pikkuAIMiddleware({
85
107
  const audioData = p.data
86
108
  ? base64ToUint8Array(p.data)
87
109
  : await fetchAsUint8Array(p.url, config.allowedAudioHosts);
110
+ if (audioData.byteLength > MAX_AUDIO_SIZE) {
111
+ throw new Error('Audio file exceeds maximum size');
112
+ }
88
113
  const result = await aiAgentRunner.transcribe({
89
114
  model: config.model,
90
115
  audio: audioData,
@@ -103,12 +128,22 @@ export const voiceInput = (config) => pikkuAIMiddleware({
103
128
  // not hear.
104
129
  if (readsAsNonSpeech(result))
105
130
  continue;
131
+ heard.push(result.text);
106
132
  updatedContent.push({ type: 'text', text: result.text });
107
133
  }
108
134
  // Every part was audio, and none of it was speech. There is nothing left
109
135
  // to send, and a message with no content is not a question.
110
136
  if (updatedContent.length === 0)
111
137
  throw new NoSpeechDetectedError();
138
+ // Only when something was actually heard. A turn can carry audio that all
139
+ // reads as non-speech and still have content — an image with a silent
140
+ // caption clip — which leaves nothing above to throw. Recording `''` here
141
+ // would send a transcript event saying the user said nothing, and a client
142
+ // that tells "not transcribed yet" from "transcribed" by the key being
143
+ // absent would render the turn as an empty bubble rather than a pending one.
144
+ if (heard.length > 0) {
145
+ shared[SPOKEN_TRANSCRIPT] = heard.join(' ');
146
+ }
112
147
  return {
113
148
  messages: [
114
149
  ...messages.slice(0, -1),
@@ -59,6 +59,21 @@ export declare const voiceOutput: (config?: {
59
59
  * the right default for a provider that genuinely is multilingual.
60
60
  */
61
61
  speakableScripts?: SpeakableScripts;
62
+ /**
63
+ * Speak every reply, including answers to turns that were typed.
64
+ *
65
+ * The default is to speak only what was spoken to. An agent is usually one
66
+ * agent serving both kinds of caller — the same thread is typed at from a
67
+ * desk and talked to from a phone — and synthesizing a reply nobody is
68
+ * listening to costs real money on every sentence of every turn. Whether the
69
+ * user is in a call is not something the client needs to declare: sending
70
+ * audio is what saying so looks like.
71
+ *
72
+ * Set this for the cases where speech is not a reply to speech at all — a
73
+ * read-aloud accessibility mode, a kiosk, anything whose whole output is
74
+ * meant to be heard.
75
+ */
76
+ always?: boolean;
62
77
  }) => import("./ai-agent.types.js").PikkuAIMiddlewareHooks<VoiceOutputState, import("../../types/core.types.js").CoreSingletonServices<{
63
78
  logLevel?: import("../../services/logger.js").LogLevel;
64
79
  secrets?: {
@@ -1,4 +1,5 @@
1
1
  import { pikkuAIMiddleware } from '../../types/core.types.js';
2
+ import { SPOKEN_TURN } from './voice-input.js';
2
3
  function bufferToBase64(data) {
3
4
  let binary = '';
4
5
  for (let i = 0; i < data.length; i++) {
@@ -100,10 +101,17 @@ async function synthesizeAudio(aiAgentRunner, input) {
100
101
  };
101
102
  }
102
103
  export const voiceOutput = (config) => pikkuAIMiddleware({
103
- modifyOutputStream: async (services, { event, state, emit, signal }) => {
104
+ modifyOutputStream: async (services, { event, state, shared, emit, signal }) => {
104
105
  const { aiAgentRunner, logger } = services;
105
106
  if (!aiAgentRunner?.generateSpeech)
106
107
  return event;
108
+ // Only an explicit `false` silences the reply. `voiceInput` sets this on
109
+ // every turn it handles, so `false` means a real user really typed; the
110
+ // key being absent means nothing reported either way — no voice input is
111
+ // wired — and that caller's replies are spoken exactly as they were before
112
+ // this option existed.
113
+ if (!config?.always && shared[SPOKEN_TURN] === false)
114
+ return event;
107
115
  /**
108
116
  * Queue a finished sentence for speech.
109
117
  *
@@ -167,6 +175,7 @@ export const voiceOutput = (config) => pikkuAIMiddleware({
167
175
  type: 'audio-delta',
168
176
  data: bufferToBase64(result.audio.bytes),
169
177
  format: result.audio.format,
178
+ text,
170
179
  });
171
180
  });
172
181
  };
@@ -1,5 +1,23 @@
1
1
  import type { ApprovalRequester, Capabilities } from '../../channel/channel-rpc.js';
2
- import type { CorePikkuCLIRender } from '../cli.types.js';
2
+ import type { Logger } from '../../../services/logger.js';
3
+ /**
4
+ * Everything a renderer gets when the command ran on the server: a logger, and
5
+ * nothing else. There is no container on this side of the socket to resolve a
6
+ * service from, so this is the whole of it rather than a subset of
7
+ * `CoreSingletonServices` — which requires `config`, `variables` and `secrets`
8
+ * the client does not have and cannot invent.
9
+ */
10
+ export type ClientCLIRenderServices = {
11
+ logger: Logger;
12
+ };
13
+ /**
14
+ * A renderer that runs on the client of a CLI-over-channel connection.
15
+ *
16
+ * Deliberately not `CorePikkuCLIRender`: that type's `Services` parameter is
17
+ * constrained to `CoreSingletonServices`, so the honest logger-only shape
18
+ * cannot be spelled through it.
19
+ */
20
+ export type CorePikkuCLIClientRender<Data> = (services: ClientCLIRenderServices, data: Data, session?: undefined) => void | Promise<void>;
3
21
  /**
4
22
  * Runs a CLI command entirely on the server. Unlike `executeCLIViaChannel`,
5
23
  * argv is forwarded untouched and never parsed here, so the client carries no
@@ -13,8 +31,8 @@ import type { CorePikkuCLIRender } from '../cli.types.js';
13
31
  export declare function executeRawCLIViaChannel({ pikkuWS, args, renderers, defaultRenderer, capabilities, approve, }: {
14
32
  pikkuWS: any;
15
33
  args?: string[];
16
- renderers?: Record<string, CorePikkuCLIRender<any>>;
17
- defaultRenderer?: CorePikkuCLIRender<any>;
34
+ renderers?: Record<string, CorePikkuCLIClientRender<any>>;
35
+ defaultRenderer?: CorePikkuCLIClientRender<any>;
18
36
  capabilities?: Capabilities;
19
37
  approve?: ApprovalRequester;
20
38
  }): Promise<number>;
@@ -4,11 +4,19 @@ import { approverForMode, takeApprovalFlags } from './cli-approval.js';
4
4
  const defaultJSONRenderer = (_services, data) => {
5
5
  console.log(JSON.stringify(data));
6
6
  };
7
- /**
8
- * The command ran on the server, so there are no services here — but a renderer
9
- * written for local execution can still reach for `services.logger`.
10
- */
11
- const clientServices = { logger: console };
7
+ // `console` is the whole logger a CLI wants — its output is the command's output.
8
+ // `setLevel` is a no-op rather than an omission because `Logger` requires it and
9
+ // there is no level to set: a renderer writes what the user asked to see.
10
+ const clientServices = {
11
+ logger: {
12
+ info: (...args) => console.info(...args),
13
+ warn: (...args) => console.warn(...args),
14
+ error: (...args) => console.error(...args),
15
+ debug: (...args) => console.debug(...args),
16
+ trace: (...args) => console.trace(...args),
17
+ setLevel: () => { },
18
+ },
19
+ };
12
20
  /**
13
21
  * Runs a CLI command entirely on the server. Unlike `executeCLIViaChannel`,
14
22
  * argv is forwarded untouched and never parsed here, so the client carries no
@@ -2,5 +2,6 @@ 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 { ClientCLIRenderServices, CorePikkuCLIClientRender, } from './cli-raw-client-runner.js';
5
6
  export { APPROVAL_FLAGS, approverForMode, createTerminalApprover, takeApprovalFlags, } from './cli-approval.js';
6
7
  export type { ApprovalMode } from './cli-approval.js';
@@ -10,6 +10,10 @@ import type { CorePersonas } from './persona.types.js';
10
10
  * running: a persona that exists only to be acted upon (the account an admin
11
11
  * bans, the colleague a document is shared with) is seeded and never run.
12
12
  *
13
+ * Exactly one `definePersonas(...)` is allowed per codebase, so there is one
14
+ * place to read the cast from and one place to add to. A second call — even in
15
+ * the same file — fails the build.
16
+ *
13
17
  * @example
14
18
  * ```typescript
15
19
  * definePersonas({
@@ -9,6 +9,10 @@
9
9
  * running: a persona that exists only to be acted upon (the account an admin
10
10
  * bans, the colleague a document is shared with) is seeded and never run.
11
11
  *
12
+ * Exactly one `definePersonas(...)` is allowed per codebase, so there is one
13
+ * place to read the cast from and one place to add to. A second call — even in
14
+ * the same file — fails the build.
15
+ *
12
16
  * @example
13
17
  * ```typescript
14
18
  * definePersonas({
@@ -36,6 +36,16 @@ export type CorePersona = {
36
36
  name: string;
37
37
  jobTitle?: string;
38
38
  description?: string;
39
+ /**
40
+ * A picture of this person, as any URL a browser can load: a file your app
41
+ * serves, an asset on a CDN, a Gravatar link.
42
+ *
43
+ * Optional because it is decoration. Omitted, the console draws a colour and
44
+ * an icon derived from the persona's id, which is stable, needs no network and
45
+ * is more legible than the identicon a synthetic address would resolve to —
46
+ * which is also why nothing is derived here.
47
+ */
48
+ avatarUrl?: string;
39
49
  /**
40
50
  * The roles this person holds. Only system roles may be named — a custom
41
51
  * role can be deleted from the console, so a persona pinned to one silently
@@ -107,6 +117,7 @@ export type PersonaMeta = {
107
117
  name: string;
108
118
  jobTitle?: string;
109
119
  description?: string;
120
+ avatarUrl?: string;
110
121
  roles: string[];
111
122
  personality?: string;
112
123
  goals: string[];
@@ -25,7 +25,8 @@ const canonicalizePayload = (data) => stableStringify(JSON.parse(JSON.stringify(
25
25
  const signingPayload = (encodedClaim, data) => `${QUEUE_IDENTITY_CLAIM_VERSION}.${encodedClaim}.${canonicalizePayload(data)}`;
26
26
  export const getQueueIdentitySecret = async (secrets) => {
27
27
  try {
28
- return (await secrets?.getSecret(QUEUE_IDENTITY_SECRET_NAME)) || undefined;
28
+ return ((await secrets?.getSecret(QUEUE_IDENTITY_SECRET_NAME))?.reveal() ||
29
+ undefined);
29
30
  }
30
31
  catch {
31
32
  return undefined;
@@ -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
  export interface PikkuWorkerConfig {
5
6
  name?: string;
6
7
  /** Total worker concurrency. */
@@ -95,7 +96,7 @@ export interface JobOptions {
95
96
  }
96
97
  export interface QueueService {
97
98
  readonly supportsResults: boolean;
98
- add<T>(queueName: string, data: T, options?: JobOptions): Promise<string>;
99
+ add<T>(queueName: string, data: Safe<T>, options?: JobOptions): Promise<string>;
99
100
  getJob<T, R>(queueName: string, jobId: string): Promise<QueueJob<T, R> | null>;
100
101
  }
101
102
  export interface QueueWorkers {
@@ -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 type { JobOptions, QueueJob, QueueService } from './queue.types.js';
@@ -11,6 +12,6 @@ export declare class SignedQueueService implements QueueService {
11
12
  private readonly logger;
12
13
  constructor(queueService: QueueService, secrets: SecretService, logger: Logger);
13
14
  get supportsResults(): boolean;
14
- add<T>(queueName: string, data: T, options?: JobOptions): Promise<string>;
15
+ add<T>(queueName: string, data: Safe<T>, options?: JobOptions): Promise<string>;
15
16
  getJob<T, R>(queueName: string, jobId: string): Promise<QueueJob<T, R> | null>;
16
17
  }
@@ -1,11 +1,12 @@
1
1
  import type { CoreServices, PikkuRawWire } from '../../types/core.types.js';
2
2
  import { PikkuError } from '../../errors/error-handler.js';
3
+ import { type SecretValue } from '../../secret-value.js';
3
4
  export type RemoteAddonAuthBinding = {
4
5
  credentialId: string;
5
6
  } | {
6
7
  secretId: string;
7
8
  } | {
8
- resolve: (services: CoreServices, wire: PikkuRawWire) => string | Promise<string>;
9
+ resolve: (services: CoreServices, wire: PikkuRawWire) => string | SecretValue<string> | Promise<string | SecretValue<string>>;
9
10
  };
10
11
  export declare class RemoteAddonAuthError extends PikkuError {
11
12
  readonly namespace: string;
@@ -1,4 +1,5 @@
1
1
  import { PikkuError, addError } from '../../errors/error-handler.js';
2
+ import { isSecretValue } from '../../secret-value.js';
2
3
  export class RemoteAddonAuthError extends PikkuError {
3
4
  namespace;
4
5
  constructor(namespace, detail) {
@@ -28,8 +29,11 @@ export async function resolveRemoteAddonToken(auth, services, wire, namespace) {
28
29
  else if (typeof auth.resolve === 'function') {
29
30
  token = await auth.resolve(services, wire);
30
31
  }
31
- if (token === null || token === undefined || token === '') {
32
+ // The wire is where a secret is meant to end up, so unwrap it here rather
33
+ // than at each branch — `resolve` and `getCredential` may hand one back too.
34
+ const resolved = isSecretValue(token) ? token.reveal() : token;
35
+ if (resolved === null || resolved === undefined || resolved === '') {
32
36
  throw new RemoteAddonAuthError(namespace, 'resolved token was empty');
33
37
  }
34
- return String(token);
38
+ return String(resolved);
35
39
  }
@@ -17,7 +17,10 @@
17
17
  * signed in as a real user against staging or production.
18
18
  */
19
19
  export type { ApiCatalogueEntry, IntentRecord, IntentSource, IntentStatus, StepRecord, VirtualUserAction, VirtualUserBudget, VirtualUserDisposition, VirtualUserFinding, VirtualUserFindingKind, VirtualUserRunResult, VirtualUserTally, VirtualUserTarget, } from './virtual-user.types.js';
20
+ export { PRODUCTION_DISPOSITION } from './virtual-user.types.js';
20
21
  export { runVirtualUser, rememberIds, type RunVirtualUserParams, type VirtualUserCallContext, } from './run-virtual-user.js';
22
+ export { personaScopes, prepareVirtualUserRun, type VirtualUserPreparation, } from './prepare-virtual-user-run.js';
23
+ export type { VirtualUserRunOutcome, VirtualUserRunRecord, VirtualUserRunStart, VirtualUserRunStore, } from './virtual-user-run-store.js';
21
24
  export { DISPOSITIONS, dispositionProfile, type DispositionProfile, type VirtualUserTuning, } from './virtual-user-dispositions.js';
22
25
  export { catalogueClassification, catalogueIndex, catalogueLookup, describeEntry, isReadOnly, reachableCatalogue, renderCatalogue, unreachableCatalogue, } from './virtual-user-catalogue.js';
23
26
  export { reachableAgents, type AgentReachability, type ReachableAgent, } from './virtual-user-agents.js';
@@ -1,4 +1,6 @@
1
+ export { PRODUCTION_DISPOSITION } from './virtual-user.types.js';
1
2
  export { runVirtualUser, rememberIds, } from './run-virtual-user.js';
3
+ export { personaScopes, prepareVirtualUserRun, } from './prepare-virtual-user-run.js';
2
4
  export { DISPOSITIONS, dispositionProfile, } from './virtual-user-dispositions.js';
3
5
  export { catalogueClassification, catalogueIndex, catalogueLookup, describeEntry, isReadOnly, reachableCatalogue, renderCatalogue, unreachableCatalogue, } from './virtual-user-catalogue.js';
4
6
  export { reachableAgents, } from './virtual-user-agents.js';
@@ -0,0 +1,54 @@
1
+ import type { FunctionsMeta } from '../../types/core.types.js';
2
+ import type { WorkflowsMeta } from '../workflow/workflow.types.js';
3
+ import type { SystemRoleDefinitions, SystemRoleDefinitionsMeta } from '../role/role.types.js';
4
+ import { type SchemaMap } from './virtual-user-derive.js';
5
+ import { type AgentReachability, type ReachableAgent } from './virtual-user-agents.js';
6
+ import type { ApiCatalogueEntry, IntentSource } from './virtual-user.types.js';
7
+ /**
8
+ * The scopes a persona holds, resolved through its roles.
9
+ *
10
+ * Roles are the only thing a persona declares; scopes are what a function
11
+ * checks. Narrowing a virtual user's catalogue needs the second, so the
12
+ * expansion happens once, here, against the same `defineSystemRole` definitions
13
+ * the seed grants from.
14
+ */
15
+ export declare const personaScopes: (persona: {
16
+ roles?: readonly string[];
17
+ }, roleScopes: Record<string, readonly string[]>) => string[];
18
+ /** Everything a run needs that is derived rather than decided. */
19
+ export interface VirtualUserPreparation {
20
+ /** Every RPC the app exposes, narrowed to what this persona can reach. */
21
+ catalogue: ApiCatalogueEntry[];
22
+ intents: IntentSource[];
23
+ scopes: string[];
24
+ agents: ReachableAgent[];
25
+ }
26
+ /**
27
+ * Derive what a virtual user needs from what the project already generates.
28
+ *
29
+ * Shared because there are two callers with the same problem and different
30
+ * sources for it: `pikku persona run` reads the inspector state at build time,
31
+ * and the scaffolded `runVirtualUser` RPC reads `metaService` at runtime. They
32
+ * must agree — a persona whose catalogue is narrower over RPC than on the CLI
33
+ * finds different things from the same seed, which is exactly the property a
34
+ * seed exists to give.
35
+ *
36
+ * Nothing here is authored for the virtual user's benefit: the function meta is
37
+ * the catalogue, the scenario meta is the intents, and the role definitions are
38
+ * what turn a persona's declared roles into the scopes a function checks.
39
+ */
40
+ export declare const prepareVirtualUserRun: (input: {
41
+ persona: {
42
+ roles?: readonly string[];
43
+ };
44
+ functionsMeta: FunctionsMeta;
45
+ schemas?: SchemaMap;
46
+ workflowsMeta?: WorkflowsMeta;
47
+ /**
48
+ * Either shape: the inspector holds an array, `metaService` hands back the
49
+ * same definitions keyed by name. Accepting both is what lets the CLI and the
50
+ * scaffolded RPC share this.
51
+ */
52
+ systemRoles?: SystemRoleDefinitions | SystemRoleDefinitionsMeta;
53
+ agentsMeta?: Readonly<Record<string, AgentReachability>>;
54
+ }) => VirtualUserPreparation;
@@ -0,0 +1,49 @@
1
+ import { flattenSystemRoleDefinitions } from '../role/validate-role-definitions.js';
2
+ import { deriveCatalogue, deriveIntents, } from './virtual-user-derive.js';
3
+ import { reachableAgents, } from './virtual-user-agents.js';
4
+ /**
5
+ * The scopes a persona holds, resolved through its roles.
6
+ *
7
+ * Roles are the only thing a persona declares; scopes are what a function
8
+ * checks. Narrowing a virtual user's catalogue needs the second, so the
9
+ * expansion happens once, here, against the same `defineSystemRole` definitions
10
+ * the seed grants from.
11
+ */
12
+ export const personaScopes = (persona, roleScopes) => {
13
+ const scopes = new Set();
14
+ for (const role of persona.roles ?? []) {
15
+ for (const scope of roleScopes[role] ?? []) {
16
+ scopes.add(scope);
17
+ }
18
+ }
19
+ return [...scopes].sort();
20
+ };
21
+ /**
22
+ * Derive what a virtual user needs from what the project already generates.
23
+ *
24
+ * Shared because there are two callers with the same problem and different
25
+ * sources for it: `pikku persona run` reads the inspector state at build time,
26
+ * and the scaffolded `runVirtualUser` RPC reads `metaService` at runtime. They
27
+ * must agree — a persona whose catalogue is narrower over RPC than on the CLI
28
+ * finds different things from the same seed, which is exactly the property a
29
+ * seed exists to give.
30
+ *
31
+ * Nothing here is authored for the virtual user's benefit: the function meta is
32
+ * the catalogue, the scenario meta is the intents, and the role definitions are
33
+ * what turn a persona's declared roles into the scopes a function checks.
34
+ */
35
+ export const prepareVirtualUserRun = (input) => {
36
+ const catalogue = deriveCatalogue(input.functionsMeta, input.schemas ?? {});
37
+ const intents = deriveIntents(input.workflowsMeta ?? {}, input.functionsMeta);
38
+ const declared = input.systemRoles ?? [];
39
+ const roleScopes = {};
40
+ for (const role of flattenSystemRoleDefinitions(Array.isArray(declared) ? declared : Object.values(declared))) {
41
+ roleScopes[role.name] = role.scopes;
42
+ }
43
+ const scopes = personaScopes(input.persona, roleScopes);
44
+ // Gated by the same scopes as the RPCs, because an agent is reached rather
45
+ // than declared: `CoreAIAgent.scopes` is checked against the session, so a
46
+ // persona finds the specialists its roles unlock and no others.
47
+ const agents = reachableAgents(input.agentsMeta ?? {}, scopes);
48
+ return { catalogue, intents, scopes, agents };
49
+ };