@mccaskie-labs/workbench-config 0.3.1 → 0.3.2

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,11 @@
1
1
  # @mccaskie-labs/workbench-config
2
2
 
3
+ ## 0.3.2
4
+
5
+ ### Patch Changes
6
+
7
+ - The ai manifest gains the reply drafter (capability 4): `Ai.reply` and `checkGuide`, the rule `ai.reply-facts-from-code`, the probe `ai.reply`, the `wb-reply-` styles, and a third route in its client questions, "Replies to messages", with what replies answer, which facts they use, the language (match the message, Taglish, English or Tagalog), the tone, the length and what a reply must never say.
8
+
3
9
  ## 0.3.1
4
10
 
5
11
  ### Patch Changes
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "surfaces": {
13
13
  "gateway": ["POST /anthropic/v1/messages", "POST /anthropic/v1/messages/count_tokens"],
14
- "package": ["createAi", "Ai.text", "Ai.structured", "Ai.stream", "Ai.countTokens", "Ai.budget", "createFakeAi", "checkWriting", "writingStops", "writingNotes", "writingFixNote"]
14
+ "package": ["createAi", "Ai.text", "Ai.structured", "Ai.stream", "Ai.countTokens", "Ai.budget", "createFakeAi", "checkWriting", "writingStops", "writingNotes", "writingFixNote", "Ai.reply", "checkGuide"]
15
15
  },
