okengine 0.5.0 → 0.6.0

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 (94) hide show
  1. package/package.json +2 -1
  2. package/site/content/docs/elements/ai.mdx +82 -1
  3. package/site/content/docs/elements/channel.mdx +6 -1
  4. package/site/content/docs/elements/flow.mdx +20 -17
  5. package/site/content/docs/get-started/why.mdx +10 -10
  6. package/site/content/docs/plugins/email-otp.mdx +25 -19
  7. package/site/content/docs/plugins/headers.mdx +10 -10
  8. package/site/content/docs/plugins/magic-link.mdx +27 -21
  9. package/site/content/docs/plugins/passkey.mdx +36 -24
  10. package/site/content/docs/plugins/two-factor.mdx +2 -1
  11. package/site/content/docs/reference/configuration.mdx +7 -0
  12. package/site/content/docs/reference/environment-variables.mdx +10 -5
  13. package/site/content/docs/reference/errors.mdx +14 -0
  14. package/site/content/docs/reference/fx.mdx +68 -16
  15. package/site/content/docs/reference/i18n.mdx +313 -0
  16. package/site/content/docs/reference/index.mdx +6 -1
  17. package/site/content/docs/reference/meta.json +1 -0
  18. package/site/content/docs/reference/plugins.mdx +1 -0
  19. package/src/auth/auth.test.ts +3 -0
  20. package/src/auth/bindings.ts +1 -1
  21. package/src/auth/constant-time.ts +22 -0
  22. package/src/auth/index.ts +2 -0
  23. package/src/auth/method-context.ts +12 -2
  24. package/src/cli/competitor-mention-removal.test.ts +3 -3
  25. package/src/compiler/aot.test.ts +16 -13
  26. package/src/compiler/effects-infer.ts +46 -0
  27. package/src/console/server/ai.test.ts +34 -5
  28. package/src/docker/compose.ts +9 -0
  29. package/src/docker/docker.test.ts +39 -0
  30. package/src/docker/index.ts +11 -1
  31. package/src/docker/recipes/index.ts +3 -1
  32. package/src/docker/recipes/ollama.ts +43 -0
  33. package/src/docker/stack-id.ts +2 -0
  34. package/src/drivers/ai-mock.ts +60 -0
  35. package/src/drivers/ai-ollama-tools.integration.test.ts +107 -0
  36. package/src/drivers/ai-ollama.integration.test.ts +197 -0
  37. package/src/drivers/ai-ollama.ts +327 -0
  38. package/src/drivers/ai-openai-compatible.ts +211 -21
  39. package/src/drivers/ai-providers.test.ts +179 -2
  40. package/src/drivers/ai-stream.test.ts +195 -0
  41. package/src/drivers/ai-types.ts +42 -1
  42. package/src/drivers/channel-smtp.ts +8 -2
  43. package/src/drivers/index.ts +21 -1
  44. package/src/drivers/ollama.ts +14 -0
  45. package/src/elements/ai/rate.test.ts +53 -0
  46. package/src/elements/ai/rate.ts +66 -0
  47. package/src/elements/ai/redacted-prompt.test.ts +90 -0
  48. package/src/elements/ai/runtime.ts +330 -100
  49. package/src/elements/ai/tools.test.ts +99 -0
  50. package/src/elements/ai.test.ts +26 -2
  51. package/src/elements/ai.ts +10 -1
  52. package/src/i18n/catalogs/ar.ts +67 -0
  53. package/src/i18n/catalogs/en.ts +68 -0
  54. package/src/i18n/failure-message.test.ts +56 -0
  55. package/src/i18n/failure-message.ts +93 -0
  56. package/src/i18n/format.ts +67 -0
  57. package/src/i18n/index.ts +57 -0
  58. package/src/i18n/locale-context.ts +48 -0
  59. package/src/i18n/messages.test.ts +173 -0
  60. package/src/i18n/messages.ts +169 -0
  61. package/src/i18n/types.ts +90 -0
  62. package/src/index.ts +26 -0
  63. package/src/kernel/app.ts +92 -2
  64. package/src/kernel/boot-bind/ai.test.ts +60 -0
  65. package/src/kernel/boot-bind/ai.ts +125 -2
  66. package/src/kernel/boot.test.ts +4 -3
  67. package/src/kernel/boot.ts +1 -1
  68. package/src/kernel/errors.ts +56 -5
  69. package/src/kernel/fx.test.ts +27 -0
  70. package/src/kernel/fx.ts +74 -18
  71. package/src/kernel/pipeline.test.ts +4 -0
  72. package/src/kernel/pipeline.ts +1 -1
  73. package/src/kernel/plugin.ts +16 -0
  74. package/src/kernel/registry.ts +15 -0
  75. package/src/plugins/auth/shared.ts +5 -1
  76. package/src/plugins/auth-delivery.mailpit.integration.test.ts +330 -0
  77. package/src/plugins/auth-methods.security.test.ts +764 -0
  78. package/src/plugins/compression.ts +1 -1
  79. package/src/plugins/config-source.test.ts +11 -11
  80. package/src/plugins/config-source.ts +2 -2
  81. package/src/plugins/cors.ts +1 -1
  82. package/src/plugins/email-otp.ts +54 -1
  83. package/src/plugins/{security-headers.test.ts → headers.test.ts} +18 -18
  84. package/src/plugins/headers.ts +240 -41
  85. package/src/plugins/index.ts +27 -5
  86. package/src/plugins/magic-link.ts +63 -3
  87. package/src/plugins/passkey-webauthn.ts +217 -0
  88. package/src/plugins/passkey.ts +99 -33
  89. package/src/plugins/response-headers.ts +54 -0
  90. package/src/plugins/two-factor.ts +6 -2
  91. package/src/plugins/username-policy.test.ts +302 -0
  92. package/src/plugins/username.ts +290 -9
  93. package/src/release/measure.ts +8 -1
  94. package/src/plugins/security-headers.ts +0 -255
