@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
@@ -1,3 +1,4 @@
1
+ import { createSecretValue, isSecretValue, } from '../secret-value.js';
1
2
  import { LocalVariablesService } from './local-variables.js';
2
3
  export class LocalSecretService {
3
4
  variables;
@@ -16,16 +17,19 @@ export class LocalSecretService {
16
17
  async getSecret(key) {
17
18
  const localValue = this.localSecrets.get(key);
18
19
  if (localValue) {
19
- return this.parseSecret(localValue);
20
+ return createSecretValue(this.parseSecret(localValue));
20
21
  }
21
22
  const value = await this.variables.get(key);
22
23
  if (value) {
23
- return this.parseSecret(value);
24
+ return createSecretValue(this.parseSecret(value));
24
25
  }
25
26
  throw new Error('Requested secret not found');
26
27
  }
27
28
  async setSecret(key, value) {
28
- this.localSecrets.set(key, typeof value === 'string' ? value : JSON.stringify(value));
29
+ // Storing the wrapper would serialize it to '[secret]', so unwrap first
30
+ // writing a secret back to the vault is exactly what this method is for.
31
+ const raw = isSecretValue(value) ? value.reveal() : value;
32
+ this.localSecrets.set(key, typeof raw === 'string' ? raw : JSON.stringify(raw));
29
33
  }
30
34
  async hasSecret(key) {
31
35
  if (this.localSecrets.has(key)) {
@@ -1,3 +1,4 @@
1
+ import type { Safe } from '../secret-value.js';
1
2
  export declare enum LogLevel {
2
3
  'trace' = 0,
3
4
  'debug' = 1,
@@ -6,12 +7,28 @@ export declare enum LogLevel {
6
7
  'error' = 4,
7
8
  'critical' = 5
8
9
  }
10
+ /**
11
+ * A log line is the easiest place to leak a vault secret, so every parameter is
12
+ * `Safe<>`-guarded: a `SecretValue` anywhere in the message or the metadata,
13
+ * however deeply nested, collapses to `never` and fails the build. Reveal it
14
+ * first if you genuinely mean to log it.
15
+ */
9
16
  export interface Logger {
10
- info(messageOrObj: string | Record<string, any>, ...meta: any[]): void;
11
- warn(messageOrObj: string | Record<string, any>, ...meta: any[]): void;
12
- error(messageOrObj: string | Record<string, any> | Error, ...meta: any[]): void;
13
- debug(message: string, ...meta: any[]): void;
14
- trace?(message: string, ...meta: any[]): void;
17
+ info<M extends string | Record<string, any>, A extends unknown[]>(messageOrObj: Safe<M>, ...meta: {
18
+ [K in keyof A]: Safe<A[K]>;
19
+ }): void;
20
+ warn<M extends string | Record<string, any>, A extends unknown[]>(messageOrObj: Safe<M>, ...meta: {
21
+ [K in keyof A]: Safe<A[K]>;
22
+ }): void;
23
+ error<M extends string | Record<string, any> | Error, A extends unknown[]>(messageOrObj: Safe<M>, ...meta: {
24
+ [K in keyof A]: Safe<A[K]>;
25
+ }): void;
26
+ debug<A extends unknown[]>(message: string, ...meta: {
27
+ [K in keyof A]: Safe<A[K]>;
28
+ }): void;
29
+ trace?<A extends unknown[]>(message: string, ...meta: {
30
+ [K in keyof A]: Safe<A[K]>;
31
+ }): void;
15
32
  setLevel(level: LogLevel): void;
16
33
  /** A logger carrying a traceId on every entry, taken per-request to correlate calls. */
17
34
  scope?(traceId: string): Logger;
@@ -32,7 +32,7 @@ export class QueueWebhookService extends WebhookService {
32
32
  let secret = input.secret;
33
33
  if (secret === undefined && webhookConfig?.secret) {
34
34
  // Naive read: caching is the secret service's concern, not ours.
35
- secret = await services.secrets.getSecret(webhookConfig.secret);
35
+ secret = (await services.secrets.getSecret(webhookConfig.secret)).reveal();
36
36
  if (!secret) {
37
37
  services.logger.error(`Webhook signing secret '${webhookConfig.secret}' (config.webhook.secret) resolved to nothing — outgoing webhooks will be sent UNSIGNED.`);
38
38
  }
@@ -1,12 +1,13 @@
1
- import type { SecretService } from './secret-service.js';
1
+ import type { SecretValue } from '../secret-value.js';
2
+ import type { SecretService, SecretValues } from './secret-service.js';
2
3
  export declare class ScopedSecretService implements SecretService {
3
4
  private secrets;
4
5
  private allowedKeys;
5
6
  constructor(secrets: SecretService, allowedKeys: Set<string>);
6
7
  private assertAllowed;
7
- getSecret<T = string>(key: string): Promise<T>;
8
+ getSecret<T = string>(key: string): Promise<SecretValue<T>>;
8
9
  hasSecret(key: string): Promise<boolean>;
9
10
  setSecret(_key: string, _value: unknown): Promise<void>;
10
11
  deleteSecret(_key: string): Promise<void>;
11
- getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<T>>;
12
+ getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>>;
12
13
  }
@@ -1,6 +1,11 @@
1
+ import type { SecretValue } from '../secret-value.js';
2
+ /** A record of secrets, each still wrapped. */
3
+ export type SecretValues<T> = {
4
+ [K in keyof T]: SecretValue<T[K]>;
5
+ };
1
6
  export interface SecretService {
2
- /** Throws if the secret is not found. */
3
- getSecret<T = string>(key: string): Promise<T>;
7
+ /** Throws if the secret is not found. Unwrap the result with `.reveal()`. */
8
+ getSecret<T = string>(key: string): Promise<SecretValue<T>>;
4
9
  /** Answers for any key, including a disallowed one — it must not throw. */
5
10
  hasSecret(key: string): Promise<boolean>;
6
11
  setSecret(key: string, value: unknown): Promise<void>;
@@ -10,5 +15,5 @@ export interface SecretService {
10
15
  * must handle keys absent at runtime. Pass a shape as `T` to avoid casting,
11
16
  * e.g. `getSecrets<{ FOO: string; BAR: { id: string } }>(['FOO', 'BAR'])`.
12
17
  */
13
- getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<T>>;
18
+ getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>>;
14
19
  }
@@ -1,4 +1,5 @@
1
- import type { SecretService } from './secret-service.js';
1
+ import type { SecretValue } from '../secret-value.js';
2
+ import type { SecretService, SecretValues } from './secret-service.js';
2
3
  export interface CredentialStatus {
3
4
  secretId: string;
4
5
  name: string;
@@ -20,12 +21,12 @@ export declare class TypedSecretService<TMap = Record<string, unknown>> implemen
20
21
  private credentialsMeta;
21
22
  private cache;
22
23
  constructor(secrets: SecretService, credentialsMeta: Record<string, CredentialMeta>);
23
- getSecret<K extends keyof TMap & string>(key: K): Promise<TMap[K]>;
24
- getSecret<T = string>(key: string): Promise<T>;
24
+ getSecret<K extends keyof TMap & string>(key: K): Promise<SecretValue<TMap[K]>>;
25
+ getSecret<T = string>(key: string): Promise<SecretValue<T>>;
25
26
  hasSecret(key: string): Promise<boolean>;
26
27
  setSecret<K extends string>(key: K, value: K extends keyof TMap ? TMap[K] : unknown): Promise<void>;
27
28
  deleteSecret(key: string): Promise<void>;
28
- getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<T>>;
29
+ getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<Partial<SecretValues<T>>>;
29
30
  getAllStatus(): Promise<CredentialStatus[]>;
30
31
  getMissing(): Promise<CredentialStatus[]>;
31
32
  }
@@ -1,3 +1,4 @@
1
+ import type { Safe } from '../secret-value.js';
1
2
  export interface SendWebhookInput {
2
3
  url: string;
3
4
  /** Sent as the `X-Pikku-Event` header. */
@@ -76,7 +77,7 @@ export declare const PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME = "pikku-outgoing-webhook
76
77
  export declare const DEFAULT_WEBHOOK_SIGNATURE_HEADER = "X-Pikku-Signature";
77
78
  export declare const DEFAULT_WEBHOOK_RETRIES = 3;
78
79
  export declare abstract class WebhookService {
79
- abstract send(input: SendWebhookInput): Promise<SendWebhookResult>;
80
+ abstract send<T extends SendWebhookInput>(input: Safe<T>): Promise<SendWebhookResult>;
80
81
  /** Produces the header value, `sha256=<hex>`, not the bare digest. */
81
82
  protected sign(secret: string, body: string): string;
82
83
  /** Public because receivers verify with it; they share the signing scheme. */
@@ -542,16 +542,16 @@ export function defineServiceTests(config) {
542
542
  endpoint: 'https://api.example.com',
543
543
  });
544
544
  const result = await service.getSecret('api-key');
545
- assert.deepEqual(result, {
545
+ assert.deepEqual(result.reveal(), {
546
546
  token: 'sk-123',
547
547
  endpoint: 'https://api.example.com',
548
548
  });
549
549
  });
550
- test('getSecret returns raw string', async () => {
550
+ test('getSecret wraps the raw string rather than returning it', async () => {
551
551
  const service = await factory({ key: kek });
552
552
  await service.setSecret('string-secret', 'plain-value');
553
553
  const result = await service.getSecret('string-secret');
554
- assert.strictEqual(result, 'plain-value');
554
+ assert.strictEqual(result.reveal(), 'plain-value');
555
555
  });
556
556
  test('hasSecret returns true/false', async () => {
557
557
  const service = await factory({ key: kek });
@@ -569,7 +569,7 @@ export function defineServiceTests(config) {
569
569
  await service.setSecret('upsert-key', { v: 1 });
570
570
  await service.setSecret('upsert-key', { v: 2 });
571
571
  const result = await service.getSecret('upsert-key');
572
- assert.deepEqual(result, { v: 2 });
572
+ assert.deepEqual(result.reveal(), { v: 2 });
573
573
  });
574
574
  test('deleteSecret removes the key', async () => {
575
575
  const service = await factory({ key: kek });
@@ -588,7 +588,7 @@ export function defineServiceTests(config) {
588
588
  previousKey: kek,
589
589
  });
590
590
  const before = await rotatedService.getSecret('rotate-test');
591
- assert.deepEqual(before, { important: 'data' });
591
+ assert.deepEqual(before.reveal(), { important: 'data' });
592
592
  assert.ok(rotatedService.rotateKEK);
593
593
  const count = await rotatedService.rotateKEK();
594
594
  assert.ok(count > 0);
@@ -597,7 +597,7 @@ export function defineServiceTests(config) {
597
597
  keyVersion: 2,
598
598
  });
599
599
  const after = await newOnlyService.getSecret('rotate-test');
600
- assert.deepEqual(after, { important: 'data' });
600
+ assert.deepEqual(after.reveal(), { important: 'data' });
601
601
  });
602
602
  test('rotateKEK throws without previousKey', async () => {
603
603
  const service = await factory({ key: kek });
@@ -25,6 +25,7 @@ import type { AIAgentRunnerService } from '../services/ai-agent-runner-service.j
25
25
  import type { AIEmbeddingService } from '../services/ai-embedding-service.js';
26
26
  import type { AIRunStateService } from '../services/ai-run-state-service.js';
27
27
  import type { AgentRunService } from '../wirings/ai-agent/ai-agent.types.js';
28
+ import type { VirtualUserRunStore } from '../wirings/virtual-user/virtual-user-run-store.js';
28
29
  import type { PikkuAIMiddlewareHooks } from '../wirings/ai-agent/ai-agent.types.js';
29
30
  import type { WorkflowRunService } from '../wirings/workflow/workflow.types.js';
30
31
  import type { CredentialService } from '../services/credential-service.js';
@@ -79,11 +80,12 @@ export type FunctionRuntimeMeta = {
79
80
  */
80
81
  auth?: boolean;
81
82
  /**
82
- * The author's declaration that this function authorizes callers in its own
83
- * body. Carries no runtime effect — it records a gate codegen cannot see, so
84
- * an audit is not left to guess whether a sessionless function is open.
83
+ * The author's declaration that this function's permission check lives in its
84
+ * own body. Carries no runtime effect — it records a gate codegen cannot see,
85
+ * so an audit is not left to guess whether a sessionless function is open.
86
+ * Refused unless `allow.permissionsInBody` is set in `pikku.config.json`.
85
87
  */
86
- selfAuthenticated?: boolean;
88
+ permissionsInBody?: boolean;
87
89
  remote?: boolean;
88
90
  /**
89
91
  * A step RPC: invoked by name only from a scenario run and refused
@@ -127,6 +129,19 @@ export type FunctionRuntimeMeta = {
127
129
  scenarioStepAddon?: string;
128
130
  /** Scenario steps only: the prose a reporter renders, with `{placeholders}` filled from the step's recorded input. */
129
131
  scenarioStepTemplate?: string;
132
+ /**
133
+ * The function's `audit` config, resolved — `audit: true` reads as
134
+ * `{ durability: 'best-effort' }`. Absent means the function records nothing:
135
+ * `auditLog.write()` from an unmarked function is dropped with a warning, so
136
+ * this is the only place a reader can see which functions have a trail at all
137
+ * without running them.
138
+ *
139
+ * Informational. The runner resolves audit from the live function config, not
140
+ * from here, so meta and runtime cannot disagree about whether audit is on.
141
+ */
142
+ audit?: {
143
+ durability: AuditDurability;
144
+ };
130
145
  /** Keeps the full `SecretService`. Set by the inspector, read by the runner. */
131
146
  secretBroker?: boolean;
132
147
  version?: number;
@@ -255,6 +270,12 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
255
270
  */
256
271
  webhookService?: WebhookService;
257
272
  metaService?: MetaService;
273
+ /**
274
+ * Where virtual-user runs are recorded. A run is dispatched and answered for
275
+ * later, so this store is the only trace it leaves — see
276
+ * {@link VirtualUserRunStore}.
277
+ */
278
+ virtualUserRunStore?: VirtualUserRunStore;
258
279
  /** V8 precise-coverage collector (`pikku dev --coverage` only) */
259
280
  coverageService?: CoverageService;
260
281
  audit?: AuditService;
@@ -305,7 +305,11 @@ export function wrapChannelWithAGUI(inner, options) {
305
305
  send({
306
306
  type: 'CUSTOM',
307
307
  name: 'pikku:audio-delta',
308
- value: { data: event.data, format: event.format },
308
+ value: {
309
+ data: event.data,
310
+ format: event.format,
311
+ ...(event.text === undefined ? {} : { text: event.text }),
312
+ },
309
313
  });
310
314
  break;
311
315
  }
@@ -313,6 +317,14 @@ export function wrapChannelWithAGUI(inner, options) {
313
317
  send({ type: 'CUSTOM', name: 'pikku:audio-done', value: {} });
314
318
  break;
315
319
  }
320
+ case 'transcript': {
321
+ send({
322
+ type: 'CUSTOM',
323
+ name: 'pikku:transcript',
324
+ value: { text: event.text },
325
+ });
326
+ break;
327
+ }
316
328
  }
317
329
  },
318
330
  };
@@ -660,7 +660,13 @@ export async function prepareAgentRun(agentName, input, params, agentSessionMap,
660
660
  const contextMessages = await loadContextMessages(memoryConfig, storage, input, workingMemoryJsonSchema);
661
661
  const userContent = input.attachments?.length
662
662
  ? [
663
- { type: 'text', text: input.message },
663
+ // Omitted when there is nothing to say. An attachment on its own is a
664
+ // real turn — a spoken one carries audio and no text at all — and an
665
+ // empty text part alongside it is a part providers are entitled to
666
+ // reject, for a caller who never wrote one.
667
+ ...(input.message
668
+ ? [{ type: 'text', text: input.message }]
669
+ : []),
664
670
  ...input.attachments.map((a) => ({
665
671
  type: a.type,
666
672
  data: a.data,
@@ -73,6 +73,8 @@ export async function runAIAgent(agentName, input, params, agentSessionMap) {
73
73
  }),
74
74
  ...(agent.aiMiddleware ?? []),
75
75
  ];
76
+ // One bag per run, shared by every middleware — see PikkuAIMiddlewareHooks.
77
+ const sharedNotes = {};
76
78
  let modifiedMessages = runnerParams.messages;
77
79
  let modifiedInstructions = runnerParams.instructions;
78
80
  for (const mw of aiMiddlewares) {
@@ -80,6 +82,7 @@ export async function runAIAgent(agentName, input, params, agentSessionMap) {
80
82
  const result = await mw.modifyInput(singletonServices, {
81
83
  messages: modifiedMessages,
82
84
  instructions: modifiedInstructions,
85
+ shared: sharedNotes,
83
86
  });
84
87
  modifiedMessages = result.messages;
85
88
  modifiedInstructions = result.instructions;
@@ -87,6 +90,12 @@ export async function runAIAgent(agentName, input, params, agentSessionMap) {
87
90
  }
88
91
  runnerParams.messages = modifiedMessages;
89
92
  runnerParams.instructions = modifiedInstructions;
93
+ // History records what the model was asked, which for a spoken turn is the
94
+ // transcript rather than the base64 audio that arrived — see the same note on
95
+ // the streaming path. Identity-checked, because a middleware may legitimately
96
+ // replace the message list with something unrelated to this turn.
97
+ const lastModified = modifiedMessages[modifiedMessages.length - 1];
98
+ const persistedUserMessage = lastModified?.id === userMessage.id ? lastModified : userMessage;
90
99
  const runId = await aiRunState.createRun({
91
100
  agentName,
92
101
  threadId,
@@ -181,7 +190,7 @@ export async function runAIAgent(agentName, input, params, agentSessionMap) {
181
190
  args: a.args,
182
191
  });
183
192
  const completedStepsForSave = accumulatedSteps.slice(0, -1);
184
- await saveMessages(storage, threadId, input.resourceId, memoryConfig, userMessage, { text: '', steps: completedStepsForSave });
193
+ await saveMessages(storage, threadId, input.resourceId, memoryConfig, persistedUserMessage, { text: '', steps: completedStepsForSave });
185
194
  if (storage) {
186
195
  await storage.saveMessages(threadId, [
187
196
  {
@@ -242,7 +251,7 @@ export async function runAIAgent(agentName, input, params, agentSessionMap) {
242
251
  outputMessages = modResult.messages;
243
252
  }
244
253
  }
245
- await saveMessages(storage, threadId, input.resourceId, memoryConfig, userMessage, {
254
+ await saveMessages(storage, threadId, input.resourceId, memoryConfig, persistedUserMessage, {
246
255
  ...result,
247
256
  text: outputText,
248
257
  uiSpec: structuredOutput.uiSpec,
@@ -416,6 +425,8 @@ async function continueAfterToolResultSync(run, agent, packageName, resolvedName
416
425
  }),
417
426
  ...(agent.aiMiddleware ?? []),
418
427
  ];
428
+ // One bag per run, shared by every middleware — see PikkuAIMiddlewareHooks.
429
+ const sharedNotes = {};
419
430
  let modifiedMessages = trimmedMessages;
420
431
  let modifiedInstructions = instructions;
421
432
  for (const mw of aiMiddlewares) {
@@ -423,6 +434,7 @@ async function continueAfterToolResultSync(run, agent, packageName, resolvedName
423
434
  const result = await mw.modifyInput(singletonServices, {
424
435
  messages: modifiedMessages,
425
436
  instructions: modifiedInstructions,
437
+ shared: sharedNotes,
426
438
  });
427
439
  modifiedMessages = result.messages;
428
440
  modifiedInstructions = result.instructions;
@@ -1,6 +1,7 @@
1
1
  import { pikkuState, getSingletonServices } from '../../pikku-state.js';
2
2
  import { AIProviderNotConfiguredError } from '../../errors/errors.js';
3
3
  import { randomUUID } from './ai-agent-utils.js';
4
+ import { SPOKEN_TRANSCRIPT } from './voice-input.js';
4
5
  import { combineChannelMiddleware, wrapChannelWithMiddleware, } from '../channel/channel-middleware-runner.js';
5
6
  import { resolveMemoryServices, loadContextMessages, trimMessages, getWorkingMemoryMiddleware, } from './ai-agent-memory.js';
6
7
  import { prepareAgentRun, resolveAgent, buildInstructions, buildToolDefs, createScopedChannel, resolveOwnerResourceId, agentSessionScope, assertResourceOwner, assertResourcePrincipalOwner, assertAgentAuthorized, ToolApprovalRequired, ToolCredentialRequired, APPROVAL_REQUIRED, CREDENTIAL_REQUIRED, } from './ai-agent-prepare.js';
@@ -411,6 +412,8 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
411
412
  }),
412
413
  ...(agent.aiMiddleware ?? []),
413
414
  ];
415
+ // One bag per run, shared by every middleware — see PikkuAIMiddlewareHooks.
416
+ const sharedNotes = {};
414
417
  let modifiedMessages = runnerParams.messages;
415
418
  let modifiedInstructions = runnerParams.instructions;
416
419
  for (const mw of aiMiddlewares) {
@@ -418,6 +421,7 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
418
421
  const result = await mw.modifyInput(singletonServices, {
419
422
  messages: modifiedMessages,
420
423
  instructions: modifiedInstructions,
424
+ shared: sharedNotes,
421
425
  });
422
426
  modifiedMessages = result.messages;
423
427
  modifiedInstructions = result.instructions;
@@ -425,6 +429,23 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
425
429
  }
426
430
  runnerParams.messages = modifiedMessages;
427
431
  runnerParams.instructions = modifiedInstructions;
432
+ // Sent on the raw channel, ahead of the run. A voice client sent audio and so
433
+ // has no idea what it said; until this arrives its own message is a blank
434
+ // bubble. Ahead of the run rather than alongside it because the answer starts
435
+ // streaming within a few hundred milliseconds, and a question that appears
436
+ // after its answer reads as the wrong question.
437
+ const transcript = sharedNotes[SPOKEN_TRANSCRIPT];
438
+ if (typeof transcript === 'string') {
439
+ channel.send({ type: 'transcript', text: transcript });
440
+ }
441
+ // What goes into thread history is what the model was actually asked. For a
442
+ // spoken turn that is not what arrived over the wire: the wire carried a
443
+ // base64 audio blob, and persisting it would write megabytes of unreadable
444
+ // data into the history while losing the only readable record of the turn.
445
+ // Identity-checked rather than assumed — a middleware is free to rewrite the
446
+ // message list into something with no relation to the turn.
447
+ const lastModified = modifiedMessages[modifiedMessages.length - 1];
448
+ const persistedUserMessage = lastModified?.id === userMessage.id ? lastModified : userMessage;
428
449
  const runId = await aiRunState.createRun({
429
450
  agentName,
430
451
  threadId,
@@ -442,7 +463,7 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
442
463
  runnerParams.abortSignal = interruptHandle.signal;
443
464
  runnerParams.tools = trackToolExecution(runnerParams.tools, interruptHandle);
444
465
  if (storage) {
445
- await storage.saveMessages(threadId, [userMessage]);
466
+ await storage.saveMessages(threadId, [persistedUserMessage]);
446
467
  }
447
468
  const streamMiddleware = aiMiddlewares
448
469
  .filter((mw) => mw.modifyOutputStream)
@@ -455,6 +476,7 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
455
476
  event,
456
477
  allEvents,
457
478
  state,
479
+ shared: sharedNotes,
458
480
  // Sends downstream directly, so a hook can hand back the fast event
459
481
  // now and push the slow one when it is ready.
460
482
  emit: next,
@@ -841,6 +863,8 @@ async function continueAfterToolResult(run, agent, packageName, resolvedName, st
841
863
  }),
842
864
  ...(agent.aiMiddleware ?? []),
843
865
  ];
866
+ // One bag per run, shared by every middleware — see PikkuAIMiddlewareHooks.
867
+ const sharedNotes = {};
844
868
  let modifiedMessages = trimmedMessages;
845
869
  let modifiedInstructions = instructions;
846
870
  for (const mw of aiMiddlewares) {
@@ -848,6 +872,7 @@ async function continueAfterToolResult(run, agent, packageName, resolvedName, st
848
872
  const result = await mw.modifyInput(singletonServices, {
849
873
  messages: modifiedMessages,
850
874
  instructions: modifiedInstructions,
875
+ shared: sharedNotes,
851
876
  });
852
877
  modifiedMessages = result.messages;
853
878
  modifiedInstructions = result.instructions;
@@ -869,6 +894,7 @@ async function continueAfterToolResult(run, agent, packageName, resolvedName, st
869
894
  event,
870
895
  allEvents,
871
896
  state,
897
+ shared: sharedNotes,
872
898
  // Sends downstream directly, so a hook can hand back the fast event
873
899
  // now and push the slow one when it is ready.
874
900
  emit: next,
@@ -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;