@mccaskie-labs/workbench-ai 0.3.0 → 0.4.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.
@@ -0,0 +1,482 @@
1
+ // The reply drafter (docs/capabilities/04-reply-drafter.md): structured facts
2
+ // plus a reply guide in, a ready-to-paste reply out. The one firm rule: facts
3
+ // come from data, words from the AI. The model places each fact as a token
4
+ // ({{packages.0.price}}); this file fills the tokens with workbench-country's
5
+ // formatter and then checks the finished text, so a price or date the model
6
+ // typed itself fails the draft. All of that is plain code, so it runs the same
7
+ // under createFakeAi.
8
+ import { COUNTRY_PACKS, formatDate, formatMoney, formatNumber } from "@mccaskie-labs/workbench-country";
9
+ import { z } from "zod";
10
+ import { WorkbenchAiError } from "./errors.js";
11
+ import { cachedBlocks } from "./helpers.js";
12
+ import { checkWriting, SPELLING_NAMES, VOICE_NAMES, writingStops } from "./writing.js";
13
+ /** The version of the drafter's own instructions, sent as the prompt version with the guide's. */
14
+ export const REPLY_PROMPT_VERSION = "2026-11-13";
15
+ export const REPLY_LANGUAGES = ["en", "tl", "taglish"];
16
+ // ---------------------------------------------------------------- the guide
17
+ const GUIDE_NAME = /^[a-z][a-z0-9-]{0,39}$/;
18
+ const FACT_NAME = /^[a-z][a-z0-9_]{0,39}$/;
19
+ const CURRENCY = /^[A-Z]{3}$/;
20
+ const DEFAULT_MAX_WORDS = 90;
21
+ const DEFAULT_MAX_QUESTIONS = 2;
22
+ /** Checks a guide's JSON and returns it typed, or throws an Error naming every problem. */
23
+ export function checkGuide(value, where = "guide") {
24
+ const problems = [];
25
+ const g = value;
26
+ if (!g || typeof g !== "object" || Array.isArray(g))
27
+ throw new Error(`${where}: not a JSON object`);
28
+ const known = new Set(["$schema", "format", "name", "version", "voice", "spelling", "country", "model", "max_words", "max_questions", "facts", "ask_order", "guide", "examples", "never"]);
29
+ for (const k of Object.keys(g))
30
+ if (!known.has(k))
31
+ problems.push(`${k} isn't a guide setting`);
32
+ if (g.format !== 1)
33
+ problems.push("format must be 1");
34
+ if (typeof g.name !== "string" || !GUIDE_NAME.test(g.name))
35
+ problems.push("name must be lower case letters, digits and dashes, starting with a letter");
36
+ if (!Number.isInteger(g.version) || g.version < 1)
37
+ problems.push("version must be a whole number from 1");
38
+ if (g.voice !== undefined && !VOICE_NAMES.includes(g.voice))
39
+ problems.push(`voice must be one of ${VOICE_NAMES.join(", ")}`);
40
+ if (g.spelling !== undefined && !SPELLING_NAMES.includes(g.spelling))
41
+ problems.push(`spelling must be one of ${SPELLING_NAMES.join(", ")}`);
42
+ if (g.country !== undefined && !(typeof g.country === "string" && COUNTRY_PACKS[g.country]))
43
+ problems.push(`country must be one of ${Object.keys(COUNTRY_PACKS).join(", ")}`);
44
+ if (g.model !== undefined && (typeof g.model !== "string" || !/^[a-z][a-z0-9.-]*$/.test(g.model)))
45
+ problems.push("model must be a Workbench alias such as claude-default");
46
+ if (g.max_words !== undefined && (!Number.isInteger(g.max_words) || g.max_words < 10 || g.max_words > 400))
47
+ problems.push("max_words must be a whole number from 10 to 400");
48
+ if (g.max_questions !== undefined && (!Number.isInteger(g.max_questions) || g.max_questions < 0 || g.max_questions > 5))
49
+ problems.push("max_questions must be a whole number from 0 to 5");
50
+ const f = g.facts;
51
+ if (!f || typeof f !== "object" || f.type !== "object" || !f.properties || typeof f.properties !== "object") {
52
+ problems.push('facts must be { "type": "object", "properties": { ... } }');
53
+ }
54
+ else {
55
+ for (const [name, s] of Object.entries(f.properties)) {
56
+ if (!FACT_NAME.test(name))
57
+ problems.push(`facts.${name}: a fact's name is lower case letters, digits and underscores`);
58
+ if (name === "link")
59
+ problems.push("facts.link: link is kept for the short link; give the fact another name");
60
+ checkFact(s, `facts.${name}`, 0, problems);
61
+ }
62
+ for (const r of f.required ?? [])
63
+ if (!Object.hasOwn(f.properties, r))
64
+ problems.push(`facts.required names ${r}, which isn't a fact`);
65
+ }
66
+ if (g.ask_order !== undefined && !(Array.isArray(g.ask_order) && g.ask_order.every((x) => typeof x === "string" && x !== "")))
67
+ problems.push("ask_order must be a list of field names");
68
+ if (!Array.isArray(g.guide) || g.guide.length === 0 || !g.guide.every((x) => typeof x === "string" && x.trim() !== ""))
69
+ problems.push("guide must be a list of plain sentences");
70
+ if (g.never !== undefined && !(Array.isArray(g.never) && g.never.every((x) => typeof x === "string" && x.trim() !== "")))
71
+ problems.push("never must be a list of plain sentences");
72
+ if (g.examples !== undefined) {
73
+ if (!Array.isArray(g.examples))
74
+ problems.push("examples must be a list");
75
+ else
76
+ g.examples.forEach((e, i) => {
77
+ if (!e || typeof e !== "object" || !REPLY_LANGUAGES.includes(e.language) || typeof e.text !== "string" || e.text.trim() === "") {
78
+ problems.push(`examples[${i}] needs language (${REPLY_LANGUAGES.join(", ")}) and text`);
79
+ }
80
+ });
81
+ }
82
+ if (problems.length)
83
+ throw new Error(`${where}: ${problems.join("; ")}`);
84
+ return g;
85
+ }
86
+ function checkFact(s, at, depth, problems) {
87
+ if (!s || typeof s !== "object") {
88
+ problems.push(`${at}: not a schema`);
89
+ return;
90
+ }
91
+ const types = ["string", "number", "integer", "boolean", "array", "object"];
92
+ if (!types.includes(s.type))
93
+ problems.push(`${at}: type must be one of ${types.join(", ")}`);
94
+ if (s["x-money"] !== undefined && (!CURRENCY.test(s["x-money"]) || (s.type !== "number" && s.type !== "integer")))
95
+ problems.push(`${at}: x-money is a currency code (PHP) on a number`);
96
+ if (s.format !== undefined && (s.format !== "date" || s.type !== "string"))
97
+ problems.push(`${at}: format may only be date, on a string`);
98
+ if (s.enum !== undefined && (s.type !== "string" || !Array.isArray(s.enum) || s.enum.length === 0))
99
+ problems.push(`${at}: enum is a list of strings, on a string`);
100
+ if (s.type === "array") {
101
+ if (depth > 0)
102
+ problems.push(`${at}: lists can't hold lists`);
103
+ if (!s.items)
104
+ problems.push(`${at}: a list needs items`);
105
+ else
106
+ checkFact(s.items, `${at}.items`, depth + 1, problems);
107
+ }
108
+ if (s.type === "object") {
109
+ if (depth === 0)
110
+ problems.push(`${at}: a fact can be an object only inside a list (packages: a list of objects)`);
111
+ if (!s.properties)
112
+ problems.push(`${at}: an object needs properties`);
113
+ else
114
+ for (const [name, p] of Object.entries(s.properties)) {
115
+ if (!FACT_NAME.test(name))
116
+ problems.push(`${at}.${name}: lower case letters, digits and underscores`);
117
+ if (p.type === "object" || p.type === "array")
118
+ problems.push(`${at}.${name}: items in a list are flat`);
119
+ else
120
+ checkFact(p, `${at}.${name}`, depth + 1, problems);
121
+ }
122
+ }
123
+ }
124
+ /** The pack that writes the guide's amounts and dates. */
125
+ function packFor(guide) {
126
+ const fromSpelling = guide.spelling?.split("-")[1];
127
+ const fromMoney = moneyCodes(guide.facts).map((code) => Object.values(COUNTRY_PACKS).find((p) => p.currency.code === code)?.country)[0];
128
+ const country = guide.country ?? fromSpelling ?? fromMoney ?? "AU";
129
+ return COUNTRY_PACKS[country] ?? COUNTRY_PACKS.AU;
130
+ }
131
+ function moneyCodes(s) {
132
+ const out = [];
133
+ if (s["x-money"])
134
+ out.push(s["x-money"]);
135
+ if (s.items)
136
+ out.push(...moneyCodes(s.items));
137
+ for (const p of Object.values(s.properties ?? {}))
138
+ out.push(...moneyCodes(p));
139
+ return out;
140
+ }
141
+ const isEmpty = (v) => v === undefined || v === null || (typeof v === "string" && v.trim() === "") || (Array.isArray(v) && v.length === 0);
142
+ function render(value, s, pack) {
143
+ if (isEmpty(value) || typeof value === "boolean")
144
+ return undefined;
145
+ // Money may come as workbench-availability's exact decimal string ("8500.00").
146
+ if (s["x-money"] && typeof value === "string" && /^-?\d+(\.\d+)?$/.test(value.trim()))
147
+ value = Number(value);
148
+ if (typeof value === "number" && (s.type === "number" || s.type === "integer")) {
149
+ const code = s["x-money"];
150
+ if (code) {
151
+ const own = pack.currency.code === code ? pack : (Object.values(COUNTRY_PACKS).find((p) => p.currency.code === code) ?? `${code} `);
152
+ return { value: formatMoney(value, own), also: [formatNumber(value, pack)] };
153
+ }
154
+ return { value: formatNumber(value, pack), also: [String(value)] };
155
+ }
156
+ if (typeof value === "string") {
157
+ if (s.format === "date")
158
+ return { value: formatDate(value, { pack }, "weekdayDayMonth"), also: [] };
159
+ return { value, also: [] };
160
+ }
161
+ return undefined;
162
+ }
163
+ /** Every token the facts give, with its finished words. Booleans and empty facts give none. */
164
+ export function tokensFor(guide, facts) {
165
+ const pack = packFor(guide);
166
+ const out = new Map();
167
+ const add = (name, fact, r) => {
168
+ if (r)
169
+ out.set(name, { name, fact, value: r.value, also: r.also });
170
+ };
171
+ for (const [fact, s] of Object.entries(guide.facts.properties)) {
172
+ const value = facts[fact];
173
+ if (s.type === "array" && Array.isArray(value) && s.items) {
174
+ const items = s.items;
175
+ if (items.type === "object") {
176
+ value.forEach((item, i) => {
177
+ if (!item || typeof item !== "object")
178
+ return;
179
+ for (const [field, fs] of Object.entries(items.properties ?? {}))
180
+ add(`${fact}.${i}.${field}`, fact, render(item[field], fs, pack));
181
+ });
182
+ }
183
+ else {
184
+ const parts = [];
185
+ value.forEach((item, i) => {
186
+ const r = render(item, items, pack);
187
+ add(`${fact}.${i}`, fact, r);
188
+ if (r)
189
+ parts.push(r.value);
190
+ });
191
+ if (parts.length)
192
+ add(fact, fact, { value: new Intl.ListFormat("en", { type: "conjunction" }).format(parts), also: [] });
193
+ }
194
+ }
195
+ else {
196
+ add(fact, fact, render(value, s, pack));
197
+ }
198
+ }
199
+ return out;
200
+ }
201
+ // ---------------------------------------------------------------- questions and language
202
+ /** The fields the draft asks for: intake's missing and ambiguous fields in the guide's ask_order, none the facts hold, at most max_questions. */
203
+ export function questionsFor(guide, facts, intake) {
204
+ if (!intake)
205
+ return [];
206
+ const order = guide.ask_order ?? [];
207
+ const wanted = new Set([...intake.missing, ...intake.ambiguous.map((a) => a.field)]);
208
+ return order.filter((f) => wanted.has(f) && isEmpty(facts[f])).slice(0, guide.max_questions ?? DEFAULT_MAX_QUESTIONS);
209
+ }
210
+ // Small, common words only: enough to tell English, Tagalog and the two mixed.
211
+ const TL_WORDS = new Set("po opo ang ng mga sa na ba pa ko mo kayo namin natin nyo niyo ninyo kami tayo siya sila ito iyan yan yun iyon dito diyan doon ay at kung para hanggang ilan magkano salamat pwede puwede lang din rin naman talaga bukas ngayon mamaya araw gabi umaga hapon wala meron mayroon may gusto sana kasi tapos pero o hindi oo sige nga ho anong ano saan kailan bakit paano sino ilang tao katao bakante pasensya paki pakisabi lahat kasama kami pong nga".split(" "));
212
+ const EN_WORDS = new Set("the a an is are was were be and or but for to of in on at with you your we our us i my me it this that these those what when where how much many can could would will do does have has please thanks thank available price rate rates per day night book booking reserve guests people how's there here from".split(" "));
213
+ /** A rough reading of a short message's language, for `match` when intake gave none, and for the tests' checker. */
214
+ export function guessLanguage(text) {
215
+ const words = text.toLowerCase().match(/[a-zñ']+/g) ?? [];
216
+ let tl = 0;
217
+ let en = 0;
218
+ for (const w of words) {
219
+ if (TL_WORDS.has(w))
220
+ tl++;
221
+ else if (EN_WORDS.has(w))
222
+ en++;
223
+ }
224
+ if (tl + en < 2)
225
+ return undefined;
226
+ const share = tl / (tl + en);
227
+ if (share >= 0.85)
228
+ return "tl";
229
+ if (share <= 0.15)
230
+ return "en";
231
+ return "taglish";
232
+ }
233
+ /** The reply's language and whether it had to fall back. */
234
+ export function languageFor(req) {
235
+ const choice = req.language ?? "match";
236
+ if (choice !== "match")
237
+ return { language: choice, unsure: false };
238
+ const seen = req.incoming?.intake?.languages;
239
+ if (seen && seen.length) {
240
+ const tl = seen.includes("tl") || seen.includes("fil");
241
+ const en = seen.includes("en");
242
+ if (tl && en)
243
+ return { language: "taglish", unsure: false };
244
+ if (tl)
245
+ return { language: "tl", unsure: false };
246
+ if (en)
247
+ return { language: "en", unsure: false };
248
+ }
249
+ const guessed = req.incoming ? guessLanguage(req.incoming.text) : undefined;
250
+ if (guessed)
251
+ return { language: guessed, unsure: false };
252
+ return { language: req.fallbackLanguage ?? "en", unsure: req.incoming !== undefined };
253
+ }
254
+ /**
255
+ * How each language is written. Until the voices' language setting and the
256
+ * Filipino pack are built (docs/spec.md §2.8), these lines go in the guide's
257
+ * prompt; they move there when they are.
258
+ */
259
+ export const LANGUAGE_LINES = {
260
+ en: "Write in plain English.",
261
+ tl: "Write in Tagalog (Filipino), as a Filipino small business writes to a customer on Messenger: polite, with po and opo, everyday words, and English only for terms Filipinos normally say in English (day use, overnight, package, reserve).",
262
+ taglish: "Write in Taglish: Tagalog and English mixed the way Filipino customers and small businesses chat on Messenger, polite, with po and opo. Use English for terms Filipinos normally say in English (day use, overnight, package, reserve).",
263
+ };
264
+ const TOKEN = /\{\{\s*([^{}]*?)\s*\}\}/g;
265
+ const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>"')]+/gi;
266
+ /** Where a fact couldn't be filled: plain for the person checking to see and fill in. */
267
+ export const GAP = "___";
268
+ /**
269
+ * Patterns for `never` lines code can see. A line matches a row when it
270
+ * mentions the row's topic; the text then fails when it matches the row's
271
+ * pattern. Other lines are in the prompt only.
272
+ */
273
+ const NEVER_CHECKS = [
274
+ {
275
+ topic: /payment|card|bank|gcash|maya|account number/i,
276
+ text: /\b(card (number|details)|credit card|debit card|cvv|cvc|expiry date|bank (account|details)|account (number|no\.?)|gcash (number|no\.?)|maya (number|no\.?)|payment details|otp|one-time pin)\b/i,
277
+ },
278
+ { topic: /discount/i, text: /\b(discount|diskwento|less \d|% off|promo price)\b/i },
279
+ { topic: /confirm.*book|book.*confirm/i, text: /\b(booking is confirmed|you'?re (all )?booked|confirmed na|naka-?book na|reserved na kayo|we('ve| have) booked)\b/i },
280
+ ];
281
+ /** The words "available" in the languages a reply is written in. */
282
+ const AVAILABLE_WORDS = /\b(available|avail|bakante|open pa)\b/i;
283
+ const countWords = (text) => (text.replace(URL_RE, " ").match(/[\p{L}\p{N}₱$][\p{L}\p{N}'’.,₱$-]*/gu) ?? []).length;
284
+ /** Fills the tokens and checks the finished text. Plain code: no AI. */
285
+ export function checkDraft(raw, ctx) {
286
+ const problems = [];
287
+ const used = new Set();
288
+ let linked = false;
289
+ let text = raw.replace(TOKEN, (_m, name) => {
290
+ if (name === "link") {
291
+ if (!ctx.link || linked)
292
+ return "";
293
+ linked = true;
294
+ return ctx.link.url;
295
+ }
296
+ const t = ctx.tokens.get(name);
297
+ if (!t) {
298
+ problems.push({ reason: "missing_fact", detail: `{{${name}}} isn't one of the facts` });
299
+ return GAP;
300
+ }
301
+ used.add(t.fact);
302
+ return t.value;
303
+ });
304
+ // A stray brace is a token the model broke: never show it raw.
305
+ if (/\{\{|\}\}/.test(text)) {
306
+ problems.push({ reason: "missing_fact", detail: "a broken token" });
307
+ text = text.replace(/\{\{|\}\}/g, GAP);
308
+ }
309
+ if (ctx.link && !linked)
310
+ text = `${text.trimEnd()} ${ctx.link.url}`;
311
+ text = tidy(text);
312
+ // Every address must be the link; every number, amount and date one the facts produced.
313
+ for (const url of text.match(URL_RE) ?? []) {
314
+ if (!ctx.link || url.replace(/[.,!?]+$/, "") !== ctx.link.url)
315
+ problems.push({ reason: "unknown_fact", detail: `the address ${url} isn't the link` });
316
+ }
317
+ let rest = text.replace(URL_RE, " ");
318
+ const allowed = [...ctx.tokens.values()].flatMap((t) => [t.value, ...t.also]).filter((s) => s !== "");
319
+ for (const value of allowed.sort((a, b) => b.length - a.length))
320
+ rest = rest.split(value).join(" ");
321
+ for (const m of rest.matchAll(/(?:[₱$€£]\s?)?\d[\d,.:/-]*(?:\s?(?:k|K|am|pm|AM|PM))?/g)) {
322
+ problems.push({ reason: "unknown_fact", detail: `"${m[0].trim()}" isn't one of the facts` });
323
+ }
324
+ const max = ctx.guide.max_words ?? DEFAULT_MAX_WORDS;
325
+ const words = countWords(text);
326
+ if (words > max * 2)
327
+ problems.push({ reason: "too_long", detail: `${words} words, more than twice the ${max} allowed` });
328
+ for (const line of ctx.guide.never ?? []) {
329
+ const check = NEVER_CHECKS.find((c) => c.topic.test(line));
330
+ const hit = check ? check.text.exec(text) : null;
331
+ if (hit)
332
+ problems.push({ reason: "never_rule", detail: `"${hit[0]}" breaks "never ${line}"` });
333
+ }
334
+ if (ctx.facts["available"] === "no") {
335
+ const hit = AVAILABLE_WORDS.exec(text);
336
+ if (hit)
337
+ problems.push({ reason: "contradicts_fact", detail: `"${hit[0]}", when the date isn't available` });
338
+ for (const [name, t] of ctx.tokens) {
339
+ if (/^alternatives\.\d+$/.test(name) && !text.includes(t.value))
340
+ problems.push({ reason: "missing_fact", detail: `the alternative ${t.value} is left out` });
341
+ }
342
+ }
343
+ for (const s of writingStops(checkWriting(text, { voice: ctx.guide.voice ?? "conversational", paragraphTarget: max * 2 }))) {
344
+ problems.push({ reason: "writing", detail: s.issue });
345
+ }
346
+ return { text, problems, answered: [...used] };
347
+ }
348
+ function tidy(text) {
349
+ return text
350
+ .replace(/[ \t]+([,.!?])/g, "$1")
351
+ .replace(/[ \t]{2,}/g, " ")
352
+ .replace(/[ \t]+\n/g, "\n")
353
+ .trim();
354
+ }
355
+ // ---------------------------------------------------------------- the prompt
356
+ const SYSTEM = [
357
+ "You draft a reply that a business will paste into a chat with a customer. A person reads it and copies it; nothing is sent by you.",
358
+ "",
359
+ "Facts. Every price, amount, date, number, package name and link in the reply comes from the facts you are given, never from you. Put each one in as its token, written exactly as listed, such as {{packages.0.price}}. Never type a digit, an amount or a date yourself, and never work one out: no totals, no discounts, no counting of days or people. If a fact you would want isn't listed, leave it out rather than guess.",
360
+ "",
361
+ "Questions. Ask only for the fields listed under \"Ask for\", together in one short line, in that order, in plain words (never a field's code name). When the list says nothing, ask for none of the customer's details; you may still end with the guide's invitation.",
362
+ "",
363
+ "Link. When the request says there is a link, put {{link}} once where it fits. Otherwise mention no link.",
364
+ "",
365
+ "Write only the reply itself, as it will be pasted: no notes, no options, no quotation marks around it, no sign that it was drafted for someone.",
366
+ ].join("\n");
367
+ function guideBlock(g) {
368
+ const lines = [`Reply guide "${g.name}", version ${g.version}.`, "", "How to answer:", ...g.guide.map((l) => `- ${l}`)];
369
+ if (g.never?.length)
370
+ lines.push("", "Never:", ...g.never.map((l) => `- ${l}`));
371
+ lines.push("", `Keep it to about ${g.max_words ?? DEFAULT_MAX_WORDS} words.`);
372
+ if (g.examples?.length) {
373
+ lines.push("", "Examples of the wording (tokens stand for facts):");
374
+ for (const e of g.examples)
375
+ lines.push(`- (${e.language}) ${e.text}`);
376
+ }
377
+ return lines.join("\n");
378
+ }
379
+ function label(field) {
380
+ return field.replace(/_/g, " ");
381
+ }
382
+ function userPrompt(o) {
383
+ const out = [`Language: ${LANGUAGE_LINES[o.language]}`];
384
+ if (o.incoming) {
385
+ out.push("", "The message being answered:", "<<<", o.incoming.text, ">>>");
386
+ const intake = o.incoming.intake;
387
+ if (intake?.data)
388
+ out.push("", `What was read from it: ${JSON.stringify(intake.data)}`);
389
+ if (intake?.assumptions?.length)
390
+ out.push(`Assumed: ${intake.assumptions.map((a) => `${a.field}: ${a.note}`).join("; ")}`);
391
+ }
392
+ out.push("", `Facts: ${JSON.stringify(o.facts)}`, "", "Tokens (use these, never the words after the equals sign):");
393
+ for (const t of o.tokens.values())
394
+ out.push(`{{${t.name}}} = ${t.value}`);
395
+ if (o.tokens.size === 0)
396
+ out.push("(none)");
397
+ out.push("", `Ask for: ${o.asked.length ? o.asked.map(label).join(", ") : "nothing"}`);
398
+ out.push(`Link: ${o.link ? "yes, put {{link}} once" : "none"}`);
399
+ if (o.previous)
400
+ out.push("", "The earlier draft, to write again:", "<<<", o.previous, ">>>");
401
+ if (o.note)
402
+ out.push(`The person asked: ${o.note}`);
403
+ if (o.fix) {
404
+ out.push("", "Your last draft failed the checks. It was:", "<<<", o.fix.draft, ">>>", "What was wrong:", ...o.fix.problems.map((p) => `- ${p.detail}`), "Write it again, fixing these. Use tokens for every fact.");
405
+ }
406
+ return out.join("\n");
407
+ }
408
+ const DRAFT_SCHEMA = z.object({ text: z.string().describe("The reply, with a token for every fact") });
409
+ /** The drafter: one call, and one more when the check fails. Returns the draft and the token text it was filled from. */
410
+ export async function draftReply(structured, guides, req) {
411
+ const guide = guides?.[req.guide];
412
+ if (!guide) {
413
+ throw new WorkbenchAiError(guides ? "invalid_request" : "invalid_config", guides ? `No reply guide named ${req.guide}: add workbench/replies/${req.guide}.json and run npx workbench-ai replies generate` : "No reply guides: pass replyGuides: REPLY_GUIDES (from src/replies.generated.ts) to createAi");
414
+ }
415
+ if (req.link && (typeof req.link.url !== "string" || !/^https:\/\/\S+$/.test(req.link.url)))
416
+ throw new WorkbenchAiError("invalid_request", "link.url must be an https address");
417
+ const facts = req.facts;
418
+ for (const r of guide.facts.required ?? []) {
419
+ if (isEmpty(facts[r]))
420
+ throw new WorkbenchAiError("invalid_request", `The facts lack ${r}, which the guide ${guide.name} requires`);
421
+ }
422
+ const tokens = tokensFor(guide, facts);
423
+ const asked = questionsFor(guide, facts, req.incoming?.intake);
424
+ const { language, unsure } = languageFor(req);
425
+ const link = req.link ? req.link : false;
426
+ const call = (fix) => structured({
427
+ model: guide.model ?? "claude-default",
428
+ job: `reply.${guide.name}`,
429
+ promptVersion: `${REPLY_PROMPT_VERSION}+${guide.name}.${guide.version}`,
430
+ system: cachedBlocks(SYSTEM, guideBlock(guide)),
431
+ prompt: userPrompt({
432
+ language,
433
+ ...(req.incoming ? { incoming: req.incoming } : {}),
434
+ facts,
435
+ tokens,
436
+ asked,
437
+ link: link !== false,
438
+ ...(req.note ? { note: req.note } : {}),
439
+ ...(req.previous ? { previous: req.previous } : {}),
440
+ ...(fix ? { fix } : {}),
441
+ }),
442
+ schema: DRAFT_SCHEMA,
443
+ maxTokens: 2000,
444
+ voice: guide.voice ?? "conversational",
445
+ ...(guide.spelling ? { spelling: guide.spelling } : { spelling: packFor(guide).spelling }),
446
+ ...(req.subject ? { subject: `${req.subject.type}:${req.subject.id}` } : {}),
447
+ ...(req.keep !== undefined ? { keep: req.keep } : {}),
448
+ });
449
+ const first = await call();
450
+ let raw = first.data.text;
451
+ let checked = checkDraft(raw, { guide, facts, tokens, link });
452
+ let last = first;
453
+ let cost = first.costUsd;
454
+ if (checked.problems.length) {
455
+ const second = await call({ draft: raw, problems: checked.problems });
456
+ cost += second.costUsd;
457
+ last = second;
458
+ raw = second.data.text;
459
+ checked = checkDraft(raw, { guide, facts, tokens, link });
460
+ }
461
+ const reasons = new Set();
462
+ for (const p of checked.problems)
463
+ if (p.reason !== "writing")
464
+ reasons.add(p.reason);
465
+ const intake = req.incoming?.intake;
466
+ if (intake?.check_reasons.some((r) => r !== "missing_required"))
467
+ reasons.add("ambiguous_incoming");
468
+ if (unsure)
469
+ reasons.add("language_unsure");
470
+ const check_reasons = [...reasons];
471
+ const draft = {
472
+ text: checked.text,
473
+ language,
474
+ asked,
475
+ answered: checked.answered,
476
+ needs_human_check: check_reasons.length > 0,
477
+ check_reasons,
478
+ ...(link ? { link } : {}),
479
+ workbench: { request_id: last.requestId, cost_usd: Math.round(cost * 1e6) / 1e6, model: last.model, alias: guide.model ?? "claude-default" },
480
+ };
481
+ return { draft, template: raw };
482
+ }
@@ -0,0 +1,36 @@
1
+ import type { ReplyCheckReason, ReplyDraft } from "./replies.ts";
2
+ export { REPLY_STYLES } from "./styles.ts";
3
+ export type ReplyWordKey = "copy" | "copied" | "selected" | "again" | "note_label" | "note_hint" | "check" | "refused" | "failed" | `reason.${ReplyCheckReason}`;
4
+ /** The box's words in English, Tagalog and Taglish. A project's own strings win (`ui.wbreply.<key>`). */
5
+ export declare const REPLY_WORDS: Readonly<Record<"en" | "tl" | "taglish", Readonly<Record<ReplyWordKey, string>>>>;
6
+ /** The project's strings: `t("ui.wbreply.copy")`. Undefined for a word it lacks (the box's own words are used). */
7
+ export type Words = (key: string) => string | undefined;
8
+ export interface ReplyBoxOptions {
9
+ /** Where Write it again posts (the project's route that calls ai.reply with `note` and `previous`). Left out: no Write it again. */
10
+ againAction?: string;
11
+ /** Fields posted with Write it again, such as the enquiry's id. */
12
+ hidden?: Readonly<Record<string, string>>;
13
+ /** The box's own words: `en` (default), `tl` or `taglish`. */
14
+ language?: "en" | "tl" | "taglish";
15
+ /** The project's strings, which win over the box's own words. */
16
+ words?: Words;
17
+ }
18
+ /**
19
+ * The box for a draft, or for why there is none: pass what `ai.reply` gave, or
20
+ * the error it threw. A refusal by the content rules or by Claude shows "We
21
+ * can't draft a reply to this one"; any other error, "Try again".
22
+ */
23
+ export declare function renderReplyBox(result: ReplyDraft | unknown, opts?: ReplyBoxOptions): string;
24
+ /** What Copy needs from the page: the clipboard (absent or refusing is fine) and a way to select the text. */
25
+ export interface CopyDeps {
26
+ clipboard?: {
27
+ writeText(text: string): Promise<void>;
28
+ } | undefined;
29
+ select(): void;
30
+ }
31
+ /** Copies exactly `text`; when the Clipboard API is missing or refused, selects it instead. */
32
+ export declare function copyReply(text: string, deps: CopyDeps): Promise<"copied" | "selected">;
33
+ /** Runs on the page: wires every box's Copy button. Stringified into REPLY_SCRIPT, so it uses nothing from outside. */
34
+ export declare function mountReplyBoxes(copy: typeof copyReply): void;
35
+ /** The box's script: put it in the page under its nonce, after the boxes. */
36
+ export declare const REPLY_SCRIPT: string;
@@ -0,0 +1,146 @@
1
+ // '@mccaskie-labs/workbench-ai/reply-ui': the box a person copies a draft
2
+ // from (docs/capabilities/04-reply-drafter.md). renderReplyBox draws the
3
+ // draft, Copy reply and Write it again; REPLY_SCRIPT (under the page's nonce)
4
+ // makes Copy work with the Clipboard API and, when the browser refuses it,
5
+ // selects the text so the person can copy it themselves. Nothing is ever sent
6
+ // from here: the person pastes the reply.
7
+ //
8
+ // Every element carries a wb-reply-* class (REPLY_STYLES), so a project
9
+ // restyles the box from its own CSS.
10
+ export { REPLY_STYLES } from "./styles.js";
11
+ /** The box's words in English, Tagalog and Taglish. A project's own strings win (`ui.wbreply.<key>`). */
12
+ export const REPLY_WORDS = {
13
+ en: {
14
+ copy: "Copy reply",
15
+ copied: "Copied",
16
+ selected: "Selected: press copy",
17
+ again: "Write it again",
18
+ note_label: "What to change",
19
+ note_hint: "Make it shorter, don't mention the pool…",
20
+ check: "Check this one before you send it:",
21
+ refused: "We can't draft a reply to this one. Please write it yourself.",
22
+ failed: "The draft didn't come through. Try again in a moment.",
23
+ "reason.unknown_fact": "it has a price, date or number that isn't from your details",
24
+ "reason.missing_fact": "something it needed is missing (shown as ___)",
25
+ "reason.too_long": "it's long",
26
+ "reason.ambiguous_incoming": "the message was hard to read",
27
+ "reason.language_unsure": "we weren't sure which language to reply in",
28
+ "reason.never_rule": "it says something your replies never say",
29
+ "reason.contradicts_fact": "it may say the date is free when it isn't",
30
+ },
31
+ tl: {
32
+ copy: "Kopyahin ang sagot",
33
+ copied: "Nakopya na",
34
+ selected: "Napili na: pindutin ang kopya",
35
+ again: "Isulat ulit",
36
+ note_label: "Ano ang babaguhin",
37
+ note_hint: "Paikliin, huwag banggitin ang pool…",
38
+ check: "Tingnan muna ito bago ipadala:",
39
+ refused: "Hindi namin ito masasagot. Pakisulat na lang po ninyo.",
40
+ failed: "Hindi dumating ang sagot. Subukan ulit mamaya.",
41
+ "reason.unknown_fact": "may presyo, petsa o numero na wala sa inyong detalye",
42
+ "reason.missing_fact": "may kulang na detalye (makikita bilang ___)",
43
+ "reason.too_long": "mahaba ito",
44
+ "reason.ambiguous_incoming": "mahirap basahin ang mensahe",
45
+ "reason.language_unsure": "hindi kami sigurado kung anong wika ang gagamitin",
46
+ "reason.never_rule": "may sinabi itong hindi dapat sabihin",
47
+ "reason.contradicts_fact": "baka sinasabi nitong bakante ang petsa kahit hindi",
48
+ },
49
+ taglish: {
50
+ copy: "Copy reply",
51
+ copied: "Copied na",
52
+ selected: "Naka-select na: pindutin ang copy",
53
+ again: "Isulat ulit",
54
+ note_label: "Ano ang babaguhin",
55
+ note_hint: "Paikliin, huwag i-mention ang pool…",
56
+ check: "I-check muna ito bago i-send:",
57
+ refused: "Hindi namin ma-draft ang reply dito. Pakisulat na lang po.",
58
+ failed: "Hindi dumating ang draft. Try ulit mamaya.",
59
+ "reason.unknown_fact": "may price, date o number na wala sa details ninyo",
60
+ "reason.missing_fact": "may kulang na detail (makikita bilang ___)",
61
+ "reason.too_long": "mahaba ito",
62
+ "reason.ambiguous_incoming": "mahirap basahin ang message",
63
+ "reason.language_unsure": "hindi kami sure kung anong language ang gagamitin",
64
+ "reason.never_rule": "may sinabi itong hindi dapat sabihin",
65
+ "reason.contradicts_fact": "baka sinasabi nitong available ang date kahit hindi",
66
+ },
67
+ };
68
+ const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
69
+ function wordsFor(o) {
70
+ const own = REPLY_WORDS[o.language ?? "en"];
71
+ return (key) => o.words?.(`ui.wbreply.${key.replace(".", "_")}`) ?? own[key];
72
+ }
73
+ const isDraft = (x) => !!x && typeof x === "object" && typeof x.text === "string" && Array.isArray(x.check_reasons);
74
+ /**
75
+ * The box for a draft, or for why there is none: pass what `ai.reply` gave, or
76
+ * the error it threw. A refusal by the content rules or by Claude shows "We
77
+ * can't draft a reply to this one"; any other error, "Try again".
78
+ */
79
+ export function renderReplyBox(result, opts = {}) {
80
+ const w = wordsFor(opts);
81
+ if (!isDraft(result)) {
82
+ const code = result?.code;
83
+ const refused = code === "content_refused" || code === "refused";
84
+ return `<section class="wb-reply" data-wb-reply><p class="wb-reply-error" role="alert">${esc(w(refused ? "refused" : "failed"))}</p></section>`;
85
+ }
86
+ const parts = [`<section class="wb-reply" data-wb-reply lang="${result.language === "en" ? "en" : "fil"}">`];
87
+ if (result.needs_human_check && result.check_reasons.length) {
88
+ parts.push(`<p class="wb-reply-check" role="note">${esc(w("check"))} ${esc(result.check_reasons.map((r) => w(`reason.${r}`)).join("; "))}.</p>`);
89
+ }
90
+ const id = `wbreply-${result.workbench.request_id.replace(/[^A-Za-z0-9_-]/g, "")}`;
91
+ parts.push(`<textarea class="wb-reply-text" id="${id}" rows="6" spellcheck="true" aria-label="${esc(w("copy"))}">${esc(result.text)}</textarea>`);
92
+ parts.push(`<div class="wb-reply-actions"><button type="button" class="wb-reply-copy" data-wb-reply-copy aria-controls="${id}">${esc(w("copy"))}</button><span class="wb-reply-status" role="status" aria-live="polite" data-copied="${esc(w("copied"))}" data-selected="${esc(w("selected"))}"></span></div>`);
93
+ if (opts.againAction) {
94
+ const hidden = Object.entries(opts.hidden ?? {})
95
+ .map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`)
96
+ .join("");
97
+ parts.push(`<form class="wb-reply-again" method="post" action="${esc(opts.againAction)}">${hidden}<input type="hidden" name="previous" value="${esc(result.text)}"><input class="wb-reply-note" name="note" maxlength="300" aria-label="${esc(w("note_label"))}" placeholder="${esc(w("note_hint"))}"><button type="submit" class="wb-reply-again-button">${esc(w("again"))}</button></form>`);
98
+ }
99
+ parts.push("</section>");
100
+ return parts.join("");
101
+ }
102
+ /** Copies exactly `text`; when the Clipboard API is missing or refused, selects it instead. */
103
+ export async function copyReply(text, deps) {
104
+ try {
105
+ if (!deps.clipboard)
106
+ throw new Error("no clipboard");
107
+ await deps.clipboard.writeText(text);
108
+ return "copied";
109
+ }
110
+ catch {
111
+ deps.select();
112
+ return "selected";
113
+ }
114
+ }
115
+ /** Runs on the page: wires every box's Copy button. Stringified into REPLY_SCRIPT, so it uses nothing from outside. */
116
+ export function mountReplyBoxes(copy) {
117
+ for (const box of Array.from(document.querySelectorAll("[data-wb-reply]"))) {
118
+ const button = box.querySelector("[data-wb-reply-copy]");
119
+ const area = box.querySelector("textarea");
120
+ const status = box.querySelector("[role=status]");
121
+ if (!button || !area || !status)
122
+ continue;
123
+ let timer;
124
+ button.addEventListener("click", () => {
125
+ void copy(area.value, {
126
+ clipboard: navigator.clipboard,
127
+ select: () => {
128
+ area.focus();
129
+ area.select();
130
+ area.setSelectionRange(0, area.value.length);
131
+ },
132
+ }).then((how) => {
133
+ status.textContent = status.dataset[how] ?? "";
134
+ button.classList.toggle("wb-reply-copied", how === "copied");
135
+ if (timer)
136
+ clearTimeout(timer);
137
+ timer = setTimeout(() => {
138
+ status.textContent = "";
139
+ button.classList.remove("wb-reply-copied");
140
+ }, 4000);
141
+ });
142
+ });
143
+ }
144
+ }
145
+ /** The box's script: put it in the page under its nonce, after the boxes. */
146
+ export const REPLY_SCRIPT = `(${mountReplyBoxes.toString()})(${copyReply.toString()});`;