@mccaskie-labs/workbench-ai 0.3.1 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @mccaskie-labs/workbench-ai
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - The reply drafter (docs/capabilities/04-reply-drafter.md). `ai.reply({ guide, facts, incoming, language, link, note, previous })` drafts a ready-to-paste reply from facts worked out by code and a reply guide (`workbench/replies/<name>.json`, typed by `npx workbench-ai replies generate` into `src/replies.generated.ts`, passed to `createAi` as `replyGuides`). The model places every fact as a token; the package fills the tokens with workbench-country (₱8,500, Saturday, December 12) and checks the finished text, so a price, date, number or address the model typed itself fails the draft, which is written once more and otherwise comes back with `needs_human_check` and its reasons. It asks only for intake's missing or ambiguous fields in the guide's `ask_order`, never one the facts hold, at most `max_questions`; replies in English, Tagalog or Taglish, matching the message by default; checks `never` lines about payment details, discounts and confirming bookings; and puts the link in exactly once. `'@mccaskie-labs/workbench-ai/reply-ui'` draws the box (`renderReplyBox`, `REPLY_SCRIPT`: Copy with a select-all fallback, Write it again), with `wb-reply-*` classes and `REPLY_STYLES` from `./styles`. `npx workbench-ai replies drafts` and `replies pick` make and keep sample replies for a guide.
8
+
3
9
  ## 0.3.1
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -53,3 +53,38 @@ npx workbench-ai pick <name> <round>-<draft> [--licence "<the owner's words>"] [
53
53
  ```
54
54
 
55
55
  `drafts` writes the drafts and a preview page to `workbench/<ai|images|image-search>/drafts/<name>/` (keep it out of git), prints what the round cost, and stops after three rounds. `pick` keeps one in `workbench/<tool>/<name>.json`, and for a picture writes `public/images/<name>.<ext>`. A found picture needs `--licence`. Exit codes: 2 for a bad command, 3 for no development key or Gateway address, 4 after the last round.
56
+
57
+ ## Reply drafts: `ai.reply`
58
+
59
+ A ready-to-paste reply to a customer's message, from facts your code worked out and a reply guide (docs/capabilities/04-reply-drafter.md). The rule: **facts come from data, words from the AI.** The model places every fact as a token (`{{packages.0.price}}`); the package fills it with workbench-country (₱8,500, Saturday, December 12) and then checks the finished text. A price, date, number or address the model typed itself fails the draft: it is written once more, and if it still fails it comes back with `needs_human_check: true` and the reasons. Nothing is ever sent: a person copies it.
60
+
61
+ ```ts
62
+ import { createAi } from '@mccaskie-labs/workbench-ai'
63
+ import { renderReplyBox, REPLY_SCRIPT } from '@mccaskie-labs/workbench-ai/reply-ui'
64
+ import { REPLY_GUIDES } from './replies.generated'
65
+
66
+ const ai = createAi({ key: env.WORKBENCH_GATEWAY_KEY, env: workbenchEnv(env), url: env.WORKBENCH_GATEWAY_URL, replyGuides: REPLY_GUIDES })
67
+ const draft = await ai.reply({
68
+ guide: 'venue-quote',
69
+ facts: { venue_name: 'Villa Sol', date: '2026-12-12', available: 'yes', packages: [{ name: 'Day Use Package', price: '8500.00', max_guests: 20 }] },
70
+ incoming: { text, intake: read }, // intake's missing and ambiguous fields decide what is asked
71
+ language: 'match', // or 'en', 'tl', 'taglish': the account's own setting
72
+ link: { url: quoteLink }, // put in exactly once, at {{link}}
73
+ })
74
+ page.body = renderReplyBox(draft, { againAction: `/enquiries/${id}/reply` }) // then REPLY_SCRIPT under the page's nonce
75
+ ```
76
+
77
+ | Export | What it does |
78
+ | --- | --- |
79
+ | `ai.reply(req)` | `{ text, language, asked, answered, needs_human_check, check_reasons, link?, workbench }`. Job `reply.<guide>`, so its spending has its own line. `check_reasons`: `unknown_fact`, `missing_fact`, `too_long`, `ambiguous_incoming`, `language_unsure`, `never_rule`, `contradicts_fact` |
80
+ | `req.note`, `req.previous` | Write it again: the person's note ("make it shorter") and the draft it replaces |
81
+ | `req.fallbackLanguage` | With `match`, the language used when the message's can't be told (flagged `language_unsure`). Default `en` |
82
+ | `checkGuide(json)` | Checks a guide's JSON, naming every problem |
83
+ | `npx workbench-ai replies generate [--check]` | Every `workbench/replies/<name>.json` into `src/replies.generated.ts`: `REPLY_GUIDES` and each guide's facts as a type, so `ai.reply` checks the guide name and facts. `--check` fails when it is out of date |
84
+ | `npx workbench-ai replies drafts <name> [--languages en,taglish] [--facts <file>]` | Two to four sample replies from sample facts, with the development key, written to `workbench/replies/drafts/<name>/` with the round's cost. Three rounds at most |
85
+ | `npx workbench-ai replies pick <name> <round>-<draft>` | Makes that sample the guide's first example |
86
+ | `renderReplyBox(draftOrError, { againAction?, hidden?, language?, words? })` (`/reply-ui`) | The draft in a text box, Copy reply and Write it again. Given the error `ai.reply` threw, a content refusal shows "We can't draft a reply to this one", anything else "Try again". English, Tagalog and Taglish words; a project's `ui.wbreply.*` strings win |
87
+ | `REPLY_SCRIPT` (`/reply-ui`) | Copy with the Clipboard API, and when the browser refuses it, selects the text so the person can copy it |
88
+ | `REPLY_STYLES` (`/styles`) | The `wb-reply-*` classes and `--wb-reply-*` variables, written into the project's `workbench.css` by `npx workbench-ui styles` |
89
+
90
+ A guide's facts are a small JSON Schema: strings (`format: "date"` for a calendar day), numbers (`x-money: "PHP"` for an amount, given as a number or a decimal string), whole numbers, enums, and lists of these or of flat objects. Booleans and enums like `available` steer the reply but aren't written as tokens. Until the voices' language setting and the Filipino pack are built, the language instruction travels in the drafter's own prompt.
package/dist/ai.js CHANGED
@@ -4,6 +4,7 @@ import { createCapture, isGatewayMeta, REQUEST_ID_HEADER } from "./capture.js";
4
4
  import { errorFromGateway, WorkbenchAiError } from "./errors.js";
5
5
  import { gatewayFetch } from "./http.js";
6
6
  import { createImages } from "./images.js";
7
+ import { draftReply } from "./replies.js";
7
8
  import { SPELLING_NAMES, VOICE_NAMES } from "./writing.js";
8
9
  const KEY_PREFIX = {
9
10
  development: "wbk_dev_",
@@ -201,7 +202,9 @@ export function createAi(opts) {
201
202
  return value;
202
203
  }
203
204
  const images = createImages({ url, key: opts.key, env, fetch: baseFetch });
204
- return { anthropic, text, structured, stream, countTokens, budget, images };
205
+ const replyWithTemplate = (req) => draftReply(structured, opts.replyGuides, req);
206
+ const reply = async (req) => (await replyWithTemplate(req)).draft;
207
+ return { anthropic, text, structured, stream, countTokens, budget, images, reply, replyWithTemplate };
205
208
  }
206
209
  function params(req, defaultMax) {
207
210
  const body = {
package/dist/cli.d.ts CHANGED
@@ -18,5 +18,7 @@ type Fetch = (url: string) => Promise<{
18
18
  arrayBuffer(): Promise<ArrayBuffer>;
19
19
  }>;
20
20
  export declare function pick(dir: string, args: Args, fetcher?: Fetch): Promise<string[]>;
21
+ /** `replies generate|drafts|pick`. */
22
+ export declare function replies(argv: string[]): Promise<string[]>;
21
23
  export declare function main(argv: string[]): Promise<number>;
22
24
  export {};
package/dist/cli.js CHANGED
@@ -11,15 +11,23 @@
11
11
  // Keeps that draft: workbench/<tool>/<name>.json, and for a picture the
12
12
  // file in public/images/. A found picture needs --licence: what the owner
13
13
  // said gives the site the right to use it.
14
+ // workbench-ai replies generate [--check] [dir]
15
+ // workbench-ai replies drafts <name> [--languages en,taglish] [--facts <file>] [dir]
16
+ // workbench-ai replies pick <name> <round>-<draft> [dir]
17
+ // Reply guides (docs/capabilities/04-reply-drafter.md): see replies-gen.ts.
14
18
  import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
15
19
  import { dirname, join, resolve } from "node:path";
16
20
  import { fileURLToPath } from "node:url";
17
21
  import { ASPECT_RATIOS, CONTENT_KINDS, draftContent } from "./content.js";
22
+ import { generate, pickSample, readGuides, sampleDrafts } from "./replies-gen.js";
18
23
  /** Rounds of drafts per piece (the offers' drafts.max_rounds), counted in the drafts folder's rounds.json. */
19
24
  export const MAX_ROUNDS = 3;
20
25
  const USAGE = [
21
26
  'usage: workbench-ai drafts text|image|search <name> --about "<what it is for>" [--count 3] [--aspect 3:2] [--spelling en-AU] [dir]',
22
27
  ' workbench-ai pick <name> <round>-<draft> [--licence "<the owner\'s words>"] [--replace] [dir]',
28
+ " workbench-ai replies generate [--check] [dir]",
29
+ " workbench-ai replies drafts <name> [--languages en,taglish] [--facts <file>] [dir]",
30
+ " workbench-ai replies pick <name> <round>-<draft> [dir]",
23
31
  ].join("\n");
24
32
  const NAME = /^[a-z][a-z0-9_]{0,39}$/;
25
33
  /** The largest found picture `pick` downloads. */
@@ -224,8 +232,53 @@ export async function pick(dir, args, fetcher = fetch) {
224
232
  lines.push("Put the wording into the strings catalogue (see the workbench-strings skill), keyed for where it is shown.");
225
233
  return lines;
226
234
  }
235
+ /** `replies generate|drafts|pick`. */
236
+ export async function replies(argv) {
237
+ const [sub, ...rest] = argv;
238
+ const flags = {};
239
+ const positional = [];
240
+ for (let i = 0; i < rest.length; i++) {
241
+ const a = rest[i];
242
+ if (a === "--check")
243
+ flags.check = true;
244
+ else if (a === "--languages" || a === "--facts") {
245
+ const v = rest[++i];
246
+ if (v === undefined)
247
+ throw new Stop(`${a} needs a value\n${USAGE}`, 2);
248
+ flags[a.slice(2)] = v;
249
+ }
250
+ else if (a.startsWith("--"))
251
+ throw new Stop(`${a} isn't an option\n${USAGE}`, 2);
252
+ else
253
+ positional.push(a);
254
+ }
255
+ try {
256
+ if (sub === "generate")
257
+ return generate(resolve(positional[0] ?? "."), flags.check === true);
258
+ if (sub === "drafts" && positional[0]) {
259
+ const dir = resolve(positional[1] ?? ".");
260
+ const { key, url } = gatewayFor(dir);
261
+ const { createAi } = await import("./ai.js");
262
+ const guides = Object.fromEntries(readGuides(dir).map((g) => [g.name, g]));
263
+ const ai = createAi({ key, env: "development", url, replyGuides: guides });
264
+ return await sampleDrafts(dir, positional[0], { ...(typeof flags.languages === "string" ? { languages: flags.languages } : {}), ...(typeof flags.facts === "string" ? { facts: flags.facts } : {}) }, ai);
265
+ }
266
+ if (sub === "pick" && positional[0] && positional[1])
267
+ return pickSample(resolve(positional[2] ?? "."), positional[0], positional[1]);
268
+ }
269
+ catch (e) {
270
+ if (e instanceof Stop)
271
+ throw e;
272
+ throw new Stop(e.message, 1);
273
+ }
274
+ throw new Stop(USAGE, 2);
275
+ }
227
276
  export async function main(argv) {
228
277
  try {
278
+ if (argv[0] === "replies") {
279
+ console.log((await replies(argv.slice(1))).join("\n"));
280
+ return 0;
281
+ }
229
282
  const args = parse(argv);
230
283
  const command = args.positional[0];
231
284
  const rest = args.positional.slice(3);
package/dist/fake.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type WorkbenchEnv } from "@mccaskie-labs/workbench-config";
2
+ import type { ReplyGuide } from "./replies.ts";
2
3
  import type { Ai } from "./types.ts";
3
4
  export interface FakeReply {
4
5
  /** Reply text. For `structured`, give `json` instead. */
@@ -51,6 +52,8 @@ export interface FakeScript {
51
52
  mode?: "enforce" | "shadow";
52
53
  } | null;
53
54
  env?: WorkbenchEnv;
55
+ /** The reply guides `reply` drafts with (the project's REPLY_GUIDES). */
56
+ replyGuides?: Readonly<Record<string, ReplyGuide>>;
54
57
  /** What `images.generate` and `images.search` get back. */
55
58
  images?: {
56
59
  /** Bytes and model for every image; by default a 1x1 PNG and the alias's target from the manifest. */
package/dist/fake.js CHANGED
@@ -66,7 +66,7 @@ function fakeSearch(script, body, requestId) {
66
66
  export function createFakeAi(script = {}) {
67
67
  const env = script.env ?? "development";
68
68
  const { fetch, calls } = fakeGateway(script);
69
- const ai = createAi({ key: FAKE_KEY[env], env, url: "https://gateway.fake", fetch });
69
+ const ai = createAi({ key: FAKE_KEY[env], env, url: "https://gateway.fake", fetch, ...(script.replyGuides ? { replyGuides: script.replyGuides } : {}) });
70
70
  return Object.assign(ai, { calls });
71
71
  }
72
72
  /** The fake Gateway on its own, as a `fetch`, for tests that build their own client. */
package/dist/index.d.ts CHANGED
@@ -4,5 +4,6 @@ export { cachedSystem, cachedBlocks, provenance } from "./helpers.ts";
4
4
  export { WorkbenchAiError, type AiErrorCode, type AiErrorDetails } from "./errors.ts";
5
5
  export { REQUEST_ID_HEADER, type GatewayMeta } from "./capture.ts";
6
6
  export { ASPECT_RATIOS } from "./images.ts";
7
+ export { checkDraft, checkGuide, guessLanguage, languageFor, questionsFor, tokensFor, GAP, LANGUAGE_LINES, REPLY_LANGUAGES, REPLY_PROMPT_VERSION, type DraftWithTemplate, type FactSchema, type GuideFacts, type IntakeResult, type ReplyCheckReason, type ReplyDraft, type ReplyExample, type ReplyGuide, type ReplyGuideFacts, type ReplyGuideName, type ReplyLanguage, type ReplyRequest, } from "./replies.ts";
7
8
  export { checkWriting, paragraphBands, paragraphTarget, paragraphsOf, writingFixNote, writingNotes, writingStops, SPELLING_NAMES, VOICE_NAMES, type CheckWritingOptions, type OwnVoice, type SpellingName, type VoiceName, type WritingIssue, } from "./writing.ts";
8
9
  export type { Ai, AiImages, AiOptions, AspectRatio, GeneratedImage, ImageGenerateRequest, ImageSearchRequest, ImageSearchResult, ImageSearchResults, AiStream, BudgetStatus, ContentBlockParam, CountTokensRequest, Effort, MessageStreamEvent, Provenance, StructuredRequest, StructuredResult, SystemBlock, TextRequest, TextResult, Usage, VoiceUsed, ZodLike, } from "./types.ts";
package/dist/index.js CHANGED
@@ -4,4 +4,5 @@ export { cachedSystem, cachedBlocks, provenance } from "./helpers.js";
4
4
  export { WorkbenchAiError } from "./errors.js";
5
5
  export { REQUEST_ID_HEADER } from "./capture.js";
6
6
  export { ASPECT_RATIOS } from "./images.js";
7
+ export { checkDraft, checkGuide, guessLanguage, languageFor, questionsFor, tokensFor, GAP, LANGUAGE_LINES, REPLY_LANGUAGES, REPLY_PROMPT_VERSION, } from "./replies.js";
7
8
  export { checkWriting, paragraphBands, paragraphTarget, paragraphsOf, writingFixNote, writingNotes, writingStops, SPELLING_NAMES, VOICE_NAMES, } from "./writing.js";
@@ -0,0 +1,20 @@
1
+ import { type ReplyGuide } from "./replies.ts";
2
+ import type { Ai } from "./types.ts";
3
+ /** The project's guides, checked, by name. Throws naming every bad file. */
4
+ export declare function readGuides(dir: string): ReplyGuide[];
5
+ /** The generated module's text. */
6
+ export declare function repliesModule(guides: readonly ReplyGuide[]): string;
7
+ export interface RepliesPlan {
8
+ files: Record<string, string>;
9
+ problems: string[];
10
+ }
11
+ /** What `replies generate` writes, and what --check fails on. */
12
+ export declare function planReplies(dir: string): RepliesPlan;
13
+ export declare function generate(dir: string, check: boolean): string[];
14
+ /** Sample replies for the owner to pick from. */
15
+ export declare function sampleDrafts(dir: string, name: string, o: {
16
+ languages?: string;
17
+ facts?: string;
18
+ }, ai: Pick<Ai, "replyWithTemplate">): Promise<string[]>;
19
+ /** Makes a sample the guide's first example. */
20
+ export declare function pickSample(dir: string, name: string, which: string): string[];
@@ -0,0 +1,188 @@
1
+ // `workbench-ai replies generate|drafts|pick` (docs/capabilities/04-reply-drafter.md):
2
+ //
3
+ // generate [--check] [dir] Every guide in <dir>/workbench/replies/*.json into
4
+ // src/replies.generated.ts: REPLY_GUIDES for createAi and
5
+ // the facts' types for ai.reply. --check writes nothing and
6
+ // fails when the file is out of date or a guide is wrong.
7
+ // drafts <name> [--languages en,taglish] [--facts <file>] [dir]
8
+ // Two to four sample replies from sample facts (the guide's
9
+ // first example's, or --facts), through the Gateway with the
10
+ // development key. Written to workbench/replies/drafts/<name>/.
11
+ // pick <name> <round>-<draft> [dir]
12
+ // Makes that sample the guide's first example.
13
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { checkGuide, REPLY_LANGUAGES } from "./replies.js";
16
+ const REPLIES_MODULE = "src/replies.generated.ts";
17
+ const REPLIES_FOLDER = "workbench/replies";
18
+ /** Rounds of sample replies per guide. */
19
+ const REPLY_MAX_ROUNDS = 3;
20
+ const read = (path) => readFileSync(path, "utf8").replace(/\r\n/g, "\n");
21
+ const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
22
+ const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
23
+ /** The project's guides, checked, by name. Throws naming every bad file. */
24
+ export function readGuides(dir) {
25
+ const folder = join(dir, REPLIES_FOLDER);
26
+ if (!existsSync(folder))
27
+ return [];
28
+ const problems = [];
29
+ const guides = [];
30
+ for (const file of readdirSync(folder).filter((f) => f.endsWith(".json")).sort()) {
31
+ const where = `${REPLIES_FOLDER}/${file}`;
32
+ try {
33
+ const g = checkGuide(JSON.parse(read(join(folder, file))), where);
34
+ if (`${g.name}.json` !== file)
35
+ throw new Error(`${where}: the file is named after its guide (${g.name}.json)`);
36
+ guides.push(g);
37
+ }
38
+ catch (e) {
39
+ problems.push(e.message);
40
+ }
41
+ }
42
+ if (problems.length)
43
+ throw new Error(problems.join("\n"));
44
+ return guides;
45
+ }
46
+ const pascal = (name) => name.replace(/(^|[-_])([a-z0-9])/g, (_m, _s, c) => c.toUpperCase());
47
+ function tsType(s, indent) {
48
+ switch (s.type) {
49
+ case "string":
50
+ return s.enum ? s.enum.map((e) => JSON.stringify(e)).join(" | ") : "string";
51
+ case "number":
52
+ case "integer":
53
+ return s["x-money"] ? "number | string" : "number";
54
+ case "boolean":
55
+ return "boolean";
56
+ case "array":
57
+ return `readonly (${s.items ? tsType(s.items, indent) : "unknown"})[]`;
58
+ case "object": {
59
+ const req = new Set(s.required ?? []);
60
+ const fields = Object.entries(s.properties ?? {}).map(([k, p]) => `${indent} ${k}${req.has(k) ? "" : "?"}: ${tsType(p, `${indent} `)};`);
61
+ return `{\n${fields.join("\n")}\n${indent}}`;
62
+ }
63
+ }
64
+ }
65
+ /** The generated module's text. */
66
+ export function repliesModule(guides) {
67
+ const out = [
68
+ "// Generated by `npx workbench-ai replies generate` from workbench/replies/*.json.",
69
+ "// Don't edit it: change the guide and run the command again.",
70
+ "",
71
+ 'import type { ReplyGuide } from "@mccaskie-labs/workbench-ai";',
72
+ "",
73
+ ];
74
+ for (const g of guides) {
75
+ out.push(`/** The facts of the reply guide "${g.name}", version ${g.version}. */`);
76
+ out.push(`export interface ${pascal(g.name)}Facts ${tsType(g.facts, "")}`, "");
77
+ }
78
+ out.push('declare module "@mccaskie-labs/workbench-ai" {', " interface ReplyGuideFacts {");
79
+ for (const g of guides)
80
+ out.push(` ${JSON.stringify(g.name)}: ${pascal(g.name)}Facts;`);
81
+ out.push(" }", "}", "");
82
+ out.push("/** Pass to createAi as `replyGuides`. */");
83
+ out.push(`export const REPLY_GUIDES: Readonly<Record<${guides.length ? guides.map((g) => JSON.stringify(g.name)).join(" | ") : "string"}, ReplyGuide>> = ${JSON.stringify(Object.fromEntries(guides.map((g) => [g.name, withoutSchema(g)])), null, 2)};`, "");
84
+ return out.join("\n");
85
+ }
86
+ function withoutSchema(g) {
87
+ const { $schema: _drop, ...rest } = g;
88
+ return rest;
89
+ }
90
+ /** What `replies generate` writes, and what --check fails on. */
91
+ export function planReplies(dir) {
92
+ const plan = { files: {}, problems: [] };
93
+ if (!existsSync(join(dir, "package.json"))) {
94
+ plan.problems.push(`${dir} is not a project (no package.json)`);
95
+ return plan;
96
+ }
97
+ let guides;
98
+ try {
99
+ guides = readGuides(dir);
100
+ }
101
+ catch (e) {
102
+ plan.problems.push(e.message);
103
+ return plan;
104
+ }
105
+ const path = join(dir, REPLIES_MODULE);
106
+ if (guides.length === 0 && !existsSync(path))
107
+ return plan;
108
+ const text = repliesModule(guides);
109
+ if (!existsSync(path) || read(path) !== text) {
110
+ plan.files[REPLIES_MODULE] = text;
111
+ plan.problems.push(`${REPLIES_MODULE} is out of date with ${REPLIES_FOLDER}/: run npx workbench-ai replies generate`);
112
+ }
113
+ return plan;
114
+ }
115
+ export function generate(dir, check) {
116
+ const plan = planReplies(dir);
117
+ const bad = plan.problems.filter((p) => !p.startsWith(REPLIES_MODULE));
118
+ if (bad.length)
119
+ throw new Error(bad.join("\n"));
120
+ if (check) {
121
+ if (plan.problems.length)
122
+ throw new Error(plan.problems.join("\n"));
123
+ return ["Reply guides are up to date."];
124
+ }
125
+ for (const [file, text] of Object.entries(plan.files)) {
126
+ mkdirSync(join(dir, file, ".."), { recursive: true });
127
+ writeFileSync(join(dir, file), text);
128
+ }
129
+ return Object.keys(plan.files).length ? [`Wrote ${REPLIES_MODULE}.`] : ["Nothing to change."];
130
+ }
131
+ /** Sample replies for the owner to pick from. */
132
+ export async function sampleDrafts(dir, name, o, ai) {
133
+ const guide = readGuides(dir).find((g) => g.name === name);
134
+ if (!guide)
135
+ throw new Error(`There is no guide ${REPLIES_FOLDER}/${name}.json`);
136
+ const facts = o.facts ? JSON.parse(read(join(dir, o.facts))) : guide.examples?.find((e) => e.facts)?.facts;
137
+ if (!facts)
138
+ throw new Error("No sample facts: give --facts <file.json>, or put facts on the guide's first example");
139
+ const languages = (o.languages ?? "en,taglish").split(",").map((l) => l.trim());
140
+ if (languages.some((l) => !REPLY_LANGUAGES.includes(l)))
141
+ throw new Error(`--languages is a list of ${REPLY_LANGUAGES.join(", ")}`);
142
+ // Two to four drafts: one per language, and a second wording when only one is asked for.
143
+ const plan = languages.slice(0, 4).map((language) => ({ language }));
144
+ if (plan.length === 1)
145
+ plan.push({ language: plan[0].language, note: "Write a different version: other words, same facts." });
146
+ const folder = join(dir, REPLIES_FOLDER, "drafts", name);
147
+ const roundsFile = join(folder, "rounds.json");
148
+ const rounds = existsSync(roundsFile) ? JSON.parse(read(roundsFile)) : { rounds: [] };
149
+ const round = rounds.rounds.length + 1;
150
+ if (round > REPLY_MAX_ROUNDS)
151
+ throw new Error(`${name} has had ${REPLY_MAX_ROUNDS} rounds of samples. Ask the owner to pick one, or to write an example themselves.`);
152
+ const done = { round, cost_usd: 0, drafts: [] };
153
+ for (const p of plan) {
154
+ const r = await ai.replyWithTemplate({ guide: name, facts, language: p.language, ...(p.note ? { note: p.note } : {}) });
155
+ done.drafts.push({ language: p.language, template: r.template, text: r.draft.text, request_id: r.draft.workbench.request_id, model: r.draft.workbench.model, cost_usd: r.draft.workbench.cost_usd });
156
+ done.cost_usd += r.draft.workbench.cost_usd;
157
+ }
158
+ done.cost_usd = Math.round(done.cost_usd * 1e6) / 1e6;
159
+ mkdirSync(folder, { recursive: true });
160
+ rounds.rounds.push(done);
161
+ writeFileSync(roundsFile, json(rounds));
162
+ const cards = done.drafts.map((d, i) => `<section><h2>${round}-${i + 1} (${d.language})</h2><p>${esc(d.text)}</p></section>`).join("");
163
+ writeFileSync(join(folder, `round-${round}.html`), `<!doctype html><meta charset="utf-8"><title>${esc(name)}: round ${round}</title><style>body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2rem auto;padding:0 1rem}section{border-top:1px solid #ccc;padding:1rem 0}</style><h1>${esc(name)}: round ${round}</h1>${cards}`);
164
+ return [
165
+ `Round ${round} of ${REPLY_MAX_ROUNDS} for ${name}: ${done.drafts.length} samples. This round cost US$${done.cost_usd.toFixed(2)}.`,
166
+ ...done.drafts.map((d, i) => ` ${round}-${i + 1} (${d.language}): ${d.text}`),
167
+ `Preview: ${REPLIES_FOLDER}/drafts/${name}/round-${round}.html`,
168
+ `Keep the owner's choice with: npx workbench-ai replies pick ${name} <round>-<draft>`,
169
+ ];
170
+ }
171
+ /** Makes a sample the guide's first example. */
172
+ export function pickSample(dir, name, which) {
173
+ if (!/^\d+-\d+$/.test(which))
174
+ throw new Error("Pick a sample as <round>-<draft>, such as 1-2");
175
+ const [round, n] = which.split("-").map(Number);
176
+ const roundsFile = join(dir, REPLIES_FOLDER, "drafts", name, "rounds.json");
177
+ if (!existsSync(roundsFile))
178
+ throw new Error(`There are no samples for ${name}`);
179
+ const d = JSON.parse(read(roundsFile)).rounds.find((r) => r.round === round)?.drafts[n - 1];
180
+ if (!d)
181
+ throw new Error(`There is no sample ${which} for ${name}`);
182
+ const file = join(dir, REPLIES_FOLDER, `${name}.json`);
183
+ const guide = JSON.parse(read(file));
184
+ const facts = guide.examples?.length ? guide.examples[0].facts : undefined;
185
+ guide.examples = [{ language: d.language, ...(facts ? { facts } : {}), text: d.template }, ...(guide.examples ?? [])].slice(0, 4);
186
+ writeFileSync(file, json(guide));
187
+ return [`${REPLIES_FOLDER}/${name}.json now starts with sample ${which} as its example. Run npx workbench-ai replies generate.`];
188
+ }
@@ -0,0 +1,211 @@
1
+ import type { StructuredRequest, StructuredResult } from "./types.ts";
2
+ import { type SpellingName, type VoiceName } from "./writing.ts";
3
+ /** The version of the drafter's own instructions, sent as the prompt version with the guide's. */
4
+ export declare const REPLY_PROMPT_VERSION = "2026-11-13";
5
+ export type ReplyLanguage = "en" | "tl" | "taglish";
6
+ export declare const REPLY_LANGUAGES: readonly ReplyLanguage[];
7
+ /**
8
+ * Why a draft needs a person to check it before it is copied. The first five
9
+ * are the spec's; `never_rule` (a guide's `never` line matched) and
10
+ * `contradicts_fact` (the text says something a fact rules out, such as
11
+ * "available" when `available` is `no`) were added in the build.
12
+ */
13
+ export type ReplyCheckReason = "unknown_fact" | "missing_fact" | "too_long" | "ambiguous_incoming" | "language_unsure" | "never_rule" | "contradicts_fact";
14
+ /** One field of a guide's facts: a small JSON Schema. */
15
+ export interface FactSchema {
16
+ type: "string" | "number" | "integer" | "boolean" | "array" | "object";
17
+ description?: string;
18
+ enum?: readonly string[];
19
+ /** `date`: a calendar day, YYYY-MM-DD, written by the country pack. */
20
+ format?: "date";
21
+ /** A money amount in this currency (ISO 4217, e.g. PHP), written by the country pack. A number, or a decimal string such as workbench-availability's "8500.00". */
22
+ "x-money"?: string;
23
+ /** A personal detail (a name), as intake marks them. */
24
+ "x-personal"?: string;
25
+ items?: FactSchema;
26
+ maxItems?: number;
27
+ properties?: Readonly<Record<string, FactSchema>>;
28
+ required?: readonly string[];
29
+ }
30
+ export interface ReplyExample {
31
+ language: ReplyLanguage;
32
+ facts?: Readonly<Record<string, unknown>>;
33
+ /** The reply with its tokens, as the model should write it. */
34
+ text: string;
35
+ }
36
+ /** A reply guide: `workbench/replies/<name>.json` in the project. */
37
+ export interface ReplyGuide {
38
+ format: 1;
39
+ name: string;
40
+ version: number;
41
+ /** A Workbench voice. Default `conversational`. */
42
+ voice?: VoiceName;
43
+ /** Default from the country: en-PH for PH. */
44
+ spelling?: SpellingName;
45
+ /** The country pack that writes amounts and dates (PH, AU, GB, US). Default: the spelling's country. */
46
+ country?: string;
47
+ /** A Workbench alias. Default `claude-default`. */
48
+ model?: string;
49
+ /** Default 90. Up to 1.25 times passes quietly; past 2 times the draft fails `too_long`. */
50
+ max_words?: number;
51
+ /** Default 2. */
52
+ max_questions?: number;
53
+ facts: FactSchema & {
54
+ type: "object";
55
+ properties: Readonly<Record<string, FactSchema>>;
56
+ };
57
+ /** The fields a reply may ask for, most important first. Only these are ever asked. */
58
+ ask_order?: readonly string[];
59
+ /** Plain sentences the owner edits: how to answer. */
60
+ guide: readonly string[];
61
+ examples?: readonly ReplyExample[];
62
+ /** What a reply must never do. Each goes into the prompt; the ones code can see are also checked. */
63
+ never?: readonly string[];
64
+ }
65
+ /**
66
+ * Each guide's facts, by guide name. Empty here; a project's generated
67
+ * `src/replies.generated.ts` adds its guides to it, so `ai.reply` checks the
68
+ * guide name and the facts' types.
69
+ */
70
+ export interface ReplyGuideFacts {
71
+ }
72
+ export type ReplyGuideName = [keyof ReplyGuideFacts] extends [never] ? string : Extract<keyof ReplyGuideFacts, string>;
73
+ export type GuideFacts<G extends string> = G extends keyof ReplyGuideFacts ? ReplyGuideFacts[G] : Readonly<Record<string, unknown>>;
74
+ /** What intake returned for the message (docs/message-intake.md §2), the parts the drafter reads. */
75
+ export interface IntakeResult {
76
+ type: string;
77
+ type_version?: number;
78
+ confidence?: number;
79
+ data: Readonly<Record<string, unknown>> | null;
80
+ missing: readonly string[];
81
+ ambiguous: readonly {
82
+ field: string;
83
+ reason?: string;
84
+ options?: readonly unknown[];
85
+ }[];
86
+ assumptions?: readonly {
87
+ field: string;
88
+ note: string;
89
+ }[];
90
+ raw_text?: string;
91
+ /** ISO 639-1 codes seen in the message. */
92
+ languages?: readonly string[];
93
+ needs_human_check: boolean;
94
+ check_reasons: readonly string[];
95
+ workbench?: {
96
+ request_id: string;
97
+ receipt?: unknown;
98
+ [key: string]: unknown;
99
+ };
100
+ }
101
+ export interface ReplyRequest<G extends string = ReplyGuideName> {
102
+ guide: G;
103
+ facts: GuideFacts<G>;
104
+ /** The message being answered. Intake's `missing` and `ambiguous` decide what is asked. */
105
+ incoming?: {
106
+ text: string;
107
+ intake?: IntakeResult;
108
+ };
109
+ /** Default `match`: the language the message is in. */
110
+ language?: ReplyLanguage | "match";
111
+ /** With `match`, the language used when the message's language can't be told. Default `en`. */
112
+ fallbackLanguage?: ReplyLanguage;
113
+ /** "Make it shorter", "don't mention the pool": as Imprint's Write it again. */
114
+ note?: string;
115
+ /** The draft being replaced. */
116
+ previous?: string;
117
+ /**
118
+ * The link to put at `{{link}}`, exactly once. Until short links (capability 3)
119
+ * are built, the app passes the finished address. `false` or left out: no link.
120
+ */
121
+ link?: {
122
+ url: string;
123
+ } | false;
124
+ subject?: {
125
+ type: string;
126
+ id: string;
127
+ };
128
+ /** The app's account (plans, capability 6). Not sent yet. */
129
+ account?: string;
130
+ /** As on every call: false marks it do-not-keep. */
131
+ keep?: boolean;
132
+ }
133
+ export interface ReplyDraft {
134
+ /** Ready to paste. */
135
+ text: string;
136
+ language: ReplyLanguage;
137
+ /** The fields it asked for. */
138
+ asked: string[];
139
+ /** The facts it used. */
140
+ answered: string[];
141
+ needs_human_check: boolean;
142
+ check_reasons: ReplyCheckReason[];
143
+ /** The link put in, when there was one. */
144
+ link?: {
145
+ url: string;
146
+ };
147
+ workbench: {
148
+ request_id: string;
149
+ cost_usd: number;
150
+ model: string;
151
+ alias: string;
152
+ };
153
+ }
154
+ /** A draft and the token text it was filled from (what `replies pick` keeps as an example). */
155
+ export interface DraftWithTemplate {
156
+ draft: ReplyDraft;
157
+ template: string;
158
+ }
159
+ /** Checks a guide's JSON and returns it typed, or throws an Error naming every problem. */
160
+ export declare function checkGuide(value: unknown, where?: string): ReplyGuide;
161
+ interface Token {
162
+ /** The token's name, as written between the braces: `packages.0.price`. */
163
+ name: string;
164
+ /** The top-level fact it comes from: `packages`. */
165
+ fact: string;
166
+ /** The finished words: ₱8,500. */
167
+ value: string;
168
+ /** Other ways the same fact may be written and still pass the check (8,500 for ₱8,500). */
169
+ also: string[];
170
+ }
171
+ /** Every token the facts give, with its finished words. Booleans and empty facts give none. */
172
+ export declare function tokensFor(guide: ReplyGuide, facts: Readonly<Record<string, unknown>>): Map<string, Token>;
173
+ /** 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. */
174
+ export declare function questionsFor(guide: ReplyGuide, facts: Readonly<Record<string, unknown>>, intake: IntakeResult | undefined): string[];
175
+ /** A rough reading of a short message's language, for `match` when intake gave none, and for the tests' checker. */
176
+ export declare function guessLanguage(text: string): ReplyLanguage | undefined;
177
+ /** The reply's language and whether it had to fall back. */
178
+ export declare function languageFor(req: Pick<ReplyRequest, "language" | "fallbackLanguage" | "incoming">): {
179
+ language: ReplyLanguage;
180
+ unsure: boolean;
181
+ };
182
+ /**
183
+ * How each language is written. Until the voices' language setting and the
184
+ * Filipino pack are built (docs/spec.md §2.8), these lines go in the guide's
185
+ * prompt; they move there when they are.
186
+ */
187
+ export declare const LANGUAGE_LINES: Readonly<Record<ReplyLanguage, string>>;
188
+ interface Problem {
189
+ reason: ReplyCheckReason | "writing";
190
+ detail: string;
191
+ }
192
+ interface CheckedDraft {
193
+ text: string;
194
+ problems: Problem[];
195
+ answered: string[];
196
+ }
197
+ /** Where a fact couldn't be filled: plain for the person checking to see and fill in. */
198
+ export declare const GAP = "___";
199
+ /** Fills the tokens and checks the finished text. Plain code: no AI. */
200
+ export declare function checkDraft(raw: string, ctx: {
201
+ guide: ReplyGuide;
202
+ facts: Readonly<Record<string, unknown>>;
203
+ tokens: Map<string, Token>;
204
+ link?: {
205
+ url: string;
206
+ } | false;
207
+ }): CheckedDraft;
208
+ type StructuredFn = <T>(req: StructuredRequest<T>) => Promise<StructuredResult<T>>;
209
+ /** The drafter: one call, and one more when the check fails. Returns the draft and the token text it was filled from. */
210
+ export declare function draftReply(structured: StructuredFn, guides: Readonly<Record<string, ReplyGuide>> | undefined, req: ReplyRequest<string>): Promise<DraftWithTemplate>;
211
+ export {};