@mccaskie-labs/workbench-ai 0.1.3 → 0.1.5

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,19 @@
1
1
  # @mccaskie-labs/workbench-ai
2
2
 
3
+ ## 0.1.5
4
+
5
+ ### Patch Changes
6
+
7
+ - Kept calls (`docs/spec.md` §2.9): a `keep` option on `text`, `structured`, `stream`, `countTokens`, `images.generate` and `images.search`. `keep: false` sends `X-Workbench-Keep: no`, so the Gateway keeps the call's facts but not its text; a refused call is kept whatever it says. `subject` is documented as how a person's kept calls are found: an app with signed-in users sends `user:<its own user id>`. `createFakeAi` records `keep` on each call.
8
+
9
+ ## 0.1.4
10
+
11
+ ### Patch Changes
12
+
13
+ - Shared voices (`docs/spec.md` §2.8). `workbench-ai`: `voice` and `spelling` options on `text`, `structured`, `stream` and `countTokens` (a Workbench voice by name, or `{ instructions }` for the app's own), `voice` on a text result, and a writing check with no AI call: `checkWriting`, `writingStops`, `writingNotes`, `writingFixNote`, `paragraphBands`. Paragraph length is a target: quiet up to 1.25 times, a note up to double, a stop only past that. `createFakeAi` records the voice and spelling of each call. `workbench-config`: the `ai` manifest lists the voices, spellings, targets and banned phrases, and three probes. `workbench-ai` now needs `workbench-config` 0.1.14.
14
+ - Updated dependencies
15
+ - @mccaskie-labs/workbench-config@0.1.12
16
+
3
17
  ## 0.1.3
4
18
 
5
19
  ### Patch Changes
package/README.md CHANGED
@@ -25,6 +25,10 @@ await save({ text: r.text, model: r.model, requestId: r.requestId })
25
25
  | `ai.budget()` | `{ status: 'ok' \| 'warn' \| 'reached', leftUsd, monthlyUsd, spentUsd, mode, month }` for this key's project and environment, cached 60 s |
26
26
  | `ai.images.generate({ prompt, aspectRatio, job, model?, references? })` | One image: `{ bytes, mimeType, model, requestId, costUsd }`. `model` is `image-default` (the default), `image-pro` or `image-openai`; `references` work on the Google aliases only. Needs the `images` scope |
27
27
  | `ai.images.search({ q, job, num?, gl?, hl? })` | Serper image search: a list of `{ title, imageUrl, thumbnailUrl, width, height, source, pageUrl }` that also carries `requestId`, `costUsd` and `credits`. Needs the `search` scope |
28
+ | `voice`, `spelling` on `text`, `structured`, `stream`, `countTokens` | `voice: 'conversational'` (or `casual`, `plain`, `formal`, `strict-formal`) has the Gateway add Workbench's house writing standards and that voice ahead of your system prompt; `spelling: 'en-AU'` (or `en-PH`, `en-GB`, `en-US`) adds one line. The text is the same bytes for every app, so it is cached and shared. `voice: { instructions }` is your own voice: it goes first in your own system prompt and no Workbench writing rule is added. A text result carries `voice: { name, spelling, version }` when one was named |
29
+ | `subject`, `keep` on every call | The Gateway keeps what each call sent and got back, for audit (`docs/spec.md` §2.9). `subject: 'user:<id>'` (your own user id, never a name or email address) is how a person's calls are found and deleted. `keep: false` keeps the call's facts but not its text; a refused call is kept whatever it says |
30
+ | `checkWriting(text, { voice?, paragraphTarget?, exempt? })` | Checks text against the house standards with no AI call. Stops: long dashes and the banned phrases. Paragraph length is a target: no issue up to 1.25 times it, a note up to double, a stop only past double. Sentence length is never an issue. `exempt` is required wording to leave out |
31
+ | `writingStops(issues)`, `writingNotes(issues)`, `writingFixNote(issues)` | The stops, the notes, and the text to add to a prompt when asking once more after a stop |
28
32
  | `ai.anthropic` | The official `@anthropic-ai/sdk` client pointed at the Gateway, for anything else |
29
33
  | `createFakeAi(script)` | The real client against a scripted fake Gateway, for tests: replies in order, refusals, truncations and Gateway errors, every call recorded in `.calls`. No key, no network |
30
34
  | `cachedSystem(stable, ...volatile)`, `cachedBlocks(...texts)` | System blocks with a prompt-cache breakpoint |
package/dist/ai.js CHANGED
@@ -5,6 +5,7 @@ import { createCapture, isGatewayMeta, REQUEST_ID_HEADER } from "./capture.js";
5
5
  import { errorFromGateway, WorkbenchAiError } from "./errors.js";
6
6
  import { gatewayFetch } from "./http.js";
7
7
  import { createImages } from "./images.js";
8
+ import { SPELLING_NAMES, VOICE_NAMES } from "./writing.js";
8
9
  const KEY_PREFIX = {
9
10
  development: "wbk_dev_",
10
11
  staging: "wbk_stg_",
@@ -59,6 +60,24 @@ export function createAi(opts) {
59
60
  throw new WorkbenchAiError("invalid_request", "subject must be type:id");
60
61
  h["X-Workbench-Subject"] = req.subject;
61
62
  }
63
+ if (req.keep === false)
64
+ h["X-Workbench-Keep"] = "no";
65
+ // Checked here too, so a wrong name fails before the network. The Gateway refuses it as well.
66
+ if (typeof req.voice === "string") {
67
+ if (!VOICE_NAMES.includes(req.voice))
68
+ throw new WorkbenchAiError("invalid_request", `voice must be one of ${VOICE_NAMES.join(", ")}, or { instructions } for the app's own`);
69
+ h["X-Workbench-Voice"] = req.voice;
70
+ }
71
+ else if (req.voice !== undefined && !isOwnVoice(req.voice)) {
72
+ throw new WorkbenchAiError("invalid_request", "an own voice needs its instructions");
73
+ }
74
+ if (req.spelling !== undefined) {
75
+ // Any case, as the Gateway takes it.
76
+ const spelling = SPELLING_NAMES.find((n) => n.toLowerCase() === String(req.spelling).toLowerCase());
77
+ if (spelling === undefined)
78
+ throw new WorkbenchAiError("invalid_request", `spelling must be one of ${SPELLING_NAMES.join(", ")}`);
79
+ h["X-Workbench-Spelling"] = spelling;
80
+ }
62
81
  return h;
63
82
  }
64
83
  async function create(body, req) {
@@ -160,7 +179,7 @@ export function createAi(opts) {
160
179
  const r = await client(cap.fetch).messages.countTokens({
161
180
  model: req.model,
162
181
  messages: [{ role: "user", content: req.prompt }],
163
- ...(req.system !== undefined ? { system: req.system } : {}),
182
+ ...withOwnVoice(req),
164
183
  }, { headers: headers(req) });
165
184
  return { inputTokens: r.input_tokens };
166
185
  }
@@ -187,12 +206,26 @@ function params(req, defaultMax) {
187
206
  max_tokens: req.maxTokens ?? defaultMax,
188
207
  messages: [{ role: "user", content: req.prompt }],
189
208
  };
190
- if (req.system !== undefined)
191
- body.system = req.system;
209
+ Object.assign(body, withOwnVoice(req));
192
210
  if (req.effort !== undefined)
193
211
  body.output_config = { effort: req.effort };
194
212
  return body;
195
213
  }
214
+ /**
215
+ * The app's system prompt. An own voice goes first in it, as the app's own block: the Gateway adds
216
+ * nothing for it, and its own content rules still come before everything.
217
+ */
218
+ function withOwnVoice(req) {
219
+ if (!isOwnVoice(req.voice))
220
+ return req.system !== undefined ? { system: req.system } : {};
221
+ const own = { type: "text", text: req.voice.instructions };
222
+ if (req.system === undefined || req.system === "")
223
+ return { system: [own] };
224
+ return { system: [own, ...(typeof req.system === "string" ? [{ type: "text", text: req.system }] : req.system)] };
225
+ }
226
+ function isOwnVoice(v) {
227
+ return typeof v === "object" && v !== null && typeof v.instructions === "string" && v.instructions.trim() !== "";
228
+ }
196
229
  function metaOf(message, cap) {
197
230
  const meta = message.workbench;
198
231
  if (isGatewayMeta(meta))
@@ -219,7 +252,14 @@ export function toTextResult(message, meta) {
219
252
  .filter((b) => b.type === "text")
220
253
  .map((b) => b.text)
221
254
  .join("");
222
- return { text, model: meta.model, requestId: meta.request_id, usage: usageOf(message.usage), costUsd: meta.cost_usd };
255
+ return {
256
+ text,
257
+ model: meta.model,
258
+ requestId: meta.request_id,
259
+ usage: usageOf(message.usage),
260
+ costUsd: meta.cost_usd,
261
+ ...(meta.voice ? { voice: meta.voice } : {}),
262
+ };
223
263
  }
224
264
  function usageOf(u) {
225
265
  return {
package/dist/capture.d.ts CHANGED
@@ -8,6 +8,12 @@ export interface GatewayMeta {
8
8
  refused?: boolean;
9
9
  alias?: string | null;
10
10
  attempts?: number;
11
+ /** Present when the call named a Workbench voice or spelling (docs/spec.md §2.8). */
12
+ voice?: {
13
+ name: string | null;
14
+ spelling: string | null;
15
+ version: number;
16
+ };
11
17
  }
12
18
  export interface Capture {
13
19
  fetch: typeof fetch;
package/dist/fake.d.ts CHANGED
@@ -28,6 +28,11 @@ export interface FakeCall {
28
28
  job: string | null;
29
29
  promptVersion: string | null;
30
30
  subject: string | null;
31
+ /** False when the call was marked do-not-keep (X-Workbench-Keep: no, docs/spec.md §2.9). */
32
+ keep: boolean;
33
+ /** The Workbench voice and spelling named (X-Workbench-Voice, X-Workbench-Spelling). */
34
+ voice: string | null;
35
+ spelling: string | null;
31
36
  /** The parsed JSON body (Anthropic Messages request), if any. */
32
37
  body: Record<string, unknown> | null;
33
38
  stream: boolean;
package/dist/fake.js CHANGED
@@ -90,6 +90,9 @@ export function fakeGateway(script = {}) {
90
90
  job: req.headers.get("x-workbench-job"),
91
91
  promptVersion: req.headers.get("x-workbench-prompt-version"),
92
92
  subject: req.headers.get("x-workbench-subject"),
93
+ keep: req.headers.get("x-workbench-keep")?.toLowerCase() !== "no",
94
+ voice: req.headers.get("x-workbench-voice"),
95
+ spelling: req.headers.get("x-workbench-spelling"),
93
96
  body,
94
97
  stream: body?.["stream"] === true,
95
98
  };
@@ -113,6 +116,19 @@ export function fakeGateway(script = {}) {
113
116
  return fakeImage(script, body, requestId);
114
117
  if (url.pathname === "/v1/images/search")
115
118
  return fakeSearch(script, body, requestId);
119
+ if (url.pathname.startsWith("/anthropic/")) {
120
+ // As the Gateway does: an unknown name is refused, and a spelling is read in any case.
121
+ const allowed = capabilities.ai.allowed;
122
+ if (call.voice !== null && !allowed.voices.some((v) => v.name === call.voice)) {
123
+ return anthropicError(400, requestId, "invalid_request", `X-Workbench-Voice must be one of ${allowed.voices.map((v) => v.name).join(", ")}.`);
124
+ }
125
+ if (call.spelling !== null) {
126
+ const named = allowed.spellings.find((x) => x.toLowerCase() === call.spelling.toLowerCase());
127
+ if (named === undefined)
128
+ return anthropicError(400, requestId, "invalid_request", `X-Workbench-Spelling must be one of ${allowed.spellings.join(", ")}.`);
129
+ call.spelling = named;
130
+ }
131
+ }
116
132
  if (url.pathname === "/anthropic/v1/messages/count_tokens") {
117
133
  return json(200, requestId, { input_tokens: script.inputTokens ?? 12 });
118
134
  }
@@ -164,6 +180,9 @@ export function fakeGateway(script = {}) {
164
180
  cost_usd: reply.costUsd ?? 0.0001,
165
181
  refused: stopReason === "refusal",
166
182
  attempts: 1,
183
+ ...(call.voice !== null || call.spelling !== null
184
+ ? { voice: { name: call.voice, spelling: call.spelling, version: capabilities.ai.allowed.writing.voice_set_version } }
185
+ : {}),
167
186
  };
168
187
  if (call.stream)
169
188
  return sse(requestId, message, meta);
package/dist/images.js CHANGED
@@ -17,6 +17,8 @@ function headers(req) {
17
17
  throw new WorkbenchAiError("invalid_request", "subject must be type:id");
18
18
  h["X-Workbench-Subject"] = req.subject;
19
19
  }
20
+ if (req.keep === false)
21
+ h["X-Workbench-Keep"] = "no";
20
22
  return h;
21
23
  }
22
24
  function toBase64(bytes) {
package/dist/index.d.ts CHANGED
@@ -4,4 +4,5 @@ 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 type { Ai, AiImages, AiOptions, AspectRatio, GeneratedImage, ImageGenerateRequest, ImageSearchRequest, ImageSearchResult, ImageSearchResults, AiStream, BudgetStatus, ContentBlockParam, CountTokensRequest, Effort, MessageStreamEvent, Provenance, StructuredRequest, StructuredResult, SystemBlock, TextRequest, TextResult, Usage, ZodLike, } from "./types.ts";
7
+ 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
+ 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,3 +4,4 @@ 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 { checkWriting, paragraphBands, paragraphTarget, paragraphsOf, writingFixNote, writingNotes, writingStops, SPELLING_NAMES, VOICE_NAMES, } from "./writing.js";
package/dist/types.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type Anthropic from "@anthropic-ai/sdk";
2
2
  import type { WorkbenchEnv } from "@mccaskie-labs/workbench-config";
3
+ import type { OwnVoice, SpellingName, VoiceName } from "./writing.ts";
3
4
  export type SystemBlock = Anthropic.TextBlockParam;
4
5
  export type ContentBlockParam = Anthropic.ContentBlockParam;
5
6
  export type MessageStreamEvent = Anthropic.MessageStreamEvent;
@@ -24,8 +25,26 @@ export interface TextRequest {
24
25
  maxTokens?: number;
25
26
  effort?: Effort;
26
27
  promptVersion?: string;
27
- /** `type:id` of the thing the call is about. */
28
+ /**
29
+ * How the text reads (docs/spec.md §2.8). A name asks the Gateway to add Workbench's house writing
30
+ * standards and that voice ahead of `system`, the same bytes for every app, so they are cached and
31
+ * shared. `{ instructions }` is the app's own voice: it goes first in the app's own system prompt and
32
+ * no Workbench writing rule is added. Left out, nothing is added.
33
+ */
34
+ voice?: VoiceName | OwnVoice;
35
+ /** The spelling to write in. The Gateway adds one line for it. Separate from the voice. */
36
+ spelling?: SpellingName;
37
+ /**
38
+ * `type:id` of the thing the call is about. An app with signed-in users sends `user:<its own user id>`
39
+ * (never a name, email address or phone number), so a person's kept calls can be found and deleted.
40
+ */
28
41
  subject?: string;
42
+ /**
43
+ * False marks this call do-not-keep (`X-Workbench-Keep: no`): the Gateway keeps its facts but
44
+ * not its text (docs/spec.md §2.9). A refused call is kept whatever this says. Left out, the
45
+ * project's own setting decides.
46
+ */
47
+ keep?: boolean;
29
48
  }
30
49
  export interface StructuredRequest<T> extends TextRequest {
31
50
  /** A zod schema. Its JSON Schema is sent as `output_config.format`; the reply is validated against it. */
@@ -59,6 +78,14 @@ export interface TextResult {
59
78
  requestId: string;
60
79
  usage: Usage;
61
80
  costUsd: number;
81
+ /** The Workbench voice and spelling the Gateway added, and the version of that text. Absent when none was named. */
82
+ voice?: VoiceUsed;
83
+ }
84
+ /** What wrote the text: store it with the output, beside the model. */
85
+ export interface VoiceUsed {
86
+ name: VoiceName | null;
87
+ spelling: SpellingName | null;
88
+ version: number;
62
89
  }
63
90
  export interface StructuredResult<T> {
64
91
  data: T;
@@ -72,6 +99,9 @@ export interface CountTokensRequest {
72
99
  job: string;
73
100
  system?: string | SystemBlock[];
74
101
  prompt: string | ContentBlockParam[];
102
+ /** As on a text call, so the count includes what the Gateway adds. */
103
+ voice?: VoiceName | OwnVoice;
104
+ spelling?: SpellingName;
75
105
  }
76
106
  export interface BudgetStatus {
77
107
  /** `ok` below the warning level, `warn` at or above it, `reached` at or above the cap. */
@@ -116,6 +146,12 @@ export interface ImageGenerateRequest {
116
146
  }[];
117
147
  promptVersion?: string;
118
148
  subject?: string;
149
+ /**
150
+ * False marks this call do-not-keep (`X-Workbench-Keep: no`): the Gateway keeps its facts but
151
+ * not its text (docs/spec.md §2.9). A refused call is kept whatever this says. Left out, the
152
+ * project's own setting decides.
153
+ */
154
+ keep?: boolean;
119
155
  }
120
156
  export interface GeneratedImage {
121
157
  bytes: Uint8Array;
@@ -136,6 +172,12 @@ export interface ImageSearchRequest {
136
172
  /** Language, e.g. `en`. */
137
173
  hl?: string;
138
174
  subject?: string;
175
+ /**
176
+ * False marks this call do-not-keep (`X-Workbench-Keep: no`): the Gateway keeps its facts but
177
+ * not its text (docs/spec.md §2.9). A refused call is kept whatever this says. Left out, the
178
+ * project's own setting decides.
179
+ */
180
+ keep?: boolean;
139
181
  }
140
182
  export interface ImageSearchResult {
141
183
  title: string;
@@ -0,0 +1,104 @@
1
+ declare const allowed: {
2
+ readonly aliases: readonly [{
3
+ readonly alias: "claude-default";
4
+ readonly provider: "anthropic";
5
+ readonly default_model: "claude-sonnet-5-5";
6
+ }, {
7
+ readonly alias: "claude-best";
8
+ readonly provider: "anthropic";
9
+ readonly default_model: "claude-opus-5-5";
10
+ }, {
11
+ readonly alias: "claude-fast";
12
+ readonly provider: "anthropic";
13
+ readonly default_model: "claude-haiku-5-5";
14
+ }];
15
+ readonly explicit_models: readonly ["claude-sonnet-5", "claude-opus-5"];
16
+ readonly server_tools: readonly ["web_search", "web_fetch", "code_execution"];
17
+ readonly required_headers: readonly ["X-Workbench-Job"];
18
+ readonly optional_headers: readonly ["X-Workbench-Voice", "X-Workbench-Spelling"];
19
+ readonly voices: readonly [{
20
+ readonly name: "casual";
21
+ readonly paragraph_target: 50;
22
+ readonly sentence_target: 18;
23
+ }, {
24
+ readonly name: "conversational";
25
+ readonly paragraph_target: 60;
26
+ readonly sentence_target: 22;
27
+ }, {
28
+ readonly name: "plain";
29
+ readonly paragraph_target: 60;
30
+ readonly sentence_target: 20;
31
+ }, {
32
+ readonly name: "formal";
33
+ readonly paragraph_target: 80;
34
+ readonly sentence_target: 25;
35
+ }, {
36
+ readonly name: "strict-formal";
37
+ readonly paragraph_target: 80;
38
+ readonly sentence_target: 28;
39
+ }];
40
+ readonly spellings: readonly ["en-AU", "en-PH", "en-GB", "en-US"];
41
+ readonly writing: {
42
+ readonly voice_set_version: 1;
43
+ readonly house_paragraph_target: 80;
44
+ readonly paragraph_grace: 1.25;
45
+ readonly paragraph_ceiling: 2;
46
+ readonly banned_phrases: readonly ["delve", "it's worth noting", "it is worth noting", "it's important to note", "it is important to note", "in today's fast-paced world", "navigate the complexities", "a testament to", "game-changer", "game-changing", "let's dive in", "dive into", "rest assured", "in conclusion", "at the end of the day", "the world of", "unlock the", "embark on", "tapestry"];
47
+ };
48
+ };
49
+ export type VoiceName = (typeof allowed.voices)[number]["name"];
50
+ export type SpellingName = (typeof allowed.spellings)[number];
51
+ /** The Workbench voices an app can name. */
52
+ export declare const VOICE_NAMES: readonly VoiceName[];
53
+ /** The spellings an app can name. */
54
+ export declare const SPELLING_NAMES: readonly SpellingName[];
55
+ /** An app's own voice: its instructions go in the app's own system prompt, and no Workbench writing rule is added. */
56
+ export interface OwnVoice {
57
+ instructions: string;
58
+ }
59
+ export interface WritingIssue {
60
+ /** `stop`: the text breaks a hard limit, so fix or retry it. `note`: worth showing, never a reason to retry. */
61
+ severity: "stop" | "note";
62
+ rule: "long-dash" | "banned-phrase" | "paragraph-length";
63
+ issue: string;
64
+ /** A short quote from the text, where one helps. */
65
+ location?: string;
66
+ suggestion?: string;
67
+ }
68
+ export interface CheckWritingOptions {
69
+ /** The voice the text was written in. Sets the paragraph target; left out, the house target applies. */
70
+ voice?: VoiceName;
71
+ /** Overrides the paragraph target, in words. */
72
+ paragraphTarget?: number;
73
+ /** Required wording (a disclaimer, a quotation) that must be copied exactly, so it is left out of the check. */
74
+ exempt?: string[];
75
+ }
76
+ /** The word counts for a paragraph target: the target, where a note starts (above `grace`) and where a stop starts (above `ceiling`). */
77
+ export declare function paragraphBands(target: number): {
78
+ target: number;
79
+ grace: number;
80
+ ceiling: number;
81
+ };
82
+ /** The paragraph target for a voice, in words. Never above the house target. */
83
+ export declare function paragraphTarget(voice?: VoiceName): number;
84
+ /** The issues that are stops. */
85
+ export declare const writingStops: (issues: readonly WritingIssue[]) => WritingIssue[];
86
+ /** The notes only. */
87
+ export declare const writingNotes: (issues: readonly WritingIssue[]) => WritingIssue[];
88
+ /**
89
+ * A Markdown text's paragraphs and list items, without headings, tables and code. As in Markdown, a
90
+ * blank line ends a paragraph and a single line break doesn't.
91
+ */
92
+ export declare function paragraphsOf(text: string): string[];
93
+ /**
94
+ * Checks text written for a reader against the Workbench house standards and
95
+ * a voice's paragraph target. Makes no AI call. An app that brought its own
96
+ * voice has its own rules, so this check isn't for its text.
97
+ */
98
+ export declare function checkWriting(text: string, opts?: CheckWritingOptions): WritingIssue[];
99
+ /**
100
+ * Text to add to a prompt that is asked once more because its answer broke the house standards.
101
+ * Only the stops are listed: a note is never a reason to ask again. Empty when there is no stop.
102
+ */
103
+ export declare function writingFixNote(issues: readonly WritingIssue[]): string;
104
+ export {};
@@ -0,0 +1,209 @@
1
+ // The writing check, with no AI (docs/spec.md §2.8): does a text keep the
2
+ // Workbench house standards and its voice's targets? Brought over from
3
+ // Imprint's checkWritingStyle. The names and figures come from the manifest
4
+ // (capabilities/ai.json), which the Gateway's own text is tested against, so
5
+ // what the model was told and what is checked here can't drift apart.
6
+ //
7
+ // Two things are hard limits: long dashes and the banned phrases. Paragraph
8
+ // length is a target (Robert, 11 November 2026, as in Imprint): up to target x
9
+ // grace passes with no note, up to target x ceiling passes with a note, and
10
+ // only past the ceiling is it a stop. A long sentence is never an issue.
11
+ import { capabilities } from "@mccaskie-labs/workbench-config";
12
+ const allowed = capabilities.ai.allowed;
13
+ const writing = allowed.writing;
14
+ /** The Workbench voices an app can name. */
15
+ export const VOICE_NAMES = allowed.voices.map((v) => v.name);
16
+ /** The spellings an app can name. */
17
+ export const SPELLING_NAMES = [...allowed.spellings];
18
+ /** The word counts for a paragraph target: the target, where a note starts (above `grace`) and where a stop starts (above `ceiling`). */
19
+ export function paragraphBands(target) {
20
+ return { target, grace: Math.round(target * writing.paragraph_grace), ceiling: Math.round(target * writing.paragraph_ceiling) };
21
+ }
22
+ /** The paragraph target for a voice, in words. Never above the house target. */
23
+ export function paragraphTarget(voice) {
24
+ const v = allowed.voices.find((x) => x.name === voice);
25
+ return Math.min(v?.paragraph_target ?? writing.house_paragraph_target, writing.house_paragraph_target);
26
+ }
27
+ /** The issues that are stops. */
28
+ export const writingStops = (issues) => issues.filter((i) => i.severity === "stop");
29
+ /** The notes only. */
30
+ export const writingNotes = (issues) => issues.filter((i) => i.severity === "note");
31
+ const LONG_DASH_RE = /—|\s–\s|\s-\s/g;
32
+ /** A spaced hyphen between two numbers is arithmetic, not punctuation. */
33
+ const BETWEEN_NUMBERS_RE = /\d\s-\s\d/;
34
+ const MOST = 6;
35
+ function quoteAround(text, index) {
36
+ const start = Math.max(0, index - 40);
37
+ const end = Math.min(text.length, index + 40);
38
+ return (start > 0 ? "…" : "") + text.slice(start, end).replace(/\s+/g, " ").trim() + (end < text.length ? "…" : "");
39
+ }
40
+ function firstWords(text, n = 12) {
41
+ const words = text.replace(/\s+/g, " ").trim().split(" ");
42
+ return words.slice(0, n).join(" ") + (words.length > n ? "…" : "");
43
+ }
44
+ function wordCount(text) {
45
+ const t = text.replace(/[*_`#>[\]()]/g, " ").trim();
46
+ return t ? t.split(/\s+/).length : 0;
47
+ }
48
+ /**
49
+ * A Markdown text's paragraphs and list items, without headings, tables and code. As in Markdown, a
50
+ * blank line ends a paragraph and a single line break doesn't.
51
+ */
52
+ export function paragraphsOf(text) {
53
+ const out = [];
54
+ let inCode = false;
55
+ let current = [];
56
+ const flush = () => {
57
+ if (current.length)
58
+ out.push(current.join(" ").trim());
59
+ current = [];
60
+ };
61
+ for (const raw of text.split(/\r?\n/)) {
62
+ const line = raw.trim();
63
+ if (line.startsWith("```")) {
64
+ flush();
65
+ inCode = !inCode;
66
+ continue;
67
+ }
68
+ if (inCode)
69
+ continue;
70
+ if (!line || /^#{1,6}\s/.test(line) || line.startsWith("|") || /^(-{3,}|\*{3,})$/.test(line)) {
71
+ flush();
72
+ continue;
73
+ }
74
+ if (/^([-*+]|\d+[.)])\s+/.test(line)) {
75
+ flush();
76
+ current.push(line.replace(/^([-*+]|\d+[.)])\s+/, ""));
77
+ continue;
78
+ }
79
+ current.push(line.replace(/^>\s?/, ""));
80
+ }
81
+ flush();
82
+ return out.filter(Boolean);
83
+ }
84
+ function paragraphIssues(paragraphs, target) {
85
+ const { grace, ceiling } = paragraphBands(target);
86
+ const counted = paragraphs.map((p) => ({ p, words: wordCount(p) }));
87
+ const stops = counted.filter((x) => x.words > ceiling);
88
+ const notes = counted.filter((x) => x.words > grace && x.words <= ceiling);
89
+ const out = [];
90
+ for (const x of stops.slice(0, MOST)) {
91
+ out.push({
92
+ severity: "stop",
93
+ rule: "paragraph-length",
94
+ issue: `A paragraph has ${x.words} words; the limit is ${ceiling} (aim for ${target}).`,
95
+ location: `"${firstWords(x.p)}"`,
96
+ suggestion: "Cut filler and repetition first. If it is still long, split it into two paragraphs with one idea each.",
97
+ });
98
+ }
99
+ if (stops.length > MOST) {
100
+ out.push({
101
+ severity: "stop",
102
+ rule: "paragraph-length",
103
+ issue: `${stops.length - MOST} more paragraphs are over the ${ceiling}-word limit (aim for ${target}).`,
104
+ suggestion: "Shorten every paragraph over the limit.",
105
+ });
106
+ }
107
+ for (const x of notes.slice(0, MOST)) {
108
+ out.push({
109
+ severity: "note",
110
+ rule: "paragraph-length",
111
+ issue: `Long paragraph: ${x.words} words, and the target is ${target}. Consider splitting it.`,
112
+ location: `"${firstWords(x.p)}"`,
113
+ suggestion: "Two paragraphs with one idea each read more easily.",
114
+ });
115
+ }
116
+ if (notes.length > MOST) {
117
+ out.push({ severity: "note", rule: "paragraph-length", issue: `${notes.length - MOST} more long paragraphs are over the ${target}-word target. Consider splitting them.` });
118
+ }
119
+ return out;
120
+ }
121
+ /**
122
+ * The prose of a Markdown text, for the dash and phrase checks: fenced code, inline code and table
123
+ * rows are taken out (the standards don't apply to code or data), quote marks and list markers are
124
+ * dropped from the start of each line, and a typographic apostrophe reads as a plain one.
125
+ */
126
+ function proseOf(text) {
127
+ const out = [];
128
+ let inCode = false;
129
+ for (const raw of text.split(/\r?\n/)) {
130
+ const line = raw.trim();
131
+ if (line.startsWith("```")) {
132
+ inCode = !inCode;
133
+ out.push("");
134
+ continue;
135
+ }
136
+ if (inCode || line.startsWith("|")) {
137
+ out.push("");
138
+ continue;
139
+ }
140
+ out.push(line
141
+ .replace(/^(>\s?)+/, "")
142
+ .replace(/^(([-*+]|\d+[.)])\s+)+/, "")
143
+ .replace(/`[^`]*`/g, " ")
144
+ .replace(/[’‘]/g, "'"));
145
+ }
146
+ return out.join("\n");
147
+ }
148
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
149
+ /**
150
+ * Checks text written for a reader against the Workbench house standards and
151
+ * a voice's paragraph target. Makes no AI call. An app that brought its own
152
+ * voice has its own rules, so this check isn't for its text.
153
+ */
154
+ export function checkWriting(text, opts = {}) {
155
+ const body = (opts.exempt ?? []).reduce((acc, e) => (e ? acc.split(e).join(" ") : acc), text);
156
+ const prose = proseOf(body);
157
+ const issues = [];
158
+ const quotes = [];
159
+ let dashes = 0;
160
+ for (const m of prose.matchAll(LONG_DASH_RE)) {
161
+ if (m[0] !== "—") {
162
+ // A dash that starts its line is a list marker, and one between two numbers is a minus sign.
163
+ const dashAt = m.index + m[0].search(/[–-]/);
164
+ const lineStart = prose.lastIndexOf("\n", dashAt - 1) + 1;
165
+ if (prose.slice(lineStart, dashAt).trim() === "")
166
+ continue;
167
+ if (BETWEEN_NUMBERS_RE.test(prose.slice(Math.max(0, m.index - 1), m.index + m[0].length + 1)))
168
+ continue;
169
+ }
170
+ dashes++;
171
+ if (quotes.length < 3)
172
+ quotes.push(quoteAround(prose, m.index));
173
+ }
174
+ if (dashes > 0) {
175
+ issues.push({
176
+ severity: "stop",
177
+ rule: "long-dash",
178
+ issue: `The text uses ${dashes} long or spaced dash${dashes === 1 ? "" : "es"} (— or " - ") as punctuation, which the house standards ban.`,
179
+ location: quotes.map((q) => `"${q}"`).join(" / "),
180
+ suggestion: "Rewrite each one with a full stop, comma, colon or brackets. Remove every one.",
181
+ });
182
+ }
183
+ const target = opts.paragraphTarget !== undefined && opts.paragraphTarget >= 1 ? opts.paragraphTarget : paragraphTarget(opts.voice);
184
+ issues.push(...paragraphIssues(paragraphsOf(body), target));
185
+ for (const phrase of writing.banned_phrases) {
186
+ const m = new RegExp(`(^|[^\\p{L}])${escapeRe(phrase)}(?=$|[^\\p{L}])`, "iu").exec(prose);
187
+ if (m) {
188
+ issues.push({
189
+ severity: "stop",
190
+ rule: "banned-phrase",
191
+ issue: `Uses the banned phrase "${phrase}".`,
192
+ location: `"${quoteAround(prose, m.index + m[1].length)}"`,
193
+ suggestion: "Rewrite the sentence without it, or cut the sentence if it adds nothing.",
194
+ });
195
+ }
196
+ }
197
+ return issues;
198
+ }
199
+ /**
200
+ * Text to add to a prompt that is asked once more because its answer broke the house standards.
201
+ * Only the stops are listed: a note is never a reason to ask again. Empty when there is no stop.
202
+ */
203
+ export function writingFixNote(issues) {
204
+ const stops = writingStops(issues);
205
+ if (stops.length === 0)
206
+ return "";
207
+ const lines = stops.map((i) => `- ${i.issue}${i.location ? ` (at: ${i.location})` : ""}${i.suggestion ? ` Fix: ${i.suggestion}` : ""}`);
208
+ return `\n\nIMPORTANT: a previous answer to this request broke the house writing standards:\n${lines.join("\n")}\nWrite the whole answer again with every one of these fixed. Change nothing else.`;
209
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mccaskie-labs/workbench-ai",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Claude through the Workbench Gateway: text, structured output, streaming and budget status, with no Anthropic key in the app. Refusals and truncations throw; createFakeAi for tests.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@anthropic-ai/sdk": "^1.5.0",
39
- "@mccaskie-labs/workbench-config": "^0.1.2"
39
+ "@mccaskie-labs/workbench-config": "^0.1.14"
40
40
  },
41
41
  "peerDependencies": {
42
42
  "zod": "^4.1.0"