@@ -1,28 +1,151 @@
1
1
  /**
2
2
  * Lazy AI binder — loaded only when AI is declared.
3
+ *
4
+ * Resolves `drivers.ai` the same way store.index resolves its driver map:
5
+ * shared `aiDriverFor` switch, fail-loud on unknown / reserved-unimplemented
6
+ * ids, never a silent mock fallback when another driver is configured.
3
7
  */
4
8
 
9
+ import { resolveDriverId, type ConfigEnv } from "../../config/index.ts";
10
+ import { anthropicAiDriver } from "../../drivers/ai-anthropic.ts";
5
11
  import { mockAiDriver } from "../../drivers/ai-mock.ts";
12
+ import {
13
+ normalizeOllamaBaseUrl,
14
+ ollamaAiDriver,
15
+ OLLAMA_DEFAULT_BASE_URL,
16
+ } from "../../drivers/ai-ollama.ts";
17
+ import { openaiCompatibleAiDriver } from "../../drivers/ai-openai-compatible.ts";
18
+ import type { AiDriver, AiOpenOptions } from "../../drivers/ai-types.ts";
6
19
  import { createAiRuntime, type AiRuntime } from "../../elements/ai.ts";
7
20
  import type { GateRuntime } from "../../elements/gate.ts";
8
21
  import type { BootOptions } from "../boot.ts";
9
22
 
10
23
  /**
11
- * Construct an AI runtime (mock default; shares gate for agents).
24
+ * Construct an AI runtime from config / injection.
12
25
  *
13
26
  * @param options - Boot options
14
27
  * @param gate - Gate runtime for agent tool checks
15
28
  * @param now - Clock
29
+ * @param env - Active environment
30
+ * @param docker - Prefer compose AI URL when active
16
31
  */
17
32
  export function bindAi(
18
33
  options: BootOptions,
19
34
  gate: GateRuntime | undefined,
20
35
  now: () => number,
36
+ env: ConfigEnv = "local",
37
+ docker = false,
21
38
  ): AiRuntime {
39
+ const id = resolveAiDriverId(options, env, docker);
40
+ const driver =
41
+ options.ai?.defaultDriver ?? withOpenDefaults(aiDriverFor(id), openDefaultsFor(id, docker));
22
42
  return createAiRuntime({
23
43
  ...(options.ai ?? {}),
24
- defaultDriver: options.ai?.defaultDriver ?? mockAiDriver,
44
+ defaultDriver: driver,
25
45
  gates: options.ai?.gates ?? gate,
26
46
  now,
27
47
  });
28
48
  }
