@pikku/core 0.12.74 → 0.12.77

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 (143) hide show
  1. package/CHANGELOG.md +378 -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/index.d.ts +3 -3
  22. package/dist/services/index.js +1 -1
  23. package/dist/services/local-content-request-handler.d.ts +29 -0
  24. package/dist/services/local-content-request-handler.js +176 -0
  25. package/dist/services/local-secrets.d.ts +4 -3
  26. package/dist/services/local-secrets.js +7 -3
  27. package/dist/services/logger.d.ts +22 -5
  28. package/dist/services/queue-webhook-service.js +1 -1
  29. package/dist/services/scoped-secret-service.d.ts +4 -3
  30. package/dist/services/secret-service.d.ts +8 -3
  31. package/dist/services/typed-secret-service.d.ts +5 -4
  32. package/dist/services/webhook-service.d.ts +2 -1
  33. package/dist/testing/service-tests.js +6 -6
  34. package/dist/types/core.types.d.ts +25 -4
  35. package/dist/wirings/ai-agent/ai-agent-agui.js +13 -1
  36. package/dist/wirings/ai-agent/ai-agent-prepare.js +7 -1
  37. package/dist/wirings/ai-agent/ai-agent-runner.js +14 -2
  38. package/dist/wirings/ai-agent/ai-agent-stream.js +27 -1
  39. package/dist/wirings/ai-agent/ai-agent.types.d.ts +40 -0
  40. package/dist/wirings/ai-agent/index.d.ts +1 -1
  41. package/dist/wirings/ai-agent/index.js +1 -1
  42. package/dist/wirings/ai-agent/voice-input.d.ts +20 -0
  43. package/dist/wirings/ai-agent/voice-input.js +44 -9
  44. package/dist/wirings/ai-agent/voice-output.d.ts +15 -0
  45. package/dist/wirings/ai-agent/voice-output.js +10 -1
  46. package/dist/wirings/cli/channel/cli-raw-client-runner.d.ts +21 -3
  47. package/dist/wirings/cli/channel/cli-raw-client-runner.js +13 -5
  48. package/dist/wirings/cli/channel/index.d.ts +1 -0
  49. package/dist/wirings/persona/define-personas.d.ts +4 -0
  50. package/dist/wirings/persona/define-personas.js +4 -0
  51. package/dist/wirings/persona/persona.types.d.ts +11 -0
  52. package/dist/wirings/queue/queue-identity.js +2 -1
  53. package/dist/wirings/queue/queue.types.d.ts +2 -1
  54. package/dist/wirings/queue/signed-queue-service.d.ts +2 -1
  55. package/dist/wirings/rpc/remote-addon-auth.d.ts +2 -1
  56. package/dist/wirings/rpc/remote-addon-auth.js +6 -2
  57. package/dist/wirings/virtual-user/index.d.ts +3 -0
  58. package/dist/wirings/virtual-user/index.js +2 -0
  59. package/dist/wirings/virtual-user/prepare-virtual-user-run.d.ts +54 -0
  60. package/dist/wirings/virtual-user/prepare-virtual-user-run.js +49 -0
  61. package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +90 -0
  62. package/dist/wirings/virtual-user/virtual-user-run-store.js +1 -0
  63. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +14 -10
  64. package/dist/wirings/workflow/pikku-scenario-service.js +1 -2
  65. package/dist/wirings/workflow/scenario-prose.js +1 -1
  66. package/dist/wirings/workflow/scenario-step.types.d.ts +13 -7
  67. package/dist/wirings/workflow/workflow.types.d.ts +7 -0
  68. package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +48 -0
  69. package/knowledge/decisions/internals/core-column-form-is-an-axis-of-its-own.md +84 -0
  70. package/knowledge/decisions/internals/core-data-classification-brand-is-an-optional-property.md +9 -2
  71. package/knowledge/decisions/internals/index.md +4 -0
  72. package/knowledge/decisions/internals/one-project-shape-check-two-validators.md +53 -0
  73. package/knowledge/decisions/internals/scenarios-live-in-files-named-for-them.md +48 -0
  74. package/knowledge/decisions/internals/validate-checks-personas-through-a-shared-module.md +43 -0
  75. package/package.json +3 -2
  76. package/src/column-form.test.ts +97 -0
  77. package/src/column-form.ts +58 -0
  78. package/src/crypto-utils.ts +25 -6
  79. package/src/data-classification.ts +44 -0
  80. package/src/function/functions.types.ts +47 -10
  81. package/src/function/index.ts +1 -0
  82. package/src/index.ts +24 -2
  83. package/src/middleware/auth-bearer.test.ts +3 -2
  84. package/src/middleware/auth-bearer.ts +2 -1
  85. package/src/middleware/remote-auth.test.ts +2 -1
  86. package/src/middleware/remote-auth.ts +1 -1
  87. package/src/remote.test.ts +2 -1
  88. package/src/remote.ts +1 -1
  89. package/src/secret-value.test.ts +204 -0
  90. package/src/secret-value.ts +111 -0
  91. package/src/services/audit-service.ts +87 -9
  92. package/src/services/credential-wire-service.ts +9 -1
  93. package/src/services/email-service.ts +3 -1
  94. package/src/services/index.ts +3 -3
  95. package/src/services/local-content-request-handler.test.ts +202 -0
  96. package/src/services/local-content-request-handler.ts +267 -0
  97. package/src/services/local-secrets.test.ts +20 -5
  98. package/src/services/local-secrets.ts +15 -7
  99. package/src/services/logger.ts +27 -7
  100. package/src/services/queue-webhook-service.test.ts +2 -1
  101. package/src/services/queue-webhook-service.ts +1 -1
  102. package/src/services/scoped-secret-service.ts +4 -3
  103. package/src/services/secret-service.ts +8 -3
  104. package/src/services/typed-secret-service.ts +11 -7
  105. package/src/services/webhook-service.ts +4 -1
  106. package/src/testing/service-tests.ts +6 -6
  107. package/src/types/core.types.ts +25 -4
  108. package/src/wirings/ai-agent/ai-agent-agui.test.ts +16 -0
  109. package/src/wirings/ai-agent/ai-agent-agui.ts +14 -1
  110. package/src/wirings/ai-agent/ai-agent-prepare.ts +7 -1
  111. package/src/wirings/ai-agent/ai-agent-runner.ts +18 -2
  112. package/src/wirings/ai-agent/ai-agent-stream.ts +32 -1
  113. package/src/wirings/ai-agent/ai-agent.types.ts +45 -1
  114. package/src/wirings/ai-agent/index.ts +2 -0
  115. package/src/wirings/ai-agent/voice-input.test.ts +65 -0
  116. package/src/wirings/ai-agent/voice-input.ts +48 -9
  117. package/src/wirings/ai-agent/voice-output.test.ts +91 -1
  118. package/src/wirings/ai-agent/voice-output.ts +28 -1
  119. package/src/wirings/cli/channel/cli-raw-client-runner.ts +39 -9
  120. package/src/wirings/cli/channel/index.ts +4 -0
  121. package/src/wirings/persona/define-personas.ts +4 -0
  122. package/src/wirings/persona/persona.types.ts +11 -0
  123. package/src/wirings/queue/queue-identity.test.ts +2 -1
  124. package/src/wirings/queue/queue-identity.ts +4 -1
  125. package/src/wirings/queue/queue.types.ts +6 -1
  126. package/src/wirings/queue/signed-queue-service.ts +2 -1
  127. package/src/wirings/rpc/remote-addon-auth.ts +8 -3
  128. package/src/wirings/rpc/rpc-runner.test.ts +6 -4
  129. package/src/wirings/virtual-user/index.ts +12 -0
  130. package/src/wirings/virtual-user/prepare-virtual-user-run.test.ts +115 -0
  131. package/src/wirings/virtual-user/prepare-virtual-user-run.ts +95 -0
  132. package/src/wirings/virtual-user/virtual-user-run-store.ts +98 -0
  133. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +14 -16
  134. package/src/wirings/workflow/pikku-scenario-service.ts +1 -10
  135. package/src/wirings/workflow/scenario-prose.test.ts +5 -7
  136. package/src/wirings/workflow/scenario-prose.ts +1 -1
  137. package/src/wirings/workflow/scenario-service.test.ts +0 -1
  138. package/src/wirings/workflow/scenario-step.test.ts +4 -5
  139. package/src/wirings/workflow/scenario-step.types.ts +13 -7
  140. package/src/wirings/workflow/scenario-surface.test.ts +6 -5
  141. package/src/wirings/workflow/workflow.types.ts +7 -0
  142. package/tsconfig.tsbuildinfo +1 -1
  143. package/tsconfig.type-tests.json +12 -0