16
16
  "config": [
17
17
  {
@@ -86,7 +86,8 @@
86
86
  { "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" },
87
87
  { "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" },
88
88
  { "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" },
89
- { "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" }
89
+ { "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" },
90
+ { "id": "ai.reply-facts-from-code", "kind": "must", "text": "Draft replies with ai.reply from facts worked out by code (prices and dates from quote(), never from the AI), and show the draft in renderReplyBox for a person to check and copy. Never send a draft automatically, and never let a draft with needs_human_check go out unread.", "enforced_by": "review", "probe": "ai.reply" }
90
91
  ],
91
92
  "examples": [
92
93
  {
@@ -102,6 +103,13 @@
102
103
  "lang": "ts",
103
104
  "probe": "ai.voice",
104
105
  "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))"
106
+ },
107
+ {
108
+ "id": "ai.example.reply",
109
+ "title": "Draft a reply to an enquiry",
110
+ "lang": "ts",
111
+ "probe": "ai.reply",
112
+ "code": "import { REPLY_GUIDES } from './replies.generated'\nconst ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env), url: env.WORKBENCH_GATEWAY_URL, replyGuides: REPLY_GUIDES })\nconst q = quote(settings, ask)\nconst draft = await ai.reply({\n guide: 'venue-quote',\n facts: { venue_name: venue.name, date: ask.date, available: q.available, packages: q.options.map((o) => ({ name: o.package, price: o.total.amount })), alternatives: q.alternatives.map((a) => a.date) },\n incoming: { text, intake: read },\n language: account.reply_language,\n})\nreturn html(renderReplyBox(draft, { againAction: `/enquiries/${id}/reply` }))"
105
113
  }
106
114
  ],
107
115
  "probes": [
@@ -116,20 +124,22 @@
116
124
  { "id": "ai.budget", "half": "runtime", "expect": "pass", "smoke": true, "cost_hint_usd": 0 },
117
125
  { "id": "ai.voice", "half": "runtime", "expect": "pass", "cost_hint_usd": 0.004 },
118
126
  { "id": "ai.voice.unknown", "half": "runtime", "expect": "refused_as_expected", "refusal": "invalid_request", "cost_hint_usd": 0 },
119
- { "id": "ai.voice.cache-read", "half": "runtime", "expect": "pass", "cost_hint_usd": 0.008 }
127
+ { "id": "ai.voice.cache-read", "half": "runtime", "expect": "pass", "cost_hint_usd": 0.008 },
128
+ { "id": "ai.reply", "half": "runtime", "expect": "pass", "cost_hint_usd": 0.005 }
120
129
  ],
130
+ "styles": { "prefix": "wb-reply-" },
121
131
  "offer": {
122
132
  "pattern": "feature_or_content",
123
- "when": "The client wants the app to write, summarise, sort, answer questions or pull details out of text with AI, as a feature visitors use, or wants wording for the site written once.",
133
+ "when": "The client wants the app to write, summarise, sort, answer questions or pull details out of text with AI, as a feature visitors use, or wants wording for the site written once, or wants ready-to-paste replies to the messages the business gets (quote requests, booking questions).",
124
134
  "say": "I'll do this with Workbench AI, which uses Claude through Workbench, so the app holds no AI key and spending has a monthly cap.",
125
135
  "answers_file": "workbench/ai/<name>.json",
126
136
  "per": "feature or piece of wording",
127
- "answers_format": "For a feature: `{ \"format\": 1, \"route\": \"feature\", \"name\", \"about\" (the client's words), \"who\", \"cap_usd\", \"keep\", \"job\" }`, where `job` is the `X-Workbench-Job` the feature's calls send (`<name>` with dots, such as `quotes.estimate`), so its spending shows on its own line in Control. Write it, then build the feature with the package. For wording made once: `npx workbench-ai pick <name> <round>-<draft>` writes it from the draft the client picked, with the round's request ids and cost; never write one from nothing. Then put the wording into the strings catalogue (the workbench-strings skill), keyed for where it is shown. Drafts and their previews stay in `workbench/ai/drafts/`, which isn't committed.",
137
+ "answers_format": "For a feature: `{ \"format\": 1, \"route\": \"feature\", \"name\", \"about\" (the client's words), \"who\", \"cap_usd\", \"keep\", \"job\" }`, where `job` is the `X-Workbench-Job` the feature's calls send (`<name>` with dots, such as `quotes.estimate`), so its spending shows on its own line in Control. Write it, then build the feature with the package. For wording made once: `npx workbench-ai pick <name> <round>-<draft>` writes it from the draft the client picked, with the round's request ids and cost; never write one from nothing. Then put the wording into the strings catalogue (the workbench-strings skill), keyed for where it is shown. Drafts and their previews stay in `workbench/ai/drafts/`, which isn't committed. For replies: write the guide itself to `workbench/replies/<name>.json` (format 1: `name`, `version`, `voice`, `spelling`, `max_words`, `max_questions`, `facts` as a small JSON Schema with `x-money` on amounts and `format: date` on dates, `ask_order`, `guide` lines, `examples`, `never`), then run `npx workbench-ai replies generate`, which checks it and writes `src/replies.generated.ts`; never edit that file. Offer samples with `npx workbench-ai replies drafts <name> --languages <the client's languages>` (two to four, with the development key; tell the client the cost it prints) and keep the one the client picks with `npx workbench-ai replies pick <name> <round>-<draft>`, which makes it the guide's first example.",
128
138
  "questions": [
129
139
  {
130
140
  "id": "ai.route",
131
141
  "sets": "route",
132
- "ask": "Is this something visitors use on the site, or something made once for the site?",
142
+ "ask": "Is this something visitors use on the site, something made once for the site, or replies to the messages you get?",
133
143
  "style": "cards",
134
144
  "answers": [
135
145
  {
@@ -141,10 +151,15 @@
141
151
  "label": "Made once for the site",
142
152
  "value": "content",
143
153
  "means": "Wording for a page, written once and kept: an About page, a welcome, product descriptions."
154
+ },
155
+ {
156
+ "label": "Replies to messages",
157
+ "value": "reply",
158
+ "means": "A ready-to-paste reply to a customer's message, with your prices and dates filled in from your own details. A person checks it and copies it; nothing is sent by itself."
144
159
  }
145
160
  ],
146
161
  "must_ask": true,
147
- "note": "Ask it even when the client's words seem to say, as a quick check: the two routes are built and paid for differently."
162
+ "note": "Ask it even when the client's words seem to say, as a quick check: the two routes are built and paid for differently. Replies are their own route: the facts (prices, dates) come from the app's own code, so ask where those facts come from before anything else."
148
163
  },
149
164
  {
150
165
  "id": "ai.who",
@@ -277,6 +292,196 @@
277
292
  ]
278
293
  },
279
294
  "note": "Name the piece after where it goes (about_us, home_blurb): lower case and underscores. The client's own words, with any facts they give (when they started, where they are), go in --about, because the drafts never invent facts and leave [gaps] for the owner instead."
295
+ },
296
+ {
297
+ "id": "ai.reply.purpose",
298
+ "sets": "reply.purpose",
299
+ "ask": "What are these replies answering?",
300
+ "style": "cards",
301
+ "answers": [
302
+ {
303
+ "label": "Quote and price requests",
304
+ "value": "quote",
305
+ "means": "\"How much for Dec 12, 15 pax?\": the reply gives the price and asks only for what's missing."
306
+ },
307
+ {
308
+ "label": "Availability questions",
309
+ "value": "availability",
310
+ "means": "\"Available pa po ba sa Sabado?\": the reply says yes or offers other dates."
311
+ },
312
+ {
313
+ "label": "Booking or appointment requests",
314
+ "value": "booking",
315
+ "means": "\"May slot po ba bukas?\": the reply lists the free times and asks which one."
316
+ }
317
+ ],
318
+ "other": {
319
+ "kind": "text",
320
+ "min": 3,
321
+ "max": 200,
322
+ "description": "What the messages are, in a few words."
323
+ },
324
+ "must_ask": true,
325
+ "when": {
326
+ "id": "ai.route",
327
+ "is": [
328
+ "reply"
329
+ ]
330
+ },
331
+ "note": "Name the guide after it (venue-quote, appointment-slots): lower case and dashes. Say that prices and dates always come from the app's own details (workbench-availability's quote() where it's a booking), never from the AI."
332
+ },
333
+ {
334
+ "id": "ai.reply.facts",
335
+ "sets": "reply.facts",
336
+ "ask": "Here are the details a reply can use. Keep, change or add?",
337
+ "style": "table",
338
+ "must_ask": true,
339
+ "when": {
340
+ "id": "ai.route",
341
+ "is": [
342
+ "reply"
343
+ ]
344
+ },
345
+ "note": "Propose one row per fact from the client's words and what the app already holds: its name, what it is, where the app gets it, and whether it is money (x-money with the currency) or a date. Only these can appear in a reply; a fact the app doesn't have can't be quoted. Then ask which details a reply may ask the customer for, most important first (ask_order); intake's missing fields are asked from that list only."
346
+ },
347
+ {
348
+ "id": "ai.reply.language",
349
+ "sets": "reply.language",
350
+ "ask": "Which language should replies use?",
351
+ "style": "cards",
352
+ "answers": [
353
+ {
354
+ "label": "Match the message",
355
+ "value": "match",
356
+ "means": "English gets English, Taglish gets Taglish, Tagalog gets Tagalog. When it can't tell, it uses your fallback and asks you to check."
357
+ },
358
+ {
359
+ "label": "Always Taglish",
360
+ "value": "taglish"
361
+ },
362
+ {
363
+ "label": "Always English",
364
+ "value": "en"
365
+ },
366
+ {
367
+ "label": "Always Tagalog",
368
+ "value": "tl"
369
+ }
370
+ ],
371
+ "when": {
372
+ "id": "ai.route",
373
+ "is": [
374
+ "reply"
375
+ ]
376
+ },
377
+ "note": "This is the default. Where the app has accounts (a venue each), make it each account's own setting in its portal, defaulting to this, and pass it to ai.reply as language; with match, pass the account's fallback as fallbackLanguage."
378
+ },
379
+ {
380
+ "id": "ai.reply.voice",
381
+ "sets": "reply.voice",
382
+ "ask": "Which tone?",
383
+ "style": "cards",
384
+ "answers": [
385
+ {
386
+ "label": "Conversational",
387
+ "value": "conversational",
388
+ "means": "Warm and natural, like a helpful person at the front desk."
389
+ },
390
+ {
391
+ "label": "Casual",
392
+ "value": "casual",
393
+ "means": "Relaxed and friendly, like chatting with a regular."
394
+ },
395
+ {
396
+ "label": "Plain",
397
+ "value": "plain",
398
+ "means": "Short and clear, nothing extra."
399
+ },
400
+ {
401
+ "label": "Formal",
402
+ "value": "formal",
403
+ "means": "Polite and proper, for a business that keeps a formal tone."
404
+ }
405
+ ],
406
+ "when": {
407
+ "id": "ai.route",
408
+ "is": [
409
+ "reply"
410
+ ]
411
+ }
412
+ },
413
+ {
414
+ "id": "ai.reply.length",
415
+ "sets": "reply.max_words",
416
+ "ask": "How long can a reply be, at most?",
417
+ "style": "cards",
418
+ "answers": [
419
+ {
420
+ "label": "About 90 words",
421
+ "value": 90,
422
+ "means": "Room for a price or two and one question."
423
+ },
424
+ {
425
+ "label": "About 50 words",
426
+ "value": 50,
427
+ "means": "A quick chat reply."
428
+ },
429
+ {
430
+ "label": "About 150 words",
431
+ "value": 150,
432
+ "means": "Room for several packages or options."
433
+ }
434
+ ],
435
+ "other": {
436
+ "kind": "whole",
437
+ "min": 20,
438
+ "max": 300,
439
+ "description": "Words, from 20 to 300."
440
+ },
441
+ "when": {
442
+ "id": "ai.route",
443
+ "is": [
444
+ "reply"
445
+ ]
446
+ },
447
+ "note": "A reply up to a quarter over passes quietly; one over twice as long is sent back to be written again, then flagged for a person to check."
448
+ },
449
+ {
450
+ "id": "ai.reply.never",
451
+ "sets": "reply.never",
452
+ "ask": "Is there anything a reply must never say?",
453
+ "style": "cards",
454
+ "answers": [
455
+ {
456
+ "label": "The usual three",
457
+ "value": "usual",
458
+ "means": "Never promise a discount, confirm a booking, or ask for payment details. Code checks these as well as telling the AI."
459
+ },
460
+ {
461
+ "label": "The usual three, and more",
462
+ "value": "usual_and_more",
463
+ "means": "Those three plus things of your own (type them in Other)."
464
+ },
465
+ {
466
+ "label": "Nothing",
467
+ "value": "none",
468
+ "means": "No rules beyond Workbench's own content rules."
469
+ }
470
+ ],
471
+ "other": {
472
+ "kind": "list",
473
+ "min": 1,
474
+ "max": 10,
475
+ "description": "Each thing a reply must never do, in a few words (give medical advice, mention the pool)."
476
+ },
477
+ "must_ask": true,
478
+ "when": {
479
+ "id": "ai.route",
480
+ "is": [
481
+ "reply"
482
+ ]
483
+ },
484
+ "note": "Write each as a never line in the guide. Lines about payment details, discounts and confirming bookings are also checked by code; others go into the prompt only, so say so if the client's line matters a lot."
280
485
  }
281
486
  ],