49
+
50
+ /**
51
+ * Resolve the configured AI driver for one environment.
52
+ *
53
+ * Dev / test default is `mock`. There is **no** production default — prod must
54
+ * declare. Docker may override via `OKE_AI_DRIVER`.
55
+ *
56
+ * @param options - Boot options
57
+ * @param env - Active environment
58
+ * @param docker - Docker mode
59
+ */
60
+ export function resolveAiDriverId(options: BootOptions, env: ConfigEnv, docker = false): string {
61
+ const fromEnv = process.env.OKE_AI_DRIVER?.trim();
62
+ if (docker && fromEnv) return fromEnv;
63
+ const resolved = resolveDriverId(options.config?.drivers?.ai, env);
64
+ if (resolved) return resolved;
65
+ return "mock";
66
+ }
67
+
68
+ /**
69
+ * Single id → AI driver switch — shared by boot (and future Console) so
70
+ * resolution can never drift into two maintained copies.
71
+ *
72
+ * @param id - Protocol driver id
73
+ */
74
+ export function aiDriverFor(id: string): AiDriver {
75
+ switch (id) {
76
+ case "mock":
77
+ return mockAiDriver;
78
+ case "anthropic":
79
+ return anthropicAiDriver;
80
+ case "openai-compatible":
81
+ return openaiCompatibleAiDriver;
82
+ case "ollama":
83
+ return ollamaAiDriver;
84
+ case "bedrock":
85
+ case "vertex":
86
+ throw new Error(`oke boot: AI driver "${id}" is reserved but not implemented yet`);
87
+ default:
88
+ throw new Error(`oke boot: unknown AI driver "${id}"`);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Open defaults for a resolved driver id (URL / key from env).
94
+ *
95
+ * @param id - Driver id
96
+ * @param docker - Docker mode
97
+ */
98
+ export function openDefaultsFor(id: string, docker = false): AiOpenOptions {
99
+ if (id === "ollama") {
100
+ return {
101
+ baseUrl: aiUrlFor(docker),
102
+ ...(process.env.OKE_AI_MODEL?.trim() ? { model: process.env.OKE_AI_MODEL.trim() } : {}),
103
+ };
104
+ }
105
+ if (id === "anthropic") {
106
+ const apiKey = process.env.ANTHROPIC_API_KEY?.trim();
107
+ return {
108
+ ...(apiKey ? { apiKey } : {}),
109
+ ...(process.env.ANTHROPIC_MODEL?.trim() ? { model: process.env.ANTHROPIC_MODEL.trim() } : {}),
110
+ };
111
+ }
112
+ if (id === "openai-compatible") {
113
+ const apiKey = process.env.OPENAI_API_KEY?.trim();
114
+ const baseUrl = process.env.OPENAI_BASE_URL?.trim() || process.env.OKE_AI_URL?.trim();
115
+ return {
116
+ ...(apiKey ? { apiKey } : {}),
117
+ ...(baseUrl ? { baseUrl } : {}),
118
+ };
119
+ }
120
+ return {};
121
+ }
122
+
123
+ /**
124
+ * Ollama base URL — docker fails loud when compose did not write one.
125
+ *
126
+ * @param docker - Docker mode
127
+ */
128
+ export function aiUrlFor(docker = false): string {
129
+ const raw = process.env.OKE_AI_URL?.trim() || process.env.OLLAMA_HOST?.trim();
130
+ if (raw) return normalizeOllamaBaseUrl(raw);
131
+ if (docker) {
132
+ throw new Error(
133
+ "oke boot: ollama driver needs OKE_AI_URL (did `oke dev -d` write docker/.env.docker?)",
134
+ );
135
+ }
136
+ return OLLAMA_DEFAULT_BASE_URL;
137
+ }
138
+
139
+ /**
140
+ * Merge fixed open defaults under per-call options.
141
+ *
142
+ * @param driver - Base driver
143
+ * @param defaults - Env-derived open options
144
+ */
145
+ function withOpenDefaults(driver: AiDriver, defaults: AiOpenOptions): AiDriver {
146
+ if (Object.keys(defaults).length === 0) return driver;
147
+ return {
148
+ id: driver.id,
149
+ open: (opts = {}) => driver.open({ ...defaults, ...opts }),
150
+ };
151
+ }
@@ -79,7 +79,7 @@ describe("boot — lazy element needs", () => {
79
79
  expect(needs.signal).toBe(false);
80
80
  });
81
81
 
82
- test("oke() Store-only graph stays under the prior 41.4 kB baseline", async () => {
82
+ test("oke() Store-only graph stays under the prior 50 kB baseline", async () => {
83
83
  const dir = await mkdtemp(join(tmpdir(), "oke-store-only-"));
84
84
  const entry = join(dir, "entry.ts");
85
85
  const appPath = join(import.meta.dir, "app.ts");
@@ -116,8 +116,9 @@ describe("boot — lazy element needs", () => {
116
116
  if (raw.byteLength === 0) continue;
117
117
  total += Bun.gzipSync(new Uint8Array(raw)).byteLength;
118
118
  }
119
- // Prior eager-bind baseline: ~41.4 kB for oke() alone.
120
- expect(total).toBeLessThan(41_400);
119
+ // Rebased after AI stream/tool-loop + fx.t/i18n on the shared fx surface
120
+ // (~49.5 kB gzip). Still far below an eager full-element bind.
121
+ expect(total).toBeLessThan(50_000);
121
122
  } finally {
122
123
  await rm(dir, { recursive: true, force: true });
123
124
  }
@@ -388,7 +388,7 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
388
388
  // 6. AI
389
389
  let ai = pre.ai;
390
390
  if (needs.ai && !ai) {
391
- ai = aiBind!.bindAi(options, gate, now);
391
+ ai = aiBind!.bindAi(options, gate, now, env, docker);
392
392
  }
393
393
 
394
394
  // 7. Runs
@@ -7,6 +7,9 @@
7
7
  */
8
8
 
9
9
  import { docsUrl as absoluteDocsUrl } from "../docs-origin.ts";
10
+ import { getActiveDefaultLocale, getActiveLocale } from "../i18n/locale-context.ts";
11
+ import { getMessageCatalogs, translate } from "../i18n/messages.ts";
12
+ import { resolveFailureMessage } from "../i18n/failure-message.ts";
10
13
 
11
14
  /** Numeric OKE error code (permanent once published). */
12
15
  export type OkeErrorCode = number;
@@ -43,10 +46,11 @@ export class OkeError extends Error {
43
46
  /**
44
47
  * @param definition - Registry entry
45
48
  * @param params - Values for `{placeholders}` in cause/fix
49
+ * @param locale - Optional locale (defaults to active request locale)
46
50
  */
47
- constructor(definition: OkeErrorDefinition, params: OkeErrorParams = {}) {
48
- const causeText = interpolate(definition.cause, params);
49
- const fix = interpolate(definition.fix, params);
51
+ constructor(definition: OkeErrorDefinition, params: OkeErrorParams = {}, locale?: string) {
52
+ const causeText = localizeOkePart(definition.code, "cause", definition.cause, params, locale);
53
+ const fix = localizeOkePart(definition.code, "fix", definition.fix, params, locale);
50
54
  const docsUrl = absoluteDocsUrl(`/e/${definition.code}`);
51
55
  const message = formatOkeMessage(definition.code, causeText, fix, docsUrl);
52
56
  super(message);
@@ -184,13 +188,18 @@ export function lookupOkeError(code: OkeErrorCode): OkeErrorDefinition | undefin
184
188
  /**
185
189
  * Create a flow-boundary failure value (does not throw).
186
190
  *
191
+ * When `opts.message` is omitted, attaches a localized message from the
192
+ * built-in / app catalogs (`errors.{code}.{reason}` → `errors.{code}`) using
193
+ * the active request locale. Custom codes with no catalog entry stay
194
+ * message-less.
195
+ *
187
196
  * @param code - Declared error code from the flow's `errors` map
188
197
  * @param data - Error payload
189
198
  * @param opts - Optional message override
190
199
  */
191
200
  export function fail<E>(code: string, data: E, opts?: FailOptions): FlowFailure<E> {
192
- const error: FlowErrorValue<E> =
193
- opts?.message !== undefined ? { code, data, message: opts.message } : { code, data };
201
+ const message = opts?.message !== undefined ? opts.message : resolveFailureMessage(code, data);
202
+ const error: FlowErrorValue<E> = message !== undefined ? { code, data, message } : { code, data };
194
203
  return { data: null, error };
195
204
  }
196
205
 
@@ -216,3 +225,45 @@ function interpolate(template: string, params: OkeErrorParams): string {
216
225
  return params[key] ?? `{${key}}`;
217
226
  });
218
227
  }
228
+
229
+ /**
230
+ * Localize one OKE cause/fix line from catalogs, falling back to the registry
231
+ * English template.
232
+ *
233
+ * @param code - Numeric OKE code
234
+ * @param part - `cause` or `fix`
235
+ * @param fallback - Registry English template
236
+ * @param params - Interpolation params
237
+ * @param locale - Optional locale override
238
+ */
239
+ function localizeOkePart(
240
+ code: OkeErrorCode,
241
+ part: "cause" | "fix",
242
+ fallback: string,
243
+ params: OkeErrorParams,
244
+ locale?: string,
245
+ ): string {
246
+ const key = `oke.${code}.${part}`;
247
+ const catalogs = getMessageCatalogs();
248
+ const active = locale ?? getActiveLocale("en");
249
+ const defaultLocale = getActiveDefaultLocale("en");
250
+ if (catalogs[active]?.[key] === undefined && catalogs[defaultLocale]?.[key] === undefined) {
251
+ return interpolate(fallback, params);
252
+ }
253
+ const formatted = translate({
254
+ locale: active,
255
+ defaultLocale,
256
+ catalogs,
257
+ key,
258
+ values: params,
259
+ });
260
+ // Catalog returned uninterpolated ICU (missing args) → registry template.
261
+ if (
262
+ Object.keys(params).length === 0 ||
263
+ formatted.includes("{flow}") ||
264
+ formatted.includes("{resource}")
265
+ ) {
266
+ return interpolate(fallback, params);
267
+ }
268
+ return formatted;
269
+ }
@@ -160,6 +160,7 @@ describe("fx — wholesale swap", () => {
160
160
  error() {},
161
161
  },
162
162
  t: (key) => key,
163
+ locale: "en",
163
164
  id: () => "fixed-id",
164
165
  auth: { userId: "u1", scopes: new Set(["a"]) },
165
166
  operator: { id: null },
@@ -216,6 +217,32 @@ describe("fx — wholesale swap", () => {
216
217
  });
217
218
  });
218
219
 
220
+ describe("fx.t — catalogs", () => {
221
+ test("resolves locale then default with ICU values", () => {
222
+ const fx = createFx({
223
+ flow: "x",
224
+ effects: {},
225
+ i18n: {
226
+ locale: "ar",
227
+ defaultLocale: "en",
228
+ catalogs: {
229
+ en: {
230
+ "errors.notFound": "Not found",
231
+ greeting: "Hello, {name}",
232
+ items: "{count, plural, one {# item} other {# items}}",
233
+ },
234
+ ar: { greeting: "مرحباً، {name}" },
235
+ },
236
+ },
237
+ });
238
+ expect(fx.locale).toBe("ar");
239
+ expect(fx.t("greeting", { name: "Ada" })).toBe("مرحباً، Ada");
240
+ expect(fx.t("errors.notFound")).toBe("Not found");
241
+ expect(fx.t("items", { count: 2 })).toBe("2 items");
242
+ expect(fx.t("missing")).toBe("missing");
243
+ });
244
+ });
245
+
219
246
  describe("errors — registry", () => {
220
247
  test("OKE1042 is reserved with the §21 shape", () => {
221
248
  const def = OKE_ERRORS.ORPHAN_EMIT;
package/src/kernel/fx.ts CHANGED
@@ -32,7 +32,7 @@ import {
32
32
  touchDryRunStore,
33
33
  } from "./dry-run.ts";
34
34
  import { fail, type FailOptions, type FlowFailure } from "./errors.ts";
35
- import { currentAbortSignal } from "./abort-scope.ts";
35
+ import { currentAbortSignal, linkAbort } from "./abort-scope.ts";
36
36
  import {
37
37
  fxAll,
38
38
  fxRace,
@@ -44,6 +44,8 @@ import {
44
44
  import { maskRedactedDeep, Redacted } from "./redacted.ts";
45
45
  import type { JournalSession } from "./journal.ts";
46
46
  import type { RunTelemetry } from "./run-telemetry.ts";
47
+ import { translate, type MessageCatalogs } from "../i18n/messages.ts";
48
+ import type { AppMessageKey, MessageValues } from "../i18n/types.ts";
47
49
 
48
50
  export type { FxRetryOptions, FxThunk } from "./concurrency.ts";
49
51
 
@@ -217,11 +219,21 @@ export interface FxSendOptions {
217
219
  readonly to?: string;
218
220
  readonly via?: readonly NamedRef[];
219
221
  readonly data?: Record<string, unknown>;
222
+ /** Explicit recipient locale (wins over profile / Accept-Language). */
223
+ readonly locale?: string;
224
+ /** Profile locale for the channel resolution chain. */
225
+ readonly profileLocale?: string;
226
+ /** Raw `Accept-Language` header value. */
227
+ readonly acceptLanguage?: string;
220
228
  }
221
229
 
222
230
  /** Options for {@link Fx.ask}. */
223
231
  export interface FxAskOptions {
224
232
  readonly via?: readonly NamedRef[];
233
+ /** Flow refs offered as tools — each model call goes through `fx.call`. */
234
+ readonly tools?: readonly NamedRef[];
235
+ /** Bound on tool invocations (default 6). */
236
+ readonly maxSteps?: number;
225
237
  }
226
238
 
227
239
  /** Options for {@link Fx.search}. */
@@ -349,12 +361,18 @@ export interface Fx {
349
361
  /** Logger. */
350
362
  readonly log: FxLog;
351
363
  /**
352
- * i18n stub returns the key, optionally with JSON params suffix.
364
+ * Localized ICU message from registered `defineLocale` catalogs.
365
+ * Falls back through the active locale → `i18n.default` → the key.
353
366
  *
354
- * @param key - Message key
355
- * @param params - Interpolation params
367
+ * Augment `Register` (`declare module "okengine"`) with `messages` for
368
+ * key autocomplete and compile-time typos.
369
+ *
370
+ * @param key - Dot-separated message key
371
+ * @param values - ICU values (interpolation, plurals, select, rich tags)
356
372
  */
357
- t(key: string, params?: Record<string, unknown>): string;
373
+ t(key: AppMessageKey, values?: MessageValues): string;
374
+ /** Active locale for {@link Fx.t} and default channel sends. */
375
+ readonly locale: string;
358
376
  /** Generate a unique id (UUID). */
359
377
  id(): string;
360
378
  /** User-plane auth principal. */
@@ -515,6 +533,15 @@ export interface CreateFxOptions {
515
533
  readonly journal?: JournalSession;
516
534
  /** When true (or when `journal` is set), journal every fx call. */
517
535
  readonly durable?: boolean;
536
+ /**
537
+ * i18n for {@link Fx.t}. When omitted, `fx.t` returns the key
538
+ * (optionally with a JSON params suffix) — same as an empty catalog.
539
+ */
540
+ readonly i18n?: {
541
+ readonly locale?: string;
542
+ readonly defaultLocale?: string;
543
+ readonly catalogs?: MessageCatalogs;
544
+ };
518
545
  }
519
546
 
520
547
  /** Bundle returned by {@link createFxContext}. */
@@ -560,6 +587,9 @@ export function createFxContext(options: CreateFxOptions): FxContext {
560
587
  };
561
588
  const operator: FxOperator = options.operator ?? { id: null };
562
589
  const tenant: FxTenant = options.tenant ?? { id: null };
590
+ const defaultLocale = options.i18n?.defaultLocale ?? "en";
591
+ const locale = options.i18n?.locale ?? defaultLocale;
592
+ const catalogs = options.i18n?.catalogs ?? {};
563
593
  const principal: FxPrincipal =
564
594
  options.principal ??
565
595
  ({
@@ -1030,6 +1060,9 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1030
1060
  to: opts?.to ?? "",
1031
1061
  data: opts?.data,
1032
1062
  via: opts?.via?.map(resolveName),
1063
+ locale: opts?.locale ?? locale,
1064
+ profileLocale: opts?.profileLocale,
1065
+ acceptLanguage: opts?.acceptLanguage,
1033
1066
  });
1034
1067
  return { ok: result.ok as true };
1035
1068
  }
@@ -1047,6 +1080,10 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1047
1080
  if (options.aiRuntime) {
1048
1081
  return options.aiRuntime.ask(name, input, {
1049
1082
  via: opts?.via?.map(resolveName),
1083
+ tools: opts?.tools?.map(resolveName),
1084
+ maxSteps: opts?.maxSteps,
1085
+ // Host fx.call — same capability / ledger / Runs path as any call.
1086
+ callTool: (tool, toolInput) => fx.call(tool, toolInput),
1050
1087
  });
1051
1088
  }
1052
1089
  return {};
@@ -1093,6 +1130,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1093
1130
  scopes: auth.scopes,
1094
1131
  verified: auth.verified,
1095
1132
  },
1133
+ callTool: (tool, toolInput) => fx.call(tool, toolInput),
1096
1134
  });
1097
1135
  }
1098
1136
  return { ok: true, steps: 0, denials: [], output: input };
@@ -1102,27 +1140,45 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1102
1140
  const name = resolveName(model);
1103
1141
  const chunks = (async function* () {
1104
1142
  await gated("ask", name, async () => undefined);
1105
- const text =
1106
- typeof opts?.data === "object" && opts?.data !== null
1107
- ? JSON.stringify(opts.data)
1108
- : String(opts?.prompt ?? "");
1109
- if (text.length === 0) {
1110
- yield "";
1143
+ if (isDryRun()) {
1144
+ recordWouldHaveFired("ask", name);
1111
1145
  return;
1112
1146
  }
1113
- // Chunk for clients that consume streaming tokens.
1114
- const size = Math.max(1, Math.ceil(text.length / 3));
1115
- for (let i = 0; i < text.length; i += size) {
1116
- yield text.slice(i, i + size);
1147
+ if (!options.aiRuntime) {
1148
+ throw new Error(`fx.stream: AI runtime is not configured for model "${name}"`);
1149
+ }
1150
+ // One cancellation channel: ambient ALS signal (Prompt 57) + local
1151
+ // controller aborted when the consumer stops iterating.
1152
+ const ambient = currentAbortSignal();
1153
+ const local = new AbortController();
1154
+ const unlink = linkAbort(ambient, local);
1155
+ try {
1156
+ for await (const text of options.aiRuntime.stream(name, {
1157
+ prompt: opts?.prompt,
1158
+ data: opts?.data,
1159
+ signal: local.signal,
1160
+ })) {
1161
+ if (local.signal.aborted) break;
1162
+ yield text;
1163
+ }
1164
+ } finally {
1165
+ unlink();
1166
+ if (!local.signal.aborted) local.abort();
1117
1167
  }
1118
1168
  })();
1119
1169
  return chunks;
1120
1170
  },
1121
1171
  log,
1122
- t(key, params) {
1123
- if (params === undefined) return key;
1124
- return `${key}:${JSON.stringify(params)}`;
1172
+ t(key, values) {
1173
+ return translate({
1174
+ locale,
1175
+ defaultLocale,
1176
+ catalogs,
1177
+ key,
1178
+ values,
1179
+ });
1125
1180
  },
1181
+ locale,
1126
1182
  id() {
1127
1183
  return crypto.randomUUID();
1128
1184
  },
@@ -33,6 +33,7 @@ describe("gateDenialFailure", () => {
33
33
  },
34
34
  );
35
35
  expect(anon.error.code).toBe("Unauthorized");
36
+ expect(anon.error.message).toBe("Authentication required.");
36
37
 
37
38
  const forbid = gateDenialFailure(
38
39
  { name: "order:create", kind: "policy", allowed: false },
@@ -42,6 +43,8 @@ describe("gateDenialFailure", () => {
42
43
  },
43
44
  );
44
45
  expect(forbid.error.code).toBe("Forbidden");
46
+ expect(forbid.error.data).toEqual({ gate: "order:create", reason: "policy_denied" });
47
+ expect(forbid.error.message).toBe("Policy denied this request.");
45
48
 
46
49
  const rate = gateDenialFailure(
47
50
  {
@@ -58,6 +61,7 @@ describe("gateDenialFailure", () => {
58
61
  );
59
62
  expect(rate.error.code).toBe("RateLimited");
60
63
  expect(rate.error.data).toEqual({ retryAfterMs: 1500 });
64
+ expect(rate.error.message).toBe("Too many requests. Try again later.");
61
65
  });
62
66
  });
63
67
 
@@ -94,7 +94,7 @@ export function gateDenialFailure(evaluation: GateEvaluation, ctx: GatePolicyCon
94
94
  }
95
95
  return fail("Forbidden", {
96
96
  gate: evaluation.name,
97
- reason: evaluation.reason ?? "policy denied",
97
+ reason: evaluation.reason ?? "policy_denied",
98
98
  });
99
99
  }
100
100
 
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { ChannelTemplateDecl } from "../elements/channel/declare.ts";
18
+ import type { TemplateCatalog } from "../elements/channel/runtime.ts";
18
19
  import type { ClockDecl } from "../elements/clock/declare.ts";
19
20
  import type { GateDecl } from "../elements/gate/declare.ts";
20
21
  import type { SignalDecl } from "../elements/signal/declare.ts";
@@ -288,6 +289,12 @@ export interface PluginApi {
288
289
  * @param decl - Template from `channel.email.template(...)` etc.
289
290
  */
290
291
  channelTemplate(decl: ChannelTemplateDecl): PluginApi;
292
+ /**
293
+ * Contribute template body catalog entries (merged into boot channel catalog).
294
+ *
295
+ * @param catalog - Locale bodies keyed by template name (`{{field}}` interpolation)
296
+ */
297
+ channelCatalog(catalog: TemplateCatalog): PluginApi;
291
298
  }
292
299
 
293
300
  /** Captured capability lists for the Manifest. */
@@ -328,6 +335,7 @@ export interface PluginRegistration {
328
335
  readonly signals: readonly SignalDecl[];
329
336
  readonly gates: readonly GateDecl[];
330
337
  readonly channelTemplates: readonly ChannelTemplateDecl[];
338
+ readonly channelCatalogs: readonly TemplateCatalog[];
331
339
  }
332
340
 
333
341
  /**
@@ -467,6 +475,8 @@ export interface PluginDef<D extends Record<string, unknown> = {}> {
467
475
  gate(decl: GateDecl): PluginDef<D>;
468
476
  /** Queue a channel template. */
469
477
  channelTemplate(decl: ChannelTemplateDecl): PluginDef<D>;
478
+ /** Queue channel template body catalog entries. */
479
+ channelCatalog(catalog: TemplateCatalog): PluginDef<D>;
470
480
  }
471
481
 
472
482
  /** Extract accumulated decoration types from a {@link PluginDef}. */
@@ -624,6 +634,12 @@ export function plugin(name: string, options: PluginOptions): PluginDef {
624
634
  });
625
635
  return def;
626
636
  },
637
+ channelCatalog(catalog) {
638
+ steps.push((api) => {
639
+ api.channelCatalog(catalog);
640
+ });
641
+ return def;
642
+ },
627
643
  };
628
644
 
629
645
  return def;
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import type { ChannelTemplateDecl } from "../elements/channel/declare.ts";
16
+ import type { TemplateCatalog } from "../elements/channel/runtime.ts";
16
17
  import type { ClockDecl } from "../elements/clock/declare.ts";
17
18
  import type { GateDecl } from "../elements/gate/declare.ts";
18
19
  import type { SignalDecl } from "../elements/signal/declare.ts";
@@ -85,6 +86,7 @@ export function createRecordingApi(identity: { readonly name: string; readonly v
85
86
  const signals: SignalDecl[] = [];
86
87
  const gates: GateDecl[] = [];
87
88
  const channelTemplates: ChannelTemplateDecl[] = [];
89
+ const channelCatalogs: TemplateCatalog[] = [];
88
90
  const needs: string[] = [];
89
91
  const declares: string[] = [];
90
92
  const intercepts: string[] = [];
@@ -204,6 +206,13 @@ export function createRecordingApi(identity: { readonly name: string; readonly v
204
206
  pushDeclare(`channel:${decl.name}`);
205
207
  return api;
206
208
  },
209
+ channelCatalog(catalog) {
210
+ channelCatalogs.push(catalog);
211
+ for (const name of Object.keys(catalog)) {
212
+ pushDeclare(`channel-catalog:${name}`);
213
+ }
214
+ return api;
215
+ },
207
216
  };
208
217
 
209
218
  return {
@@ -238,6 +247,7 @@ export function createRecordingApi(identity: { readonly name: string; readonly v
238
247
  signals: signals.slice(),
239
248
  gates: gates.slice(),
240
249
  channelTemplates: channelTemplates.slice(),
250
+ channelCatalogs: channelCatalogs.slice(),
241
251
  };
242
252
  },
243
253
  };
@@ -318,6 +328,8 @@ export interface PluginRegistry {
318
328
  gateContributions(): readonly GateDecl[];
319
329
  /** Channel templates from installed plugins. */
320
330
  channelTemplateContributions(): readonly ChannelTemplateDecl[];
331
+ /** Channel template body catalogs from installed plugins. */
332
+ channelCatalogContributions(): readonly TemplateCatalog[];
321
333
  /** HTTP Bindings from installed plugins (auth method plugins). */
322
334
  bindingContributions(): readonly Binding[];
323
335
  }
@@ -489,6 +501,9 @@ export function createPluginRegistry(): PluginRegistry {
489
501
  channelTemplateContributions() {
490
502
  return firstPerPlugin(installed, (r) => r.channelTemplates);
491
503
  },
504
+ channelCatalogContributions() {
505
+ return firstPerPlugin(installed, (r) => r.channelCatalogs);
506
+ },
492
507
  bindingContributions() {
493
508
  return firstPerPlugin(installed, (r) => r.bindings);
494
509
  },
@@ -14,7 +14,11 @@ import type { Binding } from "../../kernel/on.ts";
14
14
  import { http } from "../../kernel/triggers.ts";
15
15
  import { createEnv } from "../../runtime/primitives.ts";
16
16
 
17
- export const AuthFailed = z.object({ reason: z.string().optional() });
17
+ export const AuthFailed = z.object({
18
+ reason: z.string().optional(),
19
+ /** Policy failure details (`username_policy` / `password_policy`). */
20
+ reasons: z.array(z.string()).optional(),
21
+ });
18
22
  export const AuthRateLimited = z.object({ reason: z.string() });
19
23
 
20
24
  export const SessionTokensOut = z.object({