@@ -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
+ };
@@ -0,0 +1,90 @@
1
+ import type { VirtualUserDisposition, VirtualUserFinding, VirtualUserTally } from './virtual-user.types.js';
2
+ /**
3
+ * One recorded run: who ran, what they were told, and what came back.
4
+ *
5
+ * A run is dispatched and answered for later, so the record is created before
6
+ * the work starts and is the thing the returned `runId` addresses.
7
+ *
8
+ * This record is a run's ONLY trace. A virtual user is not a workflow — it
9
+ * explores, so no two attempts take the same steps and there is nothing to
10
+ * replay — and it is not queued either, because the record already carries the
11
+ * progress a queue would only be holding on the way here.
12
+ *
13
+ * The cost of that is the one thing to know when reading `status`: a restart
14
+ * mid-run leaves a record at `running` with nothing left to finish it. A run
15
+ * older than its budget window and still `running` is dead, not working.
16
+ */
17
+ export interface VirtualUserRunRecord {
18
+ runId: string;
19
+ persona: string;
20
+ disposition: VirtualUserDisposition;
21
+ /** What makes a run replayable at all — a finding without it is an anecdote. */
22
+ seed: number;
23
+ /**
24
+ * `running` until the engine returns. Not derived from `finishedAt` being
25
+ * unset: a crashed run has no finish time either, and the two are not the
26
+ * same result.
27
+ */
28
+ status: 'running' | 'completed' | 'failed';
29
+ /** The caller's situational goals, run alongside the derived intents. */
30
+ goals: string[];
31
+ /**
32
+ * Ids and slugs the user carried in, and whatever it learned on the way out.
33
+ * Kept because a finding only reproduces alongside the notes that produced it.
34
+ */
35
+ memory: Record<string, string>;
36
+ findings: VirtualUserFinding[];
37
+ tally: VirtualUserTally | null;
38
+ /** Which budget or stopping rule ended the run. */
39
+ stoppedBy: string | null;
40
+ /**
41
+ * Why the run itself failed, as opposed to what it found. A run that could
42
+ * not start has no findings and is not a clean empty result.
43
+ */
44
+ error: string | null;
45
+ /** The session that started it, where the host tracks one. */
46
+ startedBy: string | null;
47
+ createdAt: Date;
48
+ finishedAt: Date | null;
49
+ }
50
+ /** What a run is created with — everything else is filled in by the outcome. */
51
+ export interface VirtualUserRunStart {
52
+ persona: string;
53
+ disposition: VirtualUserDisposition;
54
+ seed: number;
55
+ goals?: readonly string[];
56
+ memory?: Record<string, string>;
57
+ startedBy?: string | null;
58
+ }
59
+ /** The outcome of a run that reached the end of its budget without throwing. */
60
+ export interface VirtualUserRunOutcome {
61
+ findings: readonly VirtualUserFinding[];
62
+ tally: VirtualUserTally;
63
+ memory: Record<string, string>;
64
+ stoppedBy: string | null;
65
+ }
66
+ /**
67
+ * Where runs are kept. Declared here rather than in a database package so the
68
+ * scaffolded RPCs depend on the shape and not on kysely — `@pikku/kysely` ships
69
+ * one implementation, and an app with its own store satisfies this instead.
70
+ *
71
+ * SECURITY: findings from an `adversarial` run are working exploits carrying
72
+ * live ids. An implementation is a privileged store; the scaffold gates every
73
+ * read behind a scope for that reason, and a host exposing these records more
74
+ * widely is publishing its own exploits.
75
+ */
76
+ export interface VirtualUserRunStore {
77
+ /** Records a run as `running` and returns its id. */
78
+ start(run: VirtualUserRunStart): Promise<string>;
79
+ /** Marks a run `completed` and stores what it found. */
80
+ complete(runId: string, outcome: VirtualUserRunOutcome): Promise<void>;
81
+ /** Marks a run `failed`. The run itself broke; it has no findings. */
82
+ fail(runId: string, error: string): Promise<void>;
83
+ get(runId: string): Promise<VirtualUserRunRecord | null>;
84
+ /** Newest first. `persona` narrows to one persona's history. */
85
+ list(options?: {
86
+ persona?: string;
87
+ limit?: number;
88
+ offset?: number;
89
+ }): Promise<VirtualUserRunRecord[]>;
90
+ }
@@ -62,7 +62,7 @@ export type WorkflowWireDoRPC = <TOutput = any, TInput = any>(stepName: string,
62
62
  */
63
63
  export type WorkflowWireDoInline = <T>(stepName: string, fn: () => Promise<T> | T, options?: WorkflowStepOptions) => Promise<T>;
64
64
  /**
65
- * Type signature for scenario.step/given/when/then - used by inspector.
65
+ * Type signature for scenario.given/when/then - used by inspector.
66
66
  *
67
67
  * Deliberately mirrors WorkflowWireDoRPC's shape: the target is a string, not
68
68
  * an imported symbol, so the extractor reads it as a literal.
@@ -181,7 +181,7 @@ export interface RpcStepMeta {
181
181
  expectEventually?: boolean;
182
182
  }
183
183
  /**
184
- * Scenario step metadata — a call to `scenario.step/given/when/then`.
184
+ * Scenario step metadata — a call to `scenario.given/when/then`.
185
185
  *
186
186
  * Distinct from RpcStepMeta on purpose: a step runs locally through
187
187
  * runPikkuFunc and must never be treated as dispatchable on the queue/replay
@@ -194,7 +194,7 @@ export interface ScenarioStepMeta {
194
194
  stepName: string;
195
195
  /** Registered name of the step function being run */
196
196
  stepFunc: string;
197
- /** Which keyword the reporter prefixes — given/when/then, or none for `step` */
197
+ /** Which keyword the reporter prefixes — Given, When or Then */
198
198
  phase: ScenarioStepPhase;
199
199
  /** Output variable name (if assigned) */
200
200
  outputVar?: string;
@@ -484,18 +484,22 @@ export interface PikkuScenarioWire extends PikkuWorkflowWire {
484
484
  /** Stub-assertion step: asserts `service.method` was called on the target server */
485
485
  expectService: (stepName: string, serviceMethod: string, options?: WorkflowExpectServiceOptions) => Promise<void>;
486
486
  /**
487
- * Run a registered scenario step. Shaped exactly like `do`'s RPC form
488
- * `(stepName, target, data, options)` — so the inspector reads the target as
489
- * a string literal rather than resolving an imported symbol.
487
+ * Run a registered scenario step, as the setup the scenario starts from.
488
+ *
489
+ * Shaped exactly like `do`'s RPC form `(stepName, target, data, options)` —
490
+ * so the inspector reads the target as a string literal rather than resolving
491
+ * an imported symbol.
490
492
  *
491
493
  * The generated `TypedScenario` narrows these over `FlattenedScenarioStepMap`.
492
494
  */
493
- step(stepName: string, stepFunc: string, data?: any, options?: ScenarioStepOptions): Promise<any>;
494
- /** `step` with a "Given" prefix in the rendered prose */
495
495
  given(stepName: string, stepFunc: string, data?: any, options?: ScenarioStepOptions): Promise<any>;
496
- /** `step` with a "When" prefix in the rendered prose */
496
+ /** `when`: the same call as `given`, rendered as the action under test */
497
497
  when(stepName: string, stepFunc: string, data?: any, options?: ScenarioStepOptions): Promise<any>;
498
- /** `step` with a "Then" prefix in the rendered prose */
498
+ /**
499
+ * `then`: a claim about what the action left behind. Unlike `given`/`when`
500
+ * this is not prose alone — the step's bindings become witnesses, so every
501
+ * declared surface is observed and the observations must agree.
502
+ */
499
503
  then(stepName: string, stepFunc: string, data?: any, options?: ScenarioStepOptions): Promise<any>;
500
504
  runScheduledTask: (name: string) => Promise<unknown>;
501
505
  }
@@ -452,12 +452,11 @@ export class PikkuScenarioService {
452
452
  }, options);