282
487
  "not_asked": [
@@ -1,4 +1,4 @@
1
- export declare const MANIFEST_VERSION: "9aae488ad117";
1
+ export declare const MANIFEST_VERSION: "4a52a6ba9520";
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", "checkWriting", "writingStops", "writingNotes", "writingFixNote"];
12
+ readonly package: readonly ["createAi", "Ai.text", "Ai.structured", "Ai.stream", "Ai.countTokens", "Ai.budget", "createFakeAi", "checkWriting", "writingStops", "writingNotes", "writingFixNote", "Ai.reply", "checkGuide"];
13
13
  };
14
14
  readonly config: readonly [{
15
15
  readonly name: "WORKBENCH_GATEWAY_KEY";
@@ -114,6 +114,12 @@ export declare const capabilities: {
114
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
115
  readonly enforced_by: "gateway";
116
116
  readonly probe: "ai.voice";
117
+ }, {
118
+ readonly id: "ai.reply-facts-from-code";
119
+ readonly kind: "must";
120
+ readonly text: "Draft replies with ai.reply from facts worked out by code (prices and dates from quote(), never from the AI), and show the draft in renderReplyBox for a person to check and copy. Never send a draft automatically, and never let a draft with needs_human_check go out unread.";
121
+ readonly enforced_by: "review";
122
+ readonly probe: "ai.reply";
117
123
  }];
118
124
  readonly examples: readonly [{
119
125
  readonly id: "ai.example.text";
@@ -127,6 +133,12 @@ export declare const capabilities: {
127
133
  readonly lang: "ts";
128
134
  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
135
  readonly probe: "ai.voice";
136
+ }, {
137
+ readonly id: "ai.example.reply";
138
+ readonly title: "Draft a reply to an enquiry";
139
+ readonly lang: "ts";
140
+ readonly code: "import { REPLY_GUIDES } from './replies.generated'\nconst ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env), url: env.WORKBENCH_GATEWAY_URL, replyGuides: REPLY_GUIDES })\nconst q = quote(settings, ask)\nconst draft = await ai.reply({\n guide: 'venue-quote',\n facts: { venue_name: venue.name, date: ask.date, available: q.available, packages: q.options.map((o) => ({ name: o.package, price: o.total.amount })), alternatives: q.alternatives.map((a) => a.date) },\n incoming: { text, intake: read },\n language: account.reply_language,\n})\nreturn html(renderReplyBox(draft, { againAction: `/enquiries/${id}/reply` }))";
141
+ readonly probe: "ai.reply";
130
142
  }];
131
143
  readonly probes: readonly [{
132
144
  readonly id: "ai.text.claude-default";
@@ -193,18 +205,23 @@ export declare const capabilities: {
193
205
  readonly half: "runtime";
194
206
  readonly expect: "pass";
195
207
  readonly cost_hint_usd: 0.008;
208
+ }, {
209
+ readonly id: "ai.reply";
210
+ readonly half: "runtime";
211
+ readonly expect: "pass";
212
+ readonly cost_hint_usd: 0.005;
196
213
  }];
197
214
  readonly offer: {
198
215
  readonly pattern: "feature_or_content";
199
- readonly when: "The client wants the app to write, summarise, sort, answer questions or pull details out of text with AI, as a feature visitors use, or wants wording for the site written once.";
216
+ readonly when: "The client wants the app to write, summarise, sort, answer questions or pull details out of text with AI, as a feature visitors use, or wants wording for the site written once, or wants ready-to-paste replies to the messages the business gets (quote requests, booking questions).";
200
217
  readonly say: "I'll do this with Workbench AI, which uses Claude through Workbench, so the app holds no AI key and spending has a monthly cap.";
201
218
  readonly answers_file: "workbench/ai/<name>.json";
202
219
  readonly per: "feature or piece of wording";
203
- readonly answers_format: "For a feature: `{ \"format\": 1, \"route\": \"feature\", \"name\", \"about\" (the client's words), \"who\", \"cap_usd\", \"keep\", \"job\" }`, where `job` is the `X-Workbench-Job` the feature's calls send (`<name>` with dots, such as `quotes.estimate`), so its spending shows on its own line in Control. Write it, then build the feature with the package. For wording made once: `npx workbench-ai pick <name> <round>-<draft>` writes it from the draft the client picked, with the round's request ids and cost; never write one from nothing. Then put the wording into the strings catalogue (the workbench-strings skill), keyed for where it is shown. Drafts and their previews stay in `workbench/ai/drafts/`, which isn't committed.";
220
+ readonly answers_format: "For a feature: `{ \"format\": 1, \"route\": \"feature\", \"name\", \"about\" (the client's words), \"who\", \"cap_usd\", \"keep\", \"job\" }`, where `job` is the `X-Workbench-Job` the feature's calls send (`<name>` with dots, such as `quotes.estimate`), so its spending shows on its own line in Control. Write it, then build the feature with the package. For wording made once: `npx workbench-ai pick <name> <round>-<draft>` writes it from the draft the client picked, with the round's request ids and cost; never write one from nothing. Then put the wording into the strings catalogue (the workbench-strings skill), keyed for where it is shown. Drafts and their previews stay in `workbench/ai/drafts/`, which isn't committed. For replies: write the guide itself to `workbench/replies/<name>.json` (format 1: `name`, `version`, `voice`, `spelling`, `max_words`, `max_questions`, `facts` as a small JSON Schema with `x-money` on amounts and `format: date` on dates, `ask_order`, `guide` lines, `examples`, `never`), then run `npx workbench-ai replies generate`, which checks it and writes `src/replies.generated.ts`; never edit that file. Offer samples with `npx workbench-ai replies drafts <name> --languages <the client's languages>` (two to four, with the development key; tell the client the cost it prints) and keep the one the client picks with `npx workbench-ai replies pick <name> <round>-<draft>`, which makes it the guide's first example.";
204
221
  readonly questions: readonly [{
205
222
  readonly id: "ai.route";
206
223
  readonly sets: "route";
207
- readonly ask: "Is this something visitors use on the site, or something made once for the site?";
224
+ readonly ask: "Is this something visitors use on the site, something made once for the site, or replies to the messages you get?";
208
225
  readonly style: "cards";
209
226
  readonly answers: readonly [{
210
227
  readonly label: "Visitors use it";
@@ -214,9 +231,13 @@ export declare const capabilities: {
214
231
  readonly label: "Made once for the site";
215
232
  readonly value: "content";
216
233
  readonly means: "Wording for a page, written once and kept: an About page, a welcome, product descriptions.";
234
+ }, {
235
+ readonly label: "Replies to messages";
236
+ readonly value: "reply";
237
+ readonly means: "A ready-to-paste reply to a customer's message, with your prices and dates filled in from your own details. A person checks it and copies it; nothing is sent by itself.";
217
238
  }];
218
239
  readonly must_ask: true;
219
- readonly note: "Ask it even when the client's words seem to say, as a quick check: the two routes are built and paid for differently.";
240
+ readonly note: "Ask it even when the client's words seem to say, as a quick check: the two routes are built and paid for differently. Replies are their own route: the facts (prices, dates) come from the app's own code, so ask where those facts come from before anything else.";
220
241
  }, {
221
242
  readonly id: "ai.who";
222
243
  readonly sets: "feature.who";
@@ -319,6 +340,156 @@ export declare const capabilities: {
319
340
  readonly is: readonly ["content"];
320
341
  };
321
342
  readonly note: "Name the piece after where it goes (about_us, home_blurb): lower case and underscores. The client's own words, with any facts they give (when they started, where they are), go in --about, because the drafts never invent facts and leave [gaps] for the owner instead.";
343
+ }, {
344
+ readonly id: "ai.reply.purpose";
345
+ readonly sets: "reply.purpose";
346
+ readonly ask: "What are these replies answering?";
347
+ readonly style: "cards";
348
+ readonly answers: readonly [{
349
+ readonly label: "Quote and price requests";
350
+ readonly value: "quote";
351
+ readonly means: "\"How much for Dec 12, 15 pax?\": the reply gives the price and asks only for what's missing.";
352
+ }, {
353
+ readonly label: "Availability questions";
354
+ readonly value: "availability";
355
+ readonly means: "\"Available pa po ba sa Sabado?\": the reply says yes or offers other dates.";
356
+ }, {
357
+ readonly label: "Booking or appointment requests";
358
+ readonly value: "booking";
359
+ readonly means: "\"May slot po ba bukas?\": the reply lists the free times and asks which one.";
360
+ }];
361
+ readonly other: {
362
+ readonly kind: "text";
363
+ readonly min: 3;
364
+ readonly max: 200;
365
+ readonly description: "What the messages are, in a few words.";
366
+ };
367
+ readonly must_ask: true;
368
+ readonly when: {
369
+ readonly id: "ai.route";
370
+ readonly is: readonly ["reply"];
371
+ };
372
+ readonly note: "Name the guide after it (venue-quote, appointment-slots): lower case and dashes. Say that prices and dates always come from the app's own details (workbench-availability's quote() where it's a booking), never from the AI.";
373
+ }, {
374
+ readonly id: "ai.reply.facts";
375
+ readonly sets: "reply.facts";
376
+ readonly ask: "Here are the details a reply can use. Keep, change or add?";
377
+ readonly style: "table";
378
+ readonly must_ask: true;
379
+ readonly when: {
380
+ readonly id: "ai.route";
381
+ readonly is: readonly ["reply"];
382
+ };
383
+ readonly note: "Propose one row per fact from the client's words and what the app already holds: its name, what it is, where the app gets it, and whether it is money (x-money with the currency) or a date. Only these can appear in a reply; a fact the app doesn't have can't be quoted. Then ask which details a reply may ask the customer for, most important first (ask_order); intake's missing fields are asked from that list only.";
384
+ }, {
385
+ readonly id: "ai.reply.language";
386
+ readonly sets: "reply.language";
387
+ readonly ask: "Which language should replies use?";
388
+ readonly style: "cards";
389
+ readonly answers: readonly [{
390
+ readonly label: "Match the message";
391
+ readonly value: "match";
392
+ readonly means: "English gets English, Taglish gets Taglish, Tagalog gets Tagalog. When it can't tell, it uses your fallback and asks you to check.";
393
+ }, {
394
+ readonly label: "Always Taglish";
395
+ readonly value: "taglish";
396
+ }, {
397
+ readonly label: "Always English";
398
+ readonly value: "en";
399
+ }, {
400
+ readonly label: "Always Tagalog";
401
+ readonly value: "tl";
402
+ }];
403
+ readonly when: {
404
+ readonly id: "ai.route";
405
+ readonly is: readonly ["reply"];
406
+ };
407
+ readonly note: "This is the default. Where the app has accounts (a venue each), make it each account's own setting in its portal, defaulting to this, and pass it to ai.reply as language; with match, pass the account's fallback as fallbackLanguage.";
408
+ }, {
409
+ readonly id: "ai.reply.voice";
410
+ readonly sets: "reply.voice";
411
+ readonly ask: "Which tone?";
412
+ readonly style: "cards";
413
+ readonly answers: readonly [{
414
+ readonly label: "Conversational";
415
+ readonly value: "conversational";
416
+ readonly means: "Warm and natural, like a helpful person at the front desk.";
417
+ }, {
418
+ readonly label: "Casual";
419
+ readonly value: "casual";
420
+ readonly means: "Relaxed and friendly, like chatting with a regular.";
421
+ }, {
422
+ readonly label: "Plain";
423
+ readonly value: "plain";
424
+ readonly means: "Short and clear, nothing extra.";
425
+ }, {
426
+ readonly label: "Formal";
427
+ readonly value: "formal";
428
+ readonly means: "Polite and proper, for a business that keeps a formal tone.";
429
+ }];
430
+ readonly when: {
431
+ readonly id: "ai.route";
432
+ readonly is: readonly ["reply"];
433
+ };
434
+ }, {
435
+ readonly id: "ai.reply.length";
436
+ readonly sets: "reply.max_words";
437
+ readonly ask: "How long can a reply be, at most?";
438
+ readonly style: "cards";
439
+ readonly answers: readonly [{
440
+ readonly label: "About 90 words";
441
+ readonly value: 90;
442
+ readonly means: "Room for a price or two and one question.";
443
+ }, {
444
+ readonly label: "About 50 words";
445
+ readonly value: 50;
446
+ readonly means: "A quick chat reply.";
447
+ }, {
448
+ readonly label: "About 150 words";
449
+ readonly value: 150;
450
+ readonly means: "Room for several packages or options.";
451
+ }];
452
+ readonly other: {
453
+ readonly kind: "whole";
454
+ readonly min: 20;
455
+ readonly max: 300;
456
+ readonly description: "Words, from 20 to 300.";
457
+ };
458
+ readonly when: {
459
+ readonly id: "ai.route";
460
+ readonly is: readonly ["reply"];
461
+ };
462
+ readonly note: "A reply up to a quarter over passes quietly; one over twice as long is sent back to be written again, then flagged for a person to check.";
463
+ }, {
464
+ readonly id: "ai.reply.never";
465
+ readonly sets: "reply.never";
466
+ readonly ask: "Is there anything a reply must never say?";
467
+ readonly style: "cards";
468
+ readonly answers: readonly [{
469
+ readonly label: "The usual three";
470
+ readonly value: "usual";
471
+ readonly means: "Never promise a discount, confirm a booking, or ask for payment details. Code checks these as well as telling the AI.";
472
+ }, {
473
+ readonly label: "The usual three, and more";
474
+ readonly value: "usual_and_more";
475
+ readonly means: "Those three plus things of your own (type them in Other).";
476
+ }, {
477
+ readonly label: "Nothing";
478
+ readonly value: "none";
479
+ readonly means: "No rules beyond Workbench's own content rules.";
480
+ }];
481
+ readonly other: {
482
+ readonly kind: "list";
483
+ readonly min: 1;
484
+ readonly max: 10;
485
+ readonly description: "Each thing a reply must never do, in a few words (give medical advice, mention the pool).";
486
+ };
487
+ readonly must_ask: true;
488
+ readonly when: {
489
+ readonly id: "ai.route";
490
+ readonly is: readonly ["reply"];
491
+ };
492
+ readonly note: "Write each as a never line in the guide. Lines about payment details, discounts and confirming bookings are also checked by code; others go into the prompt only, so say so if the client's line matters a lot.";
322
493
  }];
323
494
  readonly not_asked: readonly [{
324
495
  readonly sets: "model";
@@ -337,6 +508,9 @@ export declare const capabilities: {
337
508
  readonly command: "npx workbench-ai drafts text <name> --about \"<what it is for, with the client's facts>\"";
338
509
  };
339
510
  };
511
+ readonly styles: {
512
+ readonly prefix: "wb-reply-";
513
+ };
340
514
  readonly delivery: {
341
515
  readonly choice: "built_in";
342
516
  readonly intro: "Writing, summarising and answering questions with Claude, with a monthly spending cap.";
@@ -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 = "9aae488ad117";
3
+ export const MANIFEST_VERSION = "4a52a6ba9520";
4
4
  export const capabilities = {
5
5
  "ai": {
6
6
  "$schema": "./schema.json",
@@ -25,7 +25,9 @@ export const capabilities = {
25
25
  "checkWriting",
26
26
  "writingStops",
27
27
  "writingNotes",
28
- "writingFixNote"
28
+ "writingFixNote",
29
+ "Ai.reply",
30
+ "checkGuide"
29
31
  ]
30
32
  },
31
33
  "config": [
@@ -197,6 +199,13 @@ export const capabilities = {
197
199
  "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
200
  "enforced_by": "gateway",
199
201
  "probe": "ai.voice"
202
+ },
203
+ {
204
+ "id": "ai.reply-facts-from-code",
205
+ "kind": "must",
206
+ "text": "Draft replies with ai.reply from facts worked out by code (prices and dates from quote(), never from the AI), and show the draft in renderReplyBox for a person to check and copy. Never send a draft automatically, and never let a draft with needs_human_check go out unread.",
207
+ "enforced_by": "review",
208
+ "probe": "ai.reply"
200
209
  }
201
210
  ],
202
211
  "examples": [
@@ -213,6 +222,13 @@ export const capabilities = {
213
222
  "lang": "ts",
214
223
  "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
224
  "probe": "ai.voice"
225
+ },
226
+ {
227
+ "id": "ai.example.reply",
228
+ "title": "Draft a reply to an enquiry",
229
+ "lang": "ts",
230
+ "code": "import { REPLY_GUIDES } from './replies.generated'\nconst ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env), url: env.WORKBENCH_GATEWAY_URL, replyGuides: REPLY_GUIDES })\nconst q = quote(settings, ask)\nconst draft = await ai.reply({\n guide: 'venue-quote',\n facts: { venue_name: venue.name, date: ask.date, available: q.available, packages: q.options.map((o) => ({ name: o.package, price: o.total.amount })), alternatives: q.alternatives.map((a) => a.date) },\n incoming: { text, intake: read },\n language: account.reply_language,\n})\nreturn html(renderReplyBox(draft, { againAction: `/enquiries/${id}/reply` }))",
231
+ "probe": "ai.reply"
216
232
  }
217
233
  ],
218
234
  "probes": [
@@ -292,20 +308,26 @@ export const capabilities = {
292
308
  "half": "runtime",
293
309
  "expect": "pass",
294
310
  "cost_hint_usd": 0.008
311
+ },
312
+ {
313
+ "id": "ai.reply",
314
+ "half": "runtime",
315
+ "expect": "pass",
316
+ "cost_hint_usd": 0.005
295
317
  }
296
318
  ],
297
319
  "offer": {
298
320
  "pattern": "feature_or_content",
299
- "when": "The client wants the app to write, summarise, sort, answer questions or pull details out of text with AI, as a feature visitors use, or wants wording for the site written once.",
321
+ "when": "The client wants the app to write, summarise, sort, answer questions or pull details out of text with AI, as a feature visitors use, or wants wording for the site written once, or wants ready-to-paste replies to the messages the business gets (quote requests, booking questions).",
300
322
  "say": "I'll do this with Workbench AI, which uses Claude through Workbench, so the app holds no AI key and spending has a monthly cap.",
301
323
  "answers_file": "workbench/ai/<name>.json",
302
324
  "per": "feature or piece of wording",
303
- "answers_format": "For a feature: `{ \"format\": 1, \"route\": \"feature\", \"name\", \"about\" (the client's words), \"who\", \"cap_usd\", \"keep\", \"job\" }`, where `job` is the `X-Workbench-Job` the feature's calls send (`<name>` with dots, such as `quotes.estimate`), so its spending shows on its own line in Control. Write it, then build the feature with the package. For wording made once: `npx workbench-ai pick <name> <round>-<draft>` writes it from the draft the client picked, with the round's request ids and cost; never write one from nothing. Then put the wording into the strings catalogue (the workbench-strings skill), keyed for where it is shown. Drafts and their previews stay in `workbench/ai/drafts/`, which isn't committed.",
325
+ "answers_format": "For a feature: `{ \"format\": 1, \"route\": \"feature\", \"name\", \"about\" (the client's words), \"who\", \"cap_usd\", \"keep\", \"job\" }`, where `job` is the `X-Workbench-Job` the feature's calls send (`<name>` with dots, such as `quotes.estimate`), so its spending shows on its own line in Control. Write it, then build the feature with the package. For wording made once: `npx workbench-ai pick <name> <round>-<draft>` writes it from the draft the client picked, with the round's request ids and cost; never write one from nothing. Then put the wording into the strings catalogue (the workbench-strings skill), keyed for where it is shown. Drafts and their previews stay in `workbench/ai/drafts/`, which isn't committed. For replies: write the guide itself to `workbench/replies/<name>.json` (format 1: `name`, `version`, `voice`, `spelling`, `max_words`, `max_questions`, `facts` as a small JSON Schema with `x-money` on amounts and `format: date` on dates, `ask_order`, `guide` lines, `examples`, `never`), then run `npx workbench-ai replies generate`, which checks it and writes `src/replies.generated.ts`; never edit that file. Offer samples with `npx workbench-ai replies drafts <name> --languages <the client's languages>` (two to four, with the development key; tell the client the cost it prints) and keep the one the client picks with `npx workbench-ai replies pick <name> <round>-<draft>`, which makes it the guide's first example.",
304
326
  "questions": [
305
327
  {
306
328
  "id": "ai.route",
307
329
  "sets": "route",
308
- "ask": "Is this something visitors use on the site, or something made once for the site?",
330
+ "ask": "Is this something visitors use on the site, something made once for the site, or replies to the messages you get?",
309
331
  "style": "cards",
310
332
  "answers": [
311
333
  {
@@ -317,10 +339,15 @@ export const capabilities = {
317
339
  "label": "Made once for the site",
318
340
  "value": "content",
319
341
  "means": "Wording for a page, written once and kept: an About page, a welcome, product descriptions."
342
+ },
343
+ {
344
+ "label": "Replies to messages",
345
+ "value": "reply",
346
+ "means": "A ready-to-paste reply to a customer's message, with your prices and dates filled in from your own details. A person checks it and copies it; nothing is sent by itself."
320
347
  }
321
348
  ],
322
349
  "must_ask": true,
323
- "note": "Ask it even when the client's words seem to say, as a quick check: the two routes are built and paid for differently."
350
+ "note": "Ask it even when the client's words seem to say, as a quick check: the two routes are built and paid for differently. Replies are their own route: the facts (prices, dates) come from the app's own code, so ask where those facts come from before anything else."
324
351
  },
325
352
  {
326
353
  "id": "ai.who",
@@ -453,6 +480,196 @@ export const capabilities = {
453
480
  ]
454
481
  },
455
482
  "note": "Name the piece after where it goes (about_us, home_blurb): lower case and underscores. The client's own words, with any facts they give (when they started, where they are), go in --about, because the drafts never invent facts and leave [gaps] for the owner instead."
483
+ },
484
+ {
485
+ "id": "ai.reply.purpose",
486
+ "sets": "reply.purpose",
487
+ "ask": "What are these replies answering?",
488
+ "style": "cards",
489
+ "answers": [
490
+ {
491
+ "label": "Quote and price requests",
492
+ "value": "quote",
493
+ "means": "\"How much for Dec 12, 15 pax?\": the reply gives the price and asks only for what's missing."
494
+ },
495
+ {
496
+ "label": "Availability questions",
497
+ "value": "availability",
498
+ "means": "\"Available pa po ba sa Sabado?\": the reply says yes or offers other dates."
499
+ },
500
+ {
501
+ "label": "Booking or appointment requests",
502
+ "value": "booking",
503
+ "means": "\"May slot po ba bukas?\": the reply lists the free times and asks which one."
504
+ }
505
+ ],
506
+ "other": {
507
+ "kind": "text",
508
+ "min": 3,
509
+ "max": 200,
510
+ "description": "What the messages are, in a few words."
511
+ },
512
+ "must_ask": true,
513
+ "when": {
514
+ "id": "ai.route",
515
+ "is": [
516
+ "reply"
517
+ ]
518
+ },
519
+ "note": "Name the guide after it (venue-quote, appointment-slots): lower case and dashes. Say that prices and dates always come from the app's own details (workbench-availability's quote() where it's a booking), never from the AI."
520
+ },
521
+ {
522
+ "id": "ai.reply.facts",
523
+ "sets": "reply.facts",
524
+ "ask": "Here are the details a reply can use. Keep, change or add?",
525
+ "style": "table",
526
+ "must_ask": true,
527
+ "when": {
528
+ "id": "ai.route",
529
+ "is": [
530
+ "reply"
531
+ ]
532
+ },
533
+ "note": "Propose one row per fact from the client's words and what the app already holds: its name, what it is, where the app gets it, and whether it is money (x-money with the currency) or a date. Only these can appear in a reply; a fact the app doesn't have can't be quoted. Then ask which details a reply may ask the customer for, most important first (ask_order); intake's missing fields are asked from that list only."
534
+ },
535
+ {
536
+ "id": "ai.reply.language",
537
+ "sets": "reply.language",
538
+ "ask": "Which language should replies use?",
539
+ "style": "cards",
540
+ "answers": [
541
+ {
542
+ "label": "Match the message",
543
+ "value": "match",
544
+ "means": "English gets English, Taglish gets Taglish, Tagalog gets Tagalog. When it can't tell, it uses your fallback and asks you to check."
545
+ },
546
+ {
547
+ "label": "Always Taglish",
548
+ "value": "taglish"
549
+ },
550
+ {
551
+ "label": "Always English",
552
+ "value": "en"
553
+ },
554
+ {
555
+ "label": "Always Tagalog",
556
+ "value": "tl"
557
+ }
558
+ ],
559
+ "when": {
560
+ "id": "ai.route",
561
+ "is": [
562
+ "reply"
563
+ ]
564
+ },
565
+ "note": "This is the default. Where the app has accounts (a venue each), make it each account's own setting in its portal, defaulting to this, and pass it to ai.reply as language; with match, pass the account's fallback as fallbackLanguage."
566
+ },
567
+ {
568
+ "id": "ai.reply.voice",
569
+ "sets": "reply.voice",
570
+ "ask": "Which tone?",
571
+ "style": "cards",
572
+ "answers": [
573
+ {
574
+ "label": "Conversational",
575
+ "value": "conversational",
576
+ "means": "Warm and natural, like a helpful person at the front desk."
577
+ },
578
+ {
579
+ "label": "Casual",
580
+ "value": "casual",
581
+ "means": "Relaxed and friendly, like chatting with a regular."
582
+ },
583
+ {
584
+ "label": "Plain",
585
+ "value": "plain",
586
+ "means": "Short and clear, nothing extra."
587
+ },
588
+ {
589
+ "label": "Formal",
590
+ "value": "formal",
591
+ "means": "Polite and proper, for a business that keeps a formal tone."
592
+ }
593
+ ],
594
+ "when": {
595
+ "id": "ai.route",
596
+ "is": [
597
+ "reply"
598
+ ]
599
+ }
600
+ },
601
+ {
602
+ "id": "ai.reply.length",
603
+ "sets": "reply.max_words",
604
+ "ask": "How long can a reply be, at most?",
605
+ "style": "cards",
606
+ "answers": [
607
+ {
608
+ "label": "About 90 words",
609
+ "value": 90,
610
+ "means": "Room for a price or two and one question."
611
+ },
612
+ {
613
+ "label": "About 50 words",
614
+ "value": 50,
615
+ "means": "A quick chat reply."
616
+ },
617
+ {
618
+ "label": "About 150 words",
619
+ "value": 150,
620
+ "means": "Room for several packages or options."
621
+ }
622
+ ],
623
+ "other": {
624
+ "kind": "whole",
625
+ "min": 20,
626
+ "max": 300,
627
+ "description": "Words, from 20 to 300."
628
+ },
629
+ "when": {
630
+ "id": "ai.route",
631
+ "is": [
632
+ "reply"
633
+ ]
634
+ },
635
+ "note": "A reply up to a quarter over passes quietly; one over twice as long is sent back to be written again, then flagged for a person to check."
636
+ },
637
+ {
638
+ "id": "ai.reply.never",
639
+ "sets": "reply.never",
640
+ "ask": "Is there anything a reply must never say?",
641
+ "style": "cards",
642
+ "answers": [
643
+ {
644
+ "label": "The usual three",
645
+ "value": "usual",
646
+ "means": "Never promise a discount, confirm a booking, or ask for payment details. Code checks these as well as telling the AI."
647
+ },
648
+ {
649
+ "label": "The usual three, and more",
650
+ "value": "usual_and_more",
651
+ "means": "Those three plus things of your own (type them in Other)."
652
+ },
653
+ {
654
+ "label": "Nothing",
655
+ "value": "none",
656
+ "means": "No rules beyond Workbench's own content rules."
657
+ }
658
+ ],
659
+ "other": {
660
+ "kind": "list",
661
+ "min": 1,
662
+ "max": 10,
663
+ "description": "Each thing a reply must never do, in a few words (give medical advice, mention the pool)."
664
+ },
665
+ "must_ask": true,
666
+ "when": {
667
+ "id": "ai.route",
668
+ "is": [
669
+ "reply"
670
+ ]
671
+ },
672
+ "note": "Write each as a never line in the guide. Lines about payment details, discounts and confirming bookings are also checked by code; others go into the prompt only, so say so if the client's line matters a lot."
456
673
  }
457
674
  ],
458
675
  "not_asked": [
@@ -475,6 +692,9 @@ export const capabilities = {
475
692
  "command": "npx workbench-ai drafts text <name> --about \"<what it is for, with the client's facts>\""
476
693
  }
477
694
  },
695
+ "styles": {
696
+ "prefix": "wb-reply-"
697
+ },
478
698
  "delivery": {
479
699
  "choice": "built_in",
480
700
  "intro": "Writing, summarising and answering questions with Claude, with a monthly spending cap."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mccaskie-labs/workbench-config",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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",