@mccaskie-labs/workbench-config 0.1.12 → 0.1.14

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @mccaskie-labs/workbench-config
2
2
 
3
+ ## 0.1.14
4
+
5
+ ### Patch Changes
6
+
7
+ - Shared voices (`docs/spec.md` §2.8). `workbench-ai`: `voice` and `spelling` options on `text`, `structured`, `stream` and `countTokens` (a Workbench voice by name, or `{ instructions }` for the app's own), `voice` on a text result, and a writing check with no AI call: `checkWriting`, `writingStops`, `writingNotes`, `writingFixNote`, `paragraphBands`. Paragraph length is a target: quiet up to 1.25 times, a note up to double, a stop only past that. `createFakeAi` records the voice and spelling of each call. `workbench-config`: the `ai` manifest lists the voices, spellings, targets and banned phrases, and three probes. `workbench-ai` now needs `workbench-config` 0.1.14.
8
+
9
+ ## 0.1.13
10
+
11
+ ### Patch Changes
12
+
13
+ - The brand file: `brand.json` at the root of an app's repo (`BRAND_FILE`), checked by `defineBrand` (or `brandSchema`) and filled with the default colours (`BRAND_DEFAULTS`, `resolveBrand`). Name, site address, logo (https PNG, JPEG or SVG), colours, optional fonts, support address and footer line. workbench-email's account emails, workbench-pdf and Supabase's sign-in emails all read it, so an app is branded once.
14
+
3
15
  ## 0.1.12
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -27,6 +27,7 @@ const { WORKBENCH_GATEWAY_KEY } = config.parse(env)
27
27
  | `sharedSecret(binding, name?)` | Reads a Secrets Store binding; throws if it is empty |
28
28
  | `redact(value)`, `redactAll(text, secrets)` | `[redacted]` in place of a secret, for logs |
29
29
  | `capabilities`, `MANIFEST_VERSION`, `capabilitySchema` | The Workbench capabilities manifests and their schema. The JSON files are also at `@mccaskie-labs/workbench-config/capabilities/<component>.json` |
30
+ | `defineBrand(json)`, `brandSchema`, `resolveBrand`, `BRAND_DEFAULTS`, `BRAND_FILE` | The app's brand file, `brand.json` at the repo root: `name`, `siteUrl` (https), `logo.url` (https PNG, JPEG or SVG; optional `alt`, `width`, `height`), `colours.primary` (`#rrggbb`; `text`, `background`, `muted` default), optional `fonts.heading`/`fonts.body` (`{ family, url? }`, `url` an https stylesheet), `supportEmail` and `footer`. Unknown keys fail. Account emails, PDFs and Supabase's sign-in emails all read it |
30
31
  | `DEFAULT_GATEWAY_URL` | `https://gateway.mccaskielabs.dev` |
31
32
 
32
33
  `npx workbench-config check [dir]` compares the variable names in `.dev.vars` with `.dev.vars.example` and prints names only.
@@ -7,7 +7,7 @@
7
7
  "status": "built",
8
8
  "surfaces": {
9
9
  "gateway": ["POST /anthropic/v1/messages", "POST /anthropic/v1/messages/count_tokens"],
10
- "package": ["createAi", "Ai.text", "Ai.structured", "Ai.stream", "Ai.countTokens", "Ai.budget", "createFakeAi"]
10
+ "package": ["createAi", "Ai.text", "Ai.structured", "Ai.stream", "Ai.countTokens", "Ai.budget", "createFakeAi", "checkWriting", "writingStops", "writingNotes", "writingFixNote"]
11
11
  },
12
12
  "config": [
13
13
  {
@@ -33,7 +33,43 @@
33
33
  ],
34
34
  "explicit_models": ["claude-sonnet-5", "claude-opus-5"],
35
35
  "server_tools": ["web_search", "web_fetch", "code_execution"],
36
- "required_headers": ["X-Workbench-Job"]
36
+ "required_headers": ["X-Workbench-Job"],
37
+ "optional_headers": ["X-Workbench-Voice", "X-Workbench-Spelling"],
38
+ "voices": [
39
+ { "name": "casual", "paragraph_target": 50, "sentence_target": 18 },
40
+ { "name": "conversational", "paragraph_target": 60, "sentence_target": 22 },
41
+ { "name": "plain", "paragraph_target": 60, "sentence_target": 20 },
42
+ { "name": "formal", "paragraph_target": 80, "sentence_target": 25 },
43
+ { "name": "strict-formal", "paragraph_target": 80, "sentence_target": 28 }
44
+ ],
45
+ "spellings": ["en-AU", "en-PH", "en-GB", "en-US"],
46
+ "writing": {
47
+ "voice_set_version": 1,
48
+ "house_paragraph_target": 80,
49
+ "paragraph_grace": 1.25,
50
+ "paragraph_ceiling": 2,
51
+ "banned_phrases": [
52
+ "delve",
53
+ "it's worth noting",
54
+ "it is worth noting",
55
+ "it's important to note",
56
+ "it is important to note",
57
+ "in today's fast-paced world",
58
+ "navigate the complexities",
59
+ "a testament to",
60
+ "game-changer",
61
+ "game-changing",
62
+ "let's dive in",
63
+ "dive into",
64
+ "rest assured",
65
+ "in conclusion",
66
+ "at the end of the day",
67
+ "the world of",
68
+ "unlock the",
69
+ "embark on",
70
+ "tapestry"
71
+ ]
72
+ }
37
73
  },
38
74
  "limits": [
39
75
  { "id": "ai.retry-attempts", "value": 3, "unit": "attempts", "per": "request", "enforced_by": "gateway" }
@@ -45,7 +81,8 @@
45
81
  "rules": [
46
82
  { "id": "ai.no-partial-success", "kind": "must_not", "text": "Treat a refusal or a max_tokens truncation as a successful result.", "enforced_by": "types", "probe": "ai.text.truncation-throws" },
47
83
  { "id": "ai.store-resolved-model", "kind": "must", "text": "Store the resolved model and request id with any output kept.", "enforced_by": "review", "probe": "ai.text.claude-default" },
48
- { "id": "ai.aliases-only", "kind": "must", "text": "Ask for a Workbench alias (or an allowlisted explicit id), never an arbitrary model id.", "enforced_by": "gateway", "probe": "ai.model-not-allowed" }
84
+ { "id": "ai.aliases-only", "kind": "must", "text": "Ask for a Workbench alias (or an allowlisted explicit id), never an arbitrary model id.", "enforced_by": "gateway", "probe": "ai.model-not-allowed" },
85
+ { "id": "ai.voice-by-name", "kind": "must", "text": "Name a Workbench voice and spelling with the voice and spelling options, so the Gateway adds the text, or bring the app's own voice. Never paste Workbench's voice text into a prompt.", "enforced_by": "gateway", "probe": "ai.voice" }
49
86
  ],
50
87
  "examples": [
51
88
  {
@@ -54,6 +91,13 @@
54
91
  "lang": "ts",
55
92
  "probe": "ai.text.claude-default",
56
93
  "code": "const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env) })\nconst r = await ai.text({ model: 'claude-default', job: 'article.write', prompt })\nawait save({ text: r.text, model: r.model, requestId: r.requestId })"
94
+ },
95
+ {
96
+ "id": "ai.example.voice",
97
+ "title": "Text in a Workbench voice, then checked without AI",
98
+ "lang": "ts",
99
+ "probe": "ai.voice",
100
+ "code": "const r = await ai.text({ model: 'claude-default', job: 'article.write', voice: 'conversational', spelling: 'en-AU', prompt })\nconst issues = checkWriting(r.text, { voice: 'conversational' })\nif (writingStops(issues).length > 0) await retryWith(prompt + writingFixNote(issues))"
57
101
  }
58
102
  ],
59
103
  "probes": [
@@ -65,6 +109,9 @@
65
109
  { "id": "ai.text.truncation-throws", "half": "runtime", "expect": "pass", "cost_hint_usd": 0.001 },
66
110
  { "id": "ai.model-not-allowed", "half": "runtime", "expect": "refused_as_expected", "refusal": "model_not_allowed", "cost_hint_usd": 0 },
67
111
  { "id": "ai.count-tokens", "half": "runtime", "expect": "pass", "smoke": true, "cost_hint_usd": 0 },
68
- { "id": "ai.budget", "half": "runtime", "expect": "pass", "smoke": true, "cost_hint_usd": 0 }
112
+ { "id": "ai.budget", "half": "runtime", "expect": "pass", "smoke": true, "cost_hint_usd": 0 },
113
+ { "id": "ai.voice", "half": "runtime", "expect": "pass", "cost_hint_usd": 0.004 },
114
+ { "id": "ai.voice.unknown", "half": "runtime", "expect": "refused_as_expected", "refusal": "invalid_request", "cost_hint_usd": 0 },
115
+ { "id": "ai.voice.cache-read", "half": "runtime", "expect": "pass", "cost_hint_usd": 0.008 }
69
116
  ]
70
117
  }
@@ -0,0 +1,61 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * One brand file per app, read by workbench-email (account emails) and
4
+ * workbench-pdf (branded PDFs), so an app is branded once. Agreed between the
5
+ * sign-in kit and branded PDFs work on 11 November 2026.
6
+ *
7
+ * Lives in the app's repo as `brand.json` at the root, so Control can read it
8
+ * at a ref (as it reads migration files) without running the app's code. The
9
+ * app imports and checks it once:
10
+ *
11
+ * import brandFile from '../brand.json'
12
+ * export const brand = defineBrand(brandFile)
13
+ */
14
+ /** Where the brand file sits in an app's repo. */
15
+ export declare const BRAND_FILE = "brand.json";
16
+ export declare const brandSchema: z.ZodObject<{
17
+ name: z.ZodString;
18
+ siteUrl: z.ZodURL;
19
+ logo: z.ZodObject<{
20
+ url: z.ZodURL;
21
+ alt: z.ZodOptional<z.ZodString>;
22
+ width: z.ZodOptional<z.ZodNumber>;
23
+ height: z.ZodOptional<z.ZodNumber>;
24
+ }, z.core.$strict>;
25
+ colours: z.ZodObject<{
26
+ primary: z.ZodString;
27
+ text: z.ZodOptional<z.ZodString>;
28
+ background: z.ZodOptional<z.ZodString>;
29
+ muted: z.ZodOptional<z.ZodString>;
30
+ }, z.core.$strict>;
31
+ fonts: z.ZodOptional<z.ZodObject<{
32
+ heading: z.ZodOptional<z.ZodObject<{
33
+ family: z.ZodString;
34
+ url: z.ZodOptional<z.ZodURL>;
35
+ }, z.core.$strict>>;
36
+ body: z.ZodOptional<z.ZodObject<{
37
+ family: z.ZodString;
38
+ url: z.ZodOptional<z.ZodURL>;
39
+ }, z.core.$strict>>;
40
+ }, z.core.$strict>>;
41
+ supportEmail: z.ZodOptional<z.ZodEmail>;
42
+ footer: z.ZodOptional<z.ZodString>;
43
+ }, z.core.$strict>;
44
+ export type Brand = z.infer<typeof brandSchema>;
45
+ /** The colours a brand leaves out. */
46
+ export declare const BRAND_DEFAULTS: {
47
+ readonly text: "#1a1a1a";
48
+ readonly background: "#ffffff";
49
+ readonly muted: "#6b6b6b";
50
+ };
51
+ /** A brand with every colour filled, so emails and PDFs fill the same values. */
52
+ export type ResolvedBrand = Brand & {
53
+ colours: Required<Brand["colours"]>;
54
+ };
55
+ /**
56
+ * Validates a brand and fills its default colours. Throws a plain Error
57
+ * naming every bad field (the brand holds nothing secret, so values may show).
58
+ */
59
+ export declare function defineBrand(input: unknown): ResolvedBrand;
60
+ /** Fills the default colours of an already valid brand. */
61
+ export declare function resolveBrand(brand: Brand): ResolvedBrand;
package/dist/brand.js ADDED
@@ -0,0 +1,83 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * One brand file per app, read by workbench-email (account emails) and
4
+ * workbench-pdf (branded PDFs), so an app is branded once. Agreed between the
5
+ * sign-in kit and branded PDFs work on 11 November 2026.
6
+ *
7
+ * Lives in the app's repo as `brand.json` at the root, so Control can read it
8
+ * at a ref (as it reads migration files) without running the app's code. The
9
+ * app imports and checks it once:
10
+ *
11
+ * import brandFile from '../brand.json'
12
+ * export const brand = defineBrand(brandFile)
13
+ */
14
+ /** Where the brand file sits in an app's repo. */
15
+ export const BRAND_FILE = "brand.json";
16
+ const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, "expected a colour like #1f6feb");
17
+ const https = z.url({ protocol: /^https$/ });
18
+ // A PNG, JPEG or SVG by its path: the PDF inlines the logo and refuses
19
+ // anything else, and email clients show only these reliably.
20
+ const logoUrl = https.refine((u) => /\.(png|jpe?g|svg)$/i.test(new URL(u).pathname), "expected a .png, .jpg, .jpeg or .svg URL");
21
+ const font = z.strictObject({
22
+ /** The CSS family name. Without `url` it is used as a local font, with a system fallback. */
23
+ family: z.string().min(1).max(100),
24
+ /** An https stylesheet, such as a Google Fonts css2 link. */
25
+ url: https.optional(),
26
+ });
27
+ export const brandSchema = z.strictObject({
28
+ /** The app's name as people see it: "Sent by <name>", the PDF header. */
29
+ name: z.string().min(1).max(60),
30
+ /** The app's home page, https. Email links and the PDF footer. */
31
+ siteUrl: https,
32
+ logo: z.strictObject({
33
+ url: logoUrl,
34
+ alt: z.string().min(1).max(100).optional(),
35
+ /** Display width in CSS pixels; emails default to 120. */
36
+ width: z.number().int().min(16).max(600).optional(),
37
+ height: z.number().int().min(16).max(600).optional(),
38
+ }),
39
+ colours: z.strictObject({
40
+ /** Buttons, links and headings. */
41
+ primary: hex,
42
+ text: hex.optional(),
43
+ background: hex.optional(),
44
+ /** Small print and rules. */
45
+ muted: hex.optional(),
46
+ }),
47
+ fonts: z.strictObject({ heading: font.optional(), body: font.optional() }).optional(),
48
+ /** Shown as "Questions? Write to …" in account emails. */
49
+ supportEmail: z.email().optional(),
50
+ /** One line of small print under every email and PDF page. */
51
+ footer: z.string().min(1).max(200).optional(),
52
+ });
53
+ /** The colours a brand leaves out. */
54
+ export const BRAND_DEFAULTS = {
55
+ text: "#1a1a1a",
56
+ background: "#ffffff",
57
+ muted: "#6b6b6b",
58
+ };
59
+ /**
60
+ * Validates a brand and fills its default colours. Throws a plain Error
61
+ * naming every bad field (the brand holds nothing secret, so values may show).
62
+ */
63
+ export function defineBrand(input) {
64
+ const result = brandSchema.safeParse(input);
65
+ if (!result.success) {
66
+ const lines = result.error.issues.map((i) => `${i.path.join(".") || "(brand)"}: ${i.message}`);
67
+ throw new Error(`Invalid brand:\n${lines.join("\n")}`);
68
+ }
69
+ return resolveBrand(result.data);
70
+ }
71
+ /** Fills the default colours of an already valid brand. */
72
+ export function resolveBrand(brand) {
73
+ const c = brand.colours;
74
+ return {
75
+ ...brand,
76
+ colours: {
77
+ primary: c.primary,
78
+ text: c.text ?? BRAND_DEFAULTS.text,
79
+ background: c.background ?? BRAND_DEFAULTS.background,
80
+ muted: c.muted ?? BRAND_DEFAULTS.muted,
81
+ },
82
+ };
83
+ }
@@ -1,4 +1,4 @@
1
- export declare const MANIFEST_VERSION: "f3d5b55e579d";
1
+ export declare const MANIFEST_VERSION: "d0f2550e1f81";
2
2
  export declare const capabilities: {
3
3
  readonly ai: {
4
4
  readonly $schema: "./schema.json";
@@ -9,7 +9,7 @@ export declare const capabilities: {
9
9
  readonly status: "built";
10
10
  readonly surfaces: {
11
11
  readonly gateway: readonly ["POST /anthropic/v1/messages", "POST /anthropic/v1/messages/count_tokens"];
12
- readonly package: readonly ["createAi", "Ai.text", "Ai.structured", "Ai.stream", "Ai.countTokens", "Ai.budget", "createFakeAi"];
12
+ readonly package: readonly ["createAi", "Ai.text", "Ai.structured", "Ai.stream", "Ai.countTokens", "Ai.budget", "createFakeAi", "checkWriting", "writingStops", "writingNotes", "writingFixNote"];
13
13
  };
14
14
  readonly config: readonly [{
15
15
  readonly name: "WORKBENCH_GATEWAY_KEY";
@@ -41,6 +41,36 @@ export declare const capabilities: {
41
41
  readonly explicit_models: readonly ["claude-sonnet-5", "claude-opus-5"];
42
42
  readonly server_tools: readonly ["web_search", "web_fetch", "code_execution"];
43
43
  readonly required_headers: readonly ["X-Workbench-Job"];
44
+ readonly optional_headers: readonly ["X-Workbench-Voice", "X-Workbench-Spelling"];
45
+ readonly voices: readonly [{
46
+ readonly name: "casual";
47
+ readonly paragraph_target: 50;
48
+ readonly sentence_target: 18;
49
+ }, {
50
+ readonly name: "conversational";
51
+ readonly paragraph_target: 60;
52
+ readonly sentence_target: 22;
53
+ }, {
54
+ readonly name: "plain";
55
+ readonly paragraph_target: 60;
56
+ readonly sentence_target: 20;
57
+ }, {
58
+ readonly name: "formal";
59
+ readonly paragraph_target: 80;
60
+ readonly sentence_target: 25;
61
+ }, {
62
+ readonly name: "strict-formal";
63
+ readonly paragraph_target: 80;
64
+ readonly sentence_target: 28;
65
+ }];
66
+ readonly spellings: readonly ["en-AU", "en-PH", "en-GB", "en-US"];
67
+ readonly writing: {
68
+ readonly voice_set_version: 1;
69
+ readonly house_paragraph_target: 80;
70
+ readonly paragraph_grace: 1.25;
71
+ readonly paragraph_ceiling: 2;
72
+ readonly banned_phrases: readonly ["delve", "it's worth noting", "it is worth noting", "it's important to note", "it is important to note", "in today's fast-paced world", "navigate the complexities", "a testament to", "game-changer", "game-changing", "let's dive in", "dive into", "rest assured", "in conclusion", "at the end of the day", "the world of", "unlock the", "embark on", "tapestry"];
73
+ };
44
74
  };
45
75
  readonly limits: readonly [{
46
76
  readonly id: "ai.retry-attempts";
@@ -78,6 +108,12 @@ export declare const capabilities: {
78
108
  readonly text: "Ask for a Workbench alias (or an allowlisted explicit id), never an arbitrary model id.";
79
109
  readonly enforced_by: "gateway";
80
110
  readonly probe: "ai.model-not-allowed";
111
+ }, {
112
+ readonly id: "ai.voice-by-name";
113
+ readonly kind: "must";
114
+ readonly text: "Name a Workbench voice and spelling with the voice and spelling options, so the Gateway adds the text, or bring the app's own voice. Never paste Workbench's voice text into a prompt.";
115
+ readonly enforced_by: "gateway";
116
+ readonly probe: "ai.voice";
81
117
  }];
82
118
  readonly examples: readonly [{
83
119
  readonly id: "ai.example.text";
@@ -85,6 +121,12 @@ export declare const capabilities: {
85
121
  readonly lang: "ts";
86
122
  readonly code: "const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env) })\nconst r = await ai.text({ model: 'claude-default', job: 'article.write', prompt })\nawait save({ text: r.text, model: r.model, requestId: r.requestId })";
87
123
  readonly probe: "ai.text.claude-default";
124
+ }, {
125
+ readonly id: "ai.example.voice";
126
+ readonly title: "Text in a Workbench voice, then checked without AI";
127
+ readonly lang: "ts";
128
+ readonly code: "const r = await ai.text({ model: 'claude-default', job: 'article.write', voice: 'conversational', spelling: 'en-AU', prompt })\nconst issues = checkWriting(r.text, { voice: 'conversational' })\nif (writingStops(issues).length > 0) await retryWith(prompt + writingFixNote(issues))";
129
+ readonly probe: "ai.voice";
88
130
  }];
89
131
  readonly probes: readonly [{
90
132
  readonly id: "ai.text.claude-default";
@@ -135,6 +177,22 @@ export declare const capabilities: {
135
177
  readonly expect: "pass";
136
178
  readonly smoke: true;
137
179
  readonly cost_hint_usd: 0;
180
+ }, {
181
+ readonly id: "ai.voice";
182
+ readonly half: "runtime";
183
+ readonly expect: "pass";
184
+ readonly cost_hint_usd: 0.004;
185
+ }, {
186
+ readonly id: "ai.voice.unknown";
187
+ readonly half: "runtime";
188
+ readonly expect: "refused_as_expected";
189
+ readonly refusal: "invalid_request";
190
+ readonly cost_hint_usd: 0;
191
+ }, {
192
+ readonly id: "ai.voice.cache-read";
193
+ readonly half: "runtime";
194
+ readonly expect: "pass";
195
+ readonly cost_hint_usd: 0.008;
138
196
  }];
139
197
  };
140
198
  readonly analytics: {
@@ -1,6 +1,6 @@
1
1
  // Generated by `npm run capabilities:build` from capabilities/*.json. Do not edit.
2
2
  // check:capabilities fails in Checks when this file is stale.
3
- export const MANIFEST_VERSION = "f3d5b55e579d";
3
+ export const MANIFEST_VERSION = "d0f2550e1f81";
4
4
  export const capabilities = {
5
5
  "ai": {
6
6
  "$schema": "./schema.json",
@@ -21,7 +21,11 @@ export const capabilities = {
21
21
  "Ai.stream",
22
22
  "Ai.countTokens",
23
23
  "Ai.budget",
24
- "createFakeAi"
24
+ "createFakeAi",
25
+ "checkWriting",
26
+ "writingStops",
27
+ "writingNotes",
28
+ "writingFixNote"
25
29
  ]
26
30
  },
27
31
  "config": [
@@ -76,7 +80,71 @@ export const capabilities = {
76
80
  ],
77
81
  "required_headers": [
78
82
  "X-Workbench-Job"
79
- ]
83
+ ],
84
+ "optional_headers": [
85
+ "X-Workbench-Voice",
86
+ "X-Workbench-Spelling"
87
+ ],
88
+ "voices": [
89
+ {
90
+ "name": "casual",
91
+ "paragraph_target": 50,
92
+ "sentence_target": 18
93
+ },
94
+ {
95
+ "name": "conversational",
96
+ "paragraph_target": 60,
97
+ "sentence_target": 22
98
+ },
99
+ {
100
+ "name": "plain",
101
+ "paragraph_target": 60,
102
+ "sentence_target": 20
103
+ },
104
+ {
105
+ "name": "formal",
106
+ "paragraph_target": 80,
107
+ "sentence_target": 25
108
+ },
109
+ {
110
+ "name": "strict-formal",
111
+ "paragraph_target": 80,
112
+ "sentence_target": 28
113
+ }
114
+ ],
115
+ "spellings": [
116
+ "en-AU",
117
+ "en-PH",
118
+ "en-GB",
119
+ "en-US"
120
+ ],
121
+ "writing": {
122
+ "voice_set_version": 1,
123
+ "house_paragraph_target": 80,
124
+ "paragraph_grace": 1.25,
125
+ "paragraph_ceiling": 2,
126
+ "banned_phrases": [
127
+ "delve",
128
+ "it's worth noting",
129
+ "it is worth noting",
130
+ "it's important to note",
131
+ "it is important to note",
132
+ "in today's fast-paced world",
133
+ "navigate the complexities",
134
+ "a testament to",
135
+ "game-changer",
136
+ "game-changing",
137
+ "let's dive in",
138
+ "dive into",
139
+ "rest assured",
140
+ "in conclusion",
141
+ "at the end of the day",
142
+ "the world of",
143
+ "unlock the",
144
+ "embark on",
145
+ "tapestry"
146
+ ]
147
+ }
80
148
  },
81
149
  "limits": [
82
150
  {
@@ -122,6 +190,13 @@ export const capabilities = {
122
190
  "text": "Ask for a Workbench alias (or an allowlisted explicit id), never an arbitrary model id.",
123
191
  "enforced_by": "gateway",
124
192
  "probe": "ai.model-not-allowed"
193
+ },
194
+ {
195
+ "id": "ai.voice-by-name",
196
+ "kind": "must",
197
+ "text": "Name a Workbench voice and spelling with the voice and spelling options, so the Gateway adds the text, or bring the app's own voice. Never paste Workbench's voice text into a prompt.",
198
+ "enforced_by": "gateway",
199
+ "probe": "ai.voice"
125
200
  }
126
201
  ],
127
202
  "examples": [
@@ -131,6 +206,13 @@ export const capabilities = {
131
206
  "lang": "ts",
132
207
  "code": "const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env) })\nconst r = await ai.text({ model: 'claude-default', job: 'article.write', prompt })\nawait save({ text: r.text, model: r.model, requestId: r.requestId })",
133
208
  "probe": "ai.text.claude-default"
209
+ },
210
+ {
211
+ "id": "ai.example.voice",
212
+ "title": "Text in a Workbench voice, then checked without AI",
213
+ "lang": "ts",
214
+ "code": "const r = await ai.text({ model: 'claude-default', job: 'article.write', voice: 'conversational', spelling: 'en-AU', prompt })\nconst issues = checkWriting(r.text, { voice: 'conversational' })\nif (writingStops(issues).length > 0) await retryWith(prompt + writingFixNote(issues))",
215
+ "probe": "ai.voice"
134
216
  }
135
217
  ],
136
218
  "probes": [
@@ -191,6 +273,25 @@ export const capabilities = {
191
273
  "expect": "pass",
192
274
  "smoke": true,
193
275
  "cost_hint_usd": 0
276
+ },
277
+ {
278
+ "id": "ai.voice",
279
+ "half": "runtime",
280
+ "expect": "pass",
281
+ "cost_hint_usd": 0.004
282
+ },
283
+ {
284
+ "id": "ai.voice.unknown",
285
+ "half": "runtime",
286
+ "expect": "refused_as_expected",
287
+ "refusal": "invalid_request",
288
+ "cost_hint_usd": 0
289
+ },
290
+ {
291
+ "id": "ai.voice.cache-read",
292
+ "half": "runtime",
293
+ "expect": "pass",
294
+ "cost_hint_usd": 0.008
194
295
  }
195
296
  ]
196
297
  },
@@ -39,6 +39,20 @@ export declare const allowedSchemas: {
39
39
  explicit_models: z.ZodArray<z.ZodString>;
40
40
  server_tools: z.ZodArray<z.ZodString>;
41
41
  required_headers: z.ZodArray<z.ZodString>;
42
+ optional_headers: z.ZodArray<z.ZodString>;
43
+ voices: z.ZodArray<z.ZodObject<{
44
+ name: z.ZodString;
45
+ paragraph_target: z.ZodNumber;
46
+ sentence_target: z.ZodNumber;
47
+ }, z.core.$strict>>;
48
+ spellings: z.ZodArray<z.ZodString>;
49
+ writing: z.ZodObject<{
50
+ voice_set_version: z.ZodNumber;
51
+ house_paragraph_target: z.ZodNumber;
52
+ paragraph_grace: z.ZodNumber;
53
+ paragraph_ceiling: z.ZodNumber;
54
+ banned_phrases: z.ZodArray<z.ZodString>;
55
+ }, z.core.$strict>;
42
56
  }, z.core.$strict>;
43
57
  images: z.ZodObject<{
44
58
  aliases: z.ZodArray<z.ZodObject<{
@@ -99,6 +99,20 @@ export const allowedSchemas = {
99
99
  explicit_models: z.array(z.string().min(1)),
100
100
  server_tools: z.array(z.string().min(1)),
101
101
  required_headers: z.array(z.string().min(1)),
102
+ // Voices and spelling (docs/spec.md §2.8). The Gateway holds the text; these are the names and the
103
+ // figures the package's writing check needs, kept here so the two can't drift apart.
104
+ optional_headers: z.array(z.string().min(1)),
105
+ voices: z
106
+ .array(z.strictObject({ name: z.string().regex(/^[a-z][a-z0-9-]{0,39}$/), paragraph_target: z.number().int().positive(), sentence_target: z.number().int().positive() }))
107
+ .min(1),
108
+ spellings: z.array(z.string().regex(/^[a-z]{2,3}-[A-Z]{2}$/)).min(1),
109
+ writing: z.strictObject({
110
+ voice_set_version: z.number().int().positive(),
111
+ house_paragraph_target: z.number().int().positive(),
112
+ paragraph_grace: z.number().min(1),
113
+ paragraph_ceiling: z.number().min(1),
114
+ banned_phrases: z.array(z.string().min(1)).min(1),
115
+ }),
102
116
  }),
103
117
  images: z.strictObject({
104
118
  aliases: z.array(aliasEntry.extend({ provider: z.enum(["google", "openai"]) })).min(1),
package/dist/index.d.ts CHANGED
@@ -5,3 +5,4 @@ export { WorkbenchConfigError, type ConfigErrorCode, type ConfigProblem } from "
5
5
  export { capabilitySchema, checkManifests, allowedSchemas, COMPONENTS, type Capability, type Component, type ManifestProblem, } from "./capability-schema.ts";
6
6
  export { capabilities, MANIFEST_VERSION, type Alias, type GatewayScope } from "./capabilities.gen.ts";
7
7
  export { compareDevVars, parseDevVarNames } from "./dev-vars.ts";
8
+ export { brandSchema, defineBrand, BRAND_FILE, resolveBrand, BRAND_DEFAULTS, type Brand, type ResolvedBrand } from "./brand.ts";
package/dist/index.js CHANGED
@@ -5,3 +5,4 @@ export { WorkbenchConfigError } from "./errors.js";
5
5
  export { capabilitySchema, checkManifests, allowedSchemas, COMPONENTS, } from "./capability-schema.js";
6
6
  export { capabilities, MANIFEST_VERSION } from "./capabilities.gen.js";
7
7
  export { compareDevVars, parseDevVarNames } from "./dev-vars.js";
8
+ export { brandSchema, defineBrand, BRAND_FILE, resolveBrand, BRAND_DEFAULTS } from "./brand.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mccaskie-labs/workbench-config",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Workbench app configuration: WORKBENCH_ENV, typed variables that fail by name, secrets that are never printed, and the capabilities manifest.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",