453
453
  },
454
454
  // Scenario steps: a named `pikkuScenarioStep` run as one durable step.
455
- // `given`/`when`/`step` are sugar for each other, differing only in the
455
+ // `given`/`when` are sugar for each other, differing only in the
456
456
  // prose a reporter renders. `then` is not: the phase is what decides
457
457
  // whether the step's bindings are alternatives or witnesses, so the same
458
458
  // step function called two ways runs differently. See
459
459
  // {@link resolveScenarioSurfaces}.
460
- step: (stepName, stepFunc, data, options) => this.scenarioStep('step', scenarioStepContext(), stepName, stepFunc, data, options),
461
460
  given: (stepName, stepFunc, data, options) => this.scenarioStep('given', scenarioStepContext(), stepName, stepFunc, data, options),
462
461
  when: (stepName, stepFunc, data, options) => this.scenarioStep('when', scenarioStepContext(), stepName, stepFunc, data, options),
463
462
  then: (stepName, stepFunc, data, options) => this.scenarioStep('then', scenarioStepContext(), stepName, stepFunc, data, options),
@@ -15,7 +15,7 @@ const formatValue = (value) => {
15
15
  return String(value);
16
16
  };
17
17
  export const composeStepProse = ({ phase, description, template, input, actor, keywordWidth, }) => {
18
- const keyword = phase === 'step' ? '' : capitalise(phase);
18
+ const keyword = capitalise(phase);
19
19
  const subject = actor ? `the ${actor}` : '';
20
20
  const rendered = template ? renderStepTemplate(template, input) : description;
21
21
  const sentence = [subject, rendered].filter(Boolean).join(' ');