@avocadostudio-ai/orchestrator-core 0.22.0 → 0.23.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.
Files changed (75) hide show
  1. package/dist/agent/agent-loop-openai.js +3 -1
  2. package/dist/agent/agent-provider.js +2 -2
  3. package/dist/agents/backtest.d.ts +35 -0
  4. package/dist/agents/backtest.js +92 -0
  5. package/dist/agents/builtins.d.ts +21 -0
  6. package/dist/agents/builtins.js +102 -0
  7. package/dist/agents/draft-spec.d.ts +44 -0
  8. package/dist/agents/draft-spec.js +195 -0
  9. package/dist/agents/edit-safety-resolve.d.ts +70 -0
  10. package/dist/agents/edit-safety-resolve.js +195 -0
  11. package/dist/agents/edit-safety-runner.d.ts +68 -0
  12. package/dist/agents/edit-safety-runner.js +156 -0
  13. package/dist/agents/edit-safety.d.ts +82 -0
  14. package/dist/agents/edit-safety.js +425 -0
  15. package/dist/agents/fix-writer.d.ts +38 -0
  16. package/dist/agents/fix-writer.js +197 -0
  17. package/dist/agents/inbox.d.ts +141 -0
  18. package/dist/agents/inbox.js +360 -0
  19. package/dist/agents/learning.d.ts +32 -0
  20. package/dist/agents/learning.js +71 -0
  21. package/dist/agents/mention.d.ts +76 -0
  22. package/dist/agents/mention.js +161 -0
  23. package/dist/agents/run-spec.d.ts +27 -0
  24. package/dist/agents/run-spec.js +82 -0
  25. package/dist/agents/schedule.d.ts +22 -0
  26. package/dist/agents/schedule.js +53 -0
  27. package/dist/agents/settings.d.ts +30 -0
  28. package/dist/agents/settings.js +64 -0
  29. package/dist/agents/spec.d.ts +84 -0
  30. package/dist/agents/spec.js +70 -0
  31. package/dist/agents/stats.d.ts +15 -0
  32. package/dist/agents/stats.js +48 -0
  33. package/dist/agents/templates.d.ts +34 -0
  34. package/dist/agents/templates.js +268 -0
  35. package/dist/agents/tick.d.ts +59 -0
  36. package/dist/agents/tick.js +161 -0
  37. package/dist/chat/anthropic-model-caps.d.ts +19 -0
  38. package/dist/chat/anthropic-model-caps.js +56 -0
  39. package/dist/chat/anthropic-planner.d.ts +1 -0
  40. package/dist/chat/anthropic-planner.js +72 -15
  41. package/dist/chat/chat-pipeline.js +54 -1
  42. package/dist/chat/model-defaults.js +6 -3
  43. package/dist/checks/rules-draft.d.ts +6 -1
  44. package/dist/checks/rules-draft.js +10 -7
  45. package/dist/checks/rules-i18n.d.ts +25 -0
  46. package/dist/checks/rules-i18n.js +193 -0
  47. package/dist/checks/run-checks.d.ts +16 -1
  48. package/dist/checks/run-checks.js +80 -63
  49. package/dist/checks/session-runner.js +7 -0
  50. package/dist/checks/types.d.ts +6 -0
  51. package/dist/durable/in-memory-durable-store.d.ts +34 -1
  52. package/dist/durable/in-memory-durable-store.js +122 -1
  53. package/dist/durable/index.d.ts +1 -1
  54. package/dist/durable/sqlite-durable-store.d.ts +19 -1
  55. package/dist/durable/sqlite-durable-store.js +220 -1
  56. package/dist/durable/types.d.ts +146 -0
  57. package/dist/handler/create-orchestrator.js +191 -1
  58. package/dist/http/history-actions.d.ts +22 -0
  59. package/dist/http/history-actions.js +33 -5
  60. package/dist/http/inbox-actions.d.ts +156 -0
  61. package/dist/http/inbox-actions.js +498 -0
  62. package/dist/http/ops-actions.d.ts +17 -1
  63. package/dist/http/ops-actions.js +36 -0
  64. package/dist/http/variation-preview-actions.d.ts +30 -0
  65. package/dist/http/variation-preview-actions.js +75 -0
  66. package/dist/index.d.ts +10 -1
  67. package/dist/index.js +9 -0
  68. package/dist/nlp/deterministic-planner.d.ts +1 -1
  69. package/dist/nlp/intent-detection.d.ts +15 -0
  70. package/dist/nlp/intent-detection.js +5 -0
  71. package/dist/state/session-state.d.ts +11 -3
  72. package/dist/state/session-state.js +8 -0
  73. package/dist/state/sqlite-store.d.ts +12 -0
  74. package/dist/telemetry/usage.js +4 -0
  75. package/package.json +3 -3
@@ -0,0 +1,425 @@
1
+ import { fieldText } from "../checks/field-walk.js";
2
+ function isRecord(value) {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4
+ }
5
+ /**
6
+ * Whether a value holds something a reader would miss.
7
+ *
8
+ * Booleans never count: `false` is a setting, not an absence. Objects count
9
+ * when any string or number inside them does, so an image `{ src: "" }` is
10
+ * empty and a rich-text document with one word is not.
11
+ */
12
+ export function hasContent(value) {
13
+ if (value == null)
14
+ return false;
15
+ if (typeof value === "string")
16
+ return value.trim().length > 0;
17
+ if (typeof value === "number")
18
+ return Number.isFinite(value);
19
+ if (typeof value === "boolean")
20
+ return false;
21
+ if (Array.isArray(value))
22
+ return value.some((entry) => hasContent(entry) || isRecord(entry));
23
+ if (isRecord(value)) {
24
+ if (fieldText(value).trim().length > 0)
25
+ return true;
26
+ return Object.entries(value).some(([key, entry]) => key !== "id" && key !== "type" && hasContent(entry));
27
+ }
28
+ return false;
29
+ }
30
+ function shapeOf(value) {
31
+ if (typeof value === "string")
32
+ return "text";
33
+ if (typeof value === "number")
34
+ return "number";
35
+ if (Array.isArray(value))
36
+ return "list";
37
+ if (isRecord(value))
38
+ return "object";
39
+ return "other";
40
+ }
41
+ function definitionFor(manifest, type) {
42
+ return manifest.blocks.find((b) => b.type === type);
43
+ }
44
+ const BLOCK_LABEL_MAX = 48;
45
+ /** What a reader calls a block: its first non-empty text field. Mirrors field-walk. */
46
+ function labelFor(block, definition) {
47
+ const props = isRecord(block.props) ? block.props : {};
48
+ const keys = definition
49
+ ? Object.entries(definition.fields ?? {})
50
+ .filter(([, meta]) => meta.kind === "text")
51
+ .map(([key]) => key)
52
+ : Object.keys(props);
53
+ for (const key of keys) {
54
+ const value = props[key];
55
+ if (typeof value !== "string")
56
+ continue;
57
+ const trimmed = value.trim();
58
+ if (!trimmed || /^https?:\/\//.test(trimmed))
59
+ continue;
60
+ return trimmed.length > BLOCK_LABEL_MAX ? `${trimmed.slice(0, BLOCK_LABEL_MAX - 1)}…` : trimmed;
61
+ }
62
+ return undefined;
63
+ }
64
+ function itemFieldsFor(list, item) {
65
+ const merged = (list.itemFields ?? {});
66
+ if (!list.discriminator || !list.itemFieldsByType || !isRecord(item))
67
+ return merged;
68
+ const branch = item[list.discriminator];
69
+ if (typeof branch !== "string")
70
+ return merged;
71
+ return list.itemFieldsByType[branch] ?? merged;
72
+ }
73
+ /**
74
+ * Pair a list's items before and after the edit.
75
+ *
76
+ * By `id` when every item has one — the only pairing that survives an insert
77
+ * or a reorder. By position only when the list kept its length; otherwise a
78
+ * removed item would pair every later item with its neighbour and report
79
+ * fields as emptied that simply moved up.
80
+ */
81
+ function pairItems(before, after) {
82
+ const idOf = (item) => (isRecord(item) && typeof item.id === "string" && item.id ? item.id : undefined);
83
+ const allHaveIds = before.every((item) => idOf(item)) && after.every((item) => idOf(item));
84
+ if (allHaveIds) {
85
+ const afterIndexById = new Map(after.map((item, index) => [idOf(item), index]));
86
+ const out = [];
87
+ for (const item of before) {
88
+ const id = idOf(item);
89
+ const index = afterIndexById.get(id);
90
+ if (index === undefined)
91
+ continue;
92
+ out.push({ before: item, after: after[index], afterIndex: index, itemId: id });
93
+ }
94
+ return out;
95
+ }
96
+ if (before.length !== after.length)
97
+ return [];
98
+ return before.map((item, index) => ({ before: item, after: after[index], afterIndex: index }));
99
+ }
100
+ function fieldChange(before, after, kind) {
101
+ if (!hasContent(before))
102
+ return null;
103
+ if (!hasContent(after))
104
+ return "emptied";
105
+ if (kind === "richtext")
106
+ return null;
107
+ const from = shapeOf(before);
108
+ const to = shapeOf(after);
109
+ if (from === to)
110
+ return null;
111
+ // Only a change a reader would notice: a list or an object collapsing into a
112
+ // scalar, or the reverse. A number stored as text is the same value.
113
+ const structural = (shape) => shape === "list" || shape === "object";
114
+ return structural(from) !== structural(to) ? "type_changed" : null;
115
+ }
116
+ function blockIssues(slug, before, after, manifest, ops) {
117
+ const definition = definitionFor(manifest, after.type) ?? definitionFor(manifest, before.type);
118
+ const beforeProps = isRecord(before.props) ? before.props : {};
119
+ const afterProps = isRecord(after.props) ? after.props : {};
120
+ const blockLabel = labelFor(before, definition);
121
+ const located = {
122
+ slug,
123
+ blockId: after.id,
124
+ blockType: after.type,
125
+ ...(blockLabel ? { blockLabel } : {})
126
+ };
127
+ const out = [];
128
+ const topLevel = definition
129
+ ? Object.entries(definition.fields ?? {}).map(([key, meta]) => [key, meta])
130
+ : Object.keys(beforeProps)
131
+ .filter((key) => key !== "id")
132
+ .map((key) => [key, undefined]);
133
+ const listKeys = new Set(Object.keys(definition?.listFields ?? {}));
134
+ for (const [key, meta] of topLevel) {
135
+ if (listKeys.has(key))
136
+ continue;
137
+ const change = fieldChange(beforeProps[key], afterProps[key], meta?.kind);
138
+ if (!change)
139
+ continue;
140
+ out.push({
141
+ kind: change,
142
+ ...located,
143
+ path: key,
144
+ ...(meta?.label ? { label: meta.label } : {}),
145
+ before: beforeProps[key],
146
+ after: afterProps[key] ?? null,
147
+ revert: { op: "update_props", pageSlug: slug, blockId: after.id, patch: { [key]: beforeProps[key] } }
148
+ });
149
+ }
150
+ for (const [listKey, list] of Object.entries(definition?.listFields ?? {})) {
151
+ const beforeItems = beforeProps[listKey];
152
+ const afterItems = afterProps[listKey];
153
+ if (!Array.isArray(beforeItems) || beforeItems.length === 0)
154
+ continue;
155
+ if (!Array.isArray(afterItems) || afterItems.length === 0) {
156
+ // The whole list went. One finding for it, not one per field inside it.
157
+ out.push({
158
+ kind: Array.isArray(afterItems) || afterItems == null ? "emptied" : "type_changed",
159
+ ...located,
160
+ path: listKey,
161
+ ...(list.label ? { label: list.label } : {}),
162
+ before: beforeItems,
163
+ after: afterItems ?? null,
164
+ revert: { op: "update_props", pageSlug: slug, blockId: after.id, patch: { [listKey]: beforeItems } }
165
+ });
166
+ continue;
167
+ }
168
+ out.push(...droppedItems(slug, located, listKey, list.label, beforeItems, afterItems, ops));
169
+ for (const pair of pairItems(beforeItems, afterItems)) {
170
+ if (!isRecord(pair.before))
171
+ continue;
172
+ const afterItem = isRecord(pair.after) ? pair.after : {};
173
+ for (const [itemKey, meta] of Object.entries(itemFieldsFor(list, pair.before))) {
174
+ const change = fieldChange(pair.before[itemKey], afterItem[itemKey], meta.kind);
175
+ if (!change)
176
+ continue;
177
+ out.push({
178
+ kind: change,
179
+ ...located,
180
+ path: `${listKey}[${pair.afterIndex}].${itemKey}`,
181
+ ...(meta.label ? { label: meta.label } : {}),
182
+ before: pair.before[itemKey],
183
+ after: afterItem[itemKey] ?? null,
184
+ revert: {
185
+ op: "update_item",
186
+ pageSlug: slug,
187
+ blockId: after.id,
188
+ listKey,
189
+ ...(pair.itemId ? { itemId: pair.itemId } : { index: pair.afterIndex }),
190
+ patch: { [itemKey]: pair.before[itemKey] }
191
+ }
192
+ });
193
+ }
194
+ }
195
+ }
196
+ return out;
197
+ }
198
+ function itemIdOf(item) {
199
+ return isRecord(item) && typeof item.id === "string" && item.id ? item.id : undefined;
200
+ }
201
+ /**
202
+ * Entries that left a list without anyone removing them. Only reported when
203
+ * every entry carries an id — without ids a removal and a reorder cannot be
204
+ * told apart — and never for a list the batch explicitly removed from.
205
+ */
206
+ function droppedItems(slug, located, listKey, listLabel, beforeItems, afterItems, ops) {
207
+ const removedOnPurpose = ops.some((op) => op.op === "remove_item" && op.pageSlug === slug && op.blockId === located.blockId && op.listKey === listKey);
208
+ if (removedOnPurpose)
209
+ return [];
210
+ if (!beforeItems.every(itemIdOf) || !afterItems.every(itemIdOf))
211
+ return [];
212
+ const surviving = new Set(afterItems.map((item) => itemIdOf(item)));
213
+ const out = [];
214
+ beforeItems.forEach((item, index) => {
215
+ const itemId = itemIdOf(item);
216
+ if (surviving.has(itemId) || !hasContent(item))
217
+ return;
218
+ const anchors = beforeItems
219
+ .slice(0, index)
220
+ .reverse()
221
+ .map((earlier) => itemIdOf(earlier));
222
+ const anchor = anchors.find((id) => surviving.has(id));
223
+ out.push({
224
+ kind: "item_dropped",
225
+ ...located,
226
+ path: `${listKey}[${index}]`,
227
+ ...(listLabel ? { label: listLabel } : {}),
228
+ listKey,
229
+ itemId,
230
+ before: item,
231
+ after: null,
232
+ anchors,
233
+ revert: {
234
+ op: "add_item",
235
+ pageSlug: located.slug,
236
+ blockId: located.blockId,
237
+ listKey,
238
+ item: structuredClone(item),
239
+ ...(anchor ? { afterItemId: anchor } : {})
240
+ }
241
+ });
242
+ });
243
+ return out;
244
+ }
245
+ const PAGE_META_FIELDS = ["title", "description", "ogImage"];
246
+ /**
247
+ * Everything an edit did to one page that nobody asked for.
248
+ *
249
+ * `ops` is the batch that produced `after`: a block removed by a
250
+ * `remove_block` naming it was asked for and is not reported. A page created
251
+ * or deleted by the batch is not this agent's business — `before` or `after`
252
+ * is null and the answer is empty; deleting a page already goes through the
253
+ * destructive-action gate.
254
+ */
255
+ export function detectEditDamage(args) {
256
+ const { slug, before, after, ops, manifest } = args;
257
+ if (!before || !after)
258
+ return [];
259
+ const out = [];
260
+ const beforeMeta = isRecord(before.meta) ? before.meta : {};
261
+ const afterMeta = isRecord(after.meta) ? after.meta : {};
262
+ for (const key of PAGE_META_FIELDS) {
263
+ if (!hasContent(beforeMeta[key]) || hasContent(afterMeta[key]))
264
+ continue;
265
+ out.push({
266
+ kind: "emptied",
267
+ slug,
268
+ path: `meta.${key}`,
269
+ before: beforeMeta[key],
270
+ after: afterMeta[key] ?? null,
271
+ revert: { op: "update_page_meta", pageSlug: after.slug, patch: { [key]: beforeMeta[key] } }
272
+ });
273
+ }
274
+ const removedOnPurpose = new Set(ops.flatMap((op) => (op.op === "remove_block" && op.pageSlug === slug ? [op.blockId] : [])));
275
+ const afterById = new Map(after.blocks.map((block) => [block.id, block]));
276
+ before.blocks.forEach((block, index) => {
277
+ const survivor = afterById.get(block.id);
278
+ if (survivor) {
279
+ out.push(...blockIssues(after.slug, block, survivor, manifest, ops));
280
+ return;
281
+ }
282
+ if (removedOnPurpose.has(block.id))
283
+ return;
284
+ // Put it back after the nearest earlier block that is still on the page,
285
+ // so it returns to where it was rather than to the end.
286
+ const anchors = before.blocks
287
+ .slice(0, index)
288
+ .reverse()
289
+ .map((candidate) => candidate.id);
290
+ const anchor = anchors.find((id) => afterById.has(id));
291
+ const blockLabel = labelFor(block, definitionFor(manifest, block.type));
292
+ out.push({
293
+ kind: "block_dropped",
294
+ slug,
295
+ blockId: block.id,
296
+ blockType: block.type,
297
+ ...(blockLabel ? { blockLabel } : {}),
298
+ before: block,
299
+ after: null,
300
+ anchors,
301
+ revert: {
302
+ op: "add_block",
303
+ pageSlug: after.slug,
304
+ ...(anchor ? { afterBlockId: anchor } : {}),
305
+ block: structuredClone(block)
306
+ }
307
+ });
308
+ });
309
+ return out;
310
+ }
311
+ // ---------------------------------------------------------------------------
312
+ // Reading a value back — for the stale check before a revert
313
+ // ---------------------------------------------------------------------------
314
+ const PATH_SEGMENT = /([^.[\]]+)|\[(\d+)\]/g;
315
+ /** The value at an editable-target path inside `root`, or undefined. */
316
+ export function valueAtPath(root, path) {
317
+ let current = root;
318
+ for (const match of path.matchAll(PATH_SEGMENT)) {
319
+ if (current == null)
320
+ return undefined;
321
+ if (match[2] !== undefined) {
322
+ if (!Array.isArray(current))
323
+ return undefined;
324
+ current = current[Number(match[2])];
325
+ }
326
+ else {
327
+ if (!isRecord(current))
328
+ return undefined;
329
+ current = current[match[1]];
330
+ }
331
+ }
332
+ return current;
333
+ }
334
+ /**
335
+ * What the issue's location holds on `page` now, in the same form `after` was
336
+ * recorded in. For a dropped block, the block if it is back; otherwise null.
337
+ */
338
+ export function currentValueFor(issue, page) {
339
+ if (!page)
340
+ return undefined;
341
+ if (issue.kind === "block_dropped")
342
+ return page.blocks.find((b) => b.id === issue.blockId) ?? null;
343
+ if (!issue.path)
344
+ return undefined;
345
+ if (!issue.blockId)
346
+ return valueAtPath(page, issue.path) ?? null;
347
+ const block = page.blocks.find((b) => b.id === issue.blockId);
348
+ if (!block)
349
+ return undefined;
350
+ if (issue.kind === "item_dropped") {
351
+ const list = isRecord(block.props) ? block.props[issue.listKey ?? ""] : undefined;
352
+ if (!Array.isArray(list))
353
+ return undefined;
354
+ return list.find((item) => itemIdOf(item) === issue.itemId) ?? null;
355
+ }
356
+ return valueAtPath(block.props, issue.path) ?? null;
357
+ }
358
+ function stable(value) {
359
+ return JSON.stringify(value ?? null);
360
+ }
361
+ /**
362
+ * True while the location still holds exactly what the edit left there — the
363
+ * only state in which putting the old value back restores the edit's damage
364
+ * and nothing else.
365
+ */
366
+ export function isStillAsEditLeftIt(issue, page) {
367
+ const current = currentValueFor(issue, page);
368
+ if (current === undefined)
369
+ return false;
370
+ if (issue.kind === "block_dropped" || issue.kind === "item_dropped")
371
+ return current === null;
372
+ return stable(current) === stable(issue.after);
373
+ }
374
+ export function countIssues(issues) {
375
+ return {
376
+ emptied: issues.filter((i) => i.kind === "emptied").length,
377
+ blocksDropped: issues.filter((i) => i.kind === "block_dropped").length,
378
+ itemsDropped: issues.filter((i) => i.kind === "item_dropped").length,
379
+ typeChanged: issues.filter((i) => i.kind === "type_changed").length
380
+ };
381
+ }
382
+ /** One English line for logs, the Inbox row and anything outside the editor. */
383
+ export function describeIssues(issues) {
384
+ const counts = countIssues(issues);
385
+ const parts = [];
386
+ if (counts.emptied)
387
+ parts.push(`emptied ${counts.emptied} ${counts.emptied === 1 ? "field" : "fields"}`);
388
+ if (counts.blocksDropped) {
389
+ parts.push(`dropped ${counts.blocksDropped} ${counts.blocksDropped === 1 ? "block" : "blocks"}`);
390
+ }
391
+ if (counts.itemsDropped) {
392
+ parts.push(`dropped ${counts.itemsDropped} list ${counts.itemsDropped === 1 ? "entry" : "entries"}`);
393
+ }
394
+ if (counts.typeChanged) {
395
+ parts.push(`changed the shape of ${counts.typeChanged} ${counts.typeChanged === 1 ? "field" : "fields"}`);
396
+ }
397
+ if (parts.length <= 1)
398
+ return parts[0] ?? "";
399
+ return `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
400
+ }
401
+ /**
402
+ * The revert op for `issue`, re-anchored against `page` as it is now. For a
403
+ * dropped block or entry, the anchor is the nearest earlier neighbour that is
404
+ * on the page at this moment; every other revert is returned unchanged.
405
+ */
406
+ export function revertFor(issue, page) {
407
+ const op = issue.revert;
408
+ if (!op || !issue.anchors || !page)
409
+ return op;
410
+ if (op.op === "add_block") {
411
+ const present = new Set(page.blocks.map((block) => block.id));
412
+ const anchor = issue.anchors.find((id) => present.has(id));
413
+ const { afterBlockId: _previous, ...rest } = op;
414
+ return anchor ? { ...rest, afterBlockId: anchor } : rest;
415
+ }
416
+ if (op.op === "add_item") {
417
+ const block = page.blocks.find((b) => b.id === op.blockId);
418
+ const list = block && isRecord(block.props) ? block.props[op.listKey] : undefined;
419
+ const present = new Set(Array.isArray(list) ? list.map(itemIdOf).filter(Boolean) : []);
420
+ const anchor = issue.anchors.find((id) => present.has(id));
421
+ const { afterItemId: _previous, ...rest } = op;
422
+ return anchor ? { ...rest, afterItemId: anchor } : rest;
423
+ }
424
+ return op;
425
+ }
@@ -0,0 +1,38 @@
1
+ import { type Operation, type PageDoc } from "@avocadostudio-ai/shared";
2
+ import type { FindingFix, FindingRecord } from "../durable/types.ts";
3
+ import type { DraftClient } from "./draft-spec.ts";
4
+ export type FixField = FindingFix["field"];
5
+ /** The rules a fix can be written for, and which field each one fixes. */
6
+ export declare const FIXABLE_RULES: Record<string, FixField>;
7
+ export declare function fixFieldFor(ruleId: string): FixField | undefined;
8
+ export type FixDeps = {
9
+ /** Text model for titles and descriptions. */
10
+ client?: DraftClient;
11
+ model?: string;
12
+ /** Vision alt text; defaults to the editor's own generator. */
13
+ altText?: (imageUrl: string) => Promise<string | null>;
14
+ /** Site facts, one per line: words to avoid, product names, tone. */
15
+ facts?: string[];
16
+ };
17
+ export type WriteFixResult = {
18
+ ok: true;
19
+ fix: FindingFix;
20
+ } | {
21
+ ok: false;
22
+ error: string;
23
+ };
24
+ /** The value a fix for this finding replaces, read off the page as it is now. */
25
+ export declare function currentValue(finding: Pick<FindingRecord, "ruleId" | "evidence">, page: PageDoc): string | undefined;
26
+ /** The ops that put `text` in place — the same ops whether the text was written or edited. */
27
+ export declare function fixOps(finding: Pick<FindingRecord, "ruleId" | "slug" | "evidence">, text: string): Operation[];
28
+ /** What the page says, compactly: headings and the first sentences of its text, for the model to summarise. */
29
+ export declare function pageDigest(page: PageDoc, maxChars?: number): string;
30
+ /**
31
+ * Write a fix for one finding against the page as it is. A title or
32
+ * description outside its length is retried once with the count; a second
33
+ * miss is refused rather than stored, because the fix would reopen the very
34
+ * finding it was written for.
35
+ */
36
+ export declare function writeFix(finding: FindingRecord, page: PageDoc, deps?: FixDeps): Promise<WriteFixResult>;
37
+ /** "German" for "de": the model is told a language, not a code. */
38
+ export declare function languageName(code: string): string;
@@ -0,0 +1,197 @@
1
+ import { buildBlockManifest } from "@avocadostudio-ai/shared";
2
+ import { fieldText, walkPageFields } from "../checks/field-walk.js";
3
+ import { DESCRIPTION_MAX, DESCRIPTION_MIN, effectiveTitle, TITLE_MAX, TITLE_MIN } from "../checks/rules-draft.js";
4
+ import { getAnthropicClient } from "../chat/anthropic-planner.js";
5
+ import { defaultModelLookup } from "../chat/model-defaults.js";
6
+ import { generateAltTextFromVision } from "../chat/vision-alt-generator.js";
7
+ /** The rules a fix can be written for, and which field each one fixes. */
8
+ export const FIXABLE_RULES = {
9
+ "seo.title-missing": "title",
10
+ "seo.title-length": "title",
11
+ "seo.description-missing": "description",
12
+ "seo.description-length": "description",
13
+ "a11y.alt-missing": "alt",
14
+ "i18n.untranslated": "translation"
15
+ };
16
+ export function fixFieldFor(ruleId) {
17
+ return FIXABLE_RULES[ruleId];
18
+ }
19
+ const LIMITS = {
20
+ title: [TITLE_MIN, TITLE_MAX],
21
+ description: [DESCRIPTION_MIN, DESCRIPTION_MAX]
22
+ };
23
+ /** The value a fix for this finding replaces, read off the page as it is now. */
24
+ export function currentValue(finding, page) {
25
+ const field = fixFieldFor(finding.ruleId);
26
+ if (field === "title")
27
+ return effectiveTitle(page);
28
+ if (field === "description")
29
+ return (page.meta?.description ?? "").trim();
30
+ if (field === "alt" || field === "translation") {
31
+ const { blockId, path } = finding.evidence ?? {};
32
+ if (!blockId || !path)
33
+ return undefined;
34
+ const entry = walkPageFields(page, buildBlockManifest()).find((f) => f.blockId === blockId && f.path === path);
35
+ return entry ? fieldText(entry.value).trim() : undefined;
36
+ }
37
+ return undefined;
38
+ }
39
+ /** The ops that put `text` in place — the same ops whether the text was written or edited. */
40
+ export function fixOps(finding, text) {
41
+ const field = fixFieldFor(finding.ruleId);
42
+ if (field === "title")
43
+ return [{ op: "update_page_meta", pageSlug: finding.slug, patch: { title: text } }];
44
+ if (field === "description")
45
+ return [{ op: "update_page_meta", pageSlug: finding.slug, patch: { description: text } }];
46
+ const { blockId, path } = finding.evidence ?? {};
47
+ if ((field === "alt" || field === "translation") && blockId && path) {
48
+ return [{ op: "update_props", pageSlug: finding.slug, blockId, patch: { [path]: text } }];
49
+ }
50
+ return [];
51
+ }
52
+ /** What the page says, compactly: headings and the first sentences of its text, for the model to summarise. */
53
+ export function pageDigest(page, maxChars = 2500) {
54
+ const lines = [];
55
+ for (const entry of walkPageFields(page, buildBlockManifest())) {
56
+ if (entry.kind !== "text" && entry.kind !== "richtext")
57
+ continue;
58
+ const text = fieldText(entry.value).replace(/\s+/g, " ").trim();
59
+ if (text.length < 3 || /^https?:\/\//.test(text))
60
+ continue;
61
+ lines.push(`${entry.blockType}.${entry.path}: ${text.slice(0, 300)}`);
62
+ }
63
+ return lines.join("\n").slice(0, maxChars);
64
+ }
65
+ function systemPrompt(field) {
66
+ const [min, max] = LIMITS[field];
67
+ const what = field === "title"
68
+ ? `the page's title for search results and the browser tab`
69
+ : `the page's meta description for search results and link previews`;
70
+ return [
71
+ `You write ${what}.`,
72
+ `Length: between ${min} and ${max} characters. Count carefully; this is checked.`,
73
+ "Write in the language the page's content is written in.",
74
+ "Say what the page is about, specifically, using the page's own words and facts. Never invent a claim, a price, an offer or a date the page does not state.",
75
+ field === "title"
76
+ ? "No quotes. Put the most specific words first. You may end with the site's name after a separator if it fits."
77
+ : "One or two plain sentences. No quotes, no emoji, no calls to action the page does not make.",
78
+ "The site facts you are given are rules about the site's wording — follow them (words to avoid, product names, spelling).",
79
+ 'Reply with JSON: {"text": "..."}.'
80
+ ].join("\n");
81
+ }
82
+ const TEXT_SCHEMA = {
83
+ type: "object",
84
+ additionalProperties: false,
85
+ required: ["text"],
86
+ properties: { text: { type: "string" } }
87
+ };
88
+ async function writeText(field, page, deps, retryNote) {
89
+ const client = deps.client ?? getAnthropicClient();
90
+ const model = deps.model ?? defaultModelLookup().anthropic.balanced;
91
+ const user = {
92
+ slug: page.slug,
93
+ currentTitle: effectiveTitle(page) || null,
94
+ currentDescription: (page.meta?.description ?? "").trim() || null,
95
+ siteFacts: deps.facts ?? [],
96
+ content: pageDigest(page),
97
+ ...(retryNote ? { previousAttempt: retryNote } : {})
98
+ };
99
+ const response = await client.messages.create({
100
+ model,
101
+ max_tokens: 400,
102
+ system: systemPrompt(field),
103
+ output_config: { format: { type: "json_schema", schema: TEXT_SCHEMA } },
104
+ messages: [{ role: "user", content: JSON.stringify(user) }]
105
+ }, { signal: AbortSignal.timeout(60_000) });
106
+ const raw = response.content.find((block) => block.type === "text")?.text ?? "";
107
+ const text = String(JSON.parse(raw || "{}").text ?? "");
108
+ return text.replace(/\s+/g, " ").trim().replace(/^["“„']+|["”']+$/g, "");
109
+ }
110
+ /**
111
+ * Write a fix for one finding against the page as it is. A title or
112
+ * description outside its length is retried once with the count; a second
113
+ * miss is refused rather than stored, because the fix would reopen the very
114
+ * finding it was written for.
115
+ */
116
+ export async function writeFix(finding, page, deps = {}) {
117
+ const field = fixFieldFor(finding.ruleId);
118
+ if (!field)
119
+ return { ok: false, error: "no fix can be written for this kind of finding" };
120
+ const base = currentValue(finding, page);
121
+ if (base === undefined)
122
+ return { ok: false, error: "the field this finding is about is no longer on the page" };
123
+ if (field === "alt") {
124
+ const imageUrl = finding.evidence?.imageUrl;
125
+ if (!imageUrl)
126
+ return { ok: false, error: "the finding does not say which image it is about" };
127
+ const generate = deps.altText ?? ((url) => generateAltTextFromVision({ imageUrl: url, preferredProvider: "anthropic" }));
128
+ const text = (await generate(imageUrl))?.trim();
129
+ if (!text)
130
+ return { ok: false, error: "the image could not be described" };
131
+ return { ok: true, fix: { field, text, base, ops: fixOps(finding, text), by: "vision", at: Date.now() } };
132
+ }
133
+ if (field === "translation")
134
+ return writeTranslation(finding, page, base, deps);
135
+ const [min, max] = LIMITS[field];
136
+ let text = await writeText(field, page, deps);
137
+ if (text.length < min || text.length > max) {
138
+ text = await writeText(field, page, deps, `"${text}" is ${text.length} characters; it must be ${min}–${max}.`);
139
+ }
140
+ if (text.length < min || text.length > max) {
141
+ return { ok: false, error: `the ${field} came back ${text.length} characters, outside ${min}–${max}` };
142
+ }
143
+ return {
144
+ ok: true,
145
+ fix: { field, text, base, ops: fixOps(finding, text), by: deps.model ?? defaultModelLookup().anthropic.balanced, at: Date.now() }
146
+ };
147
+ }
148
+ /** "German" for "de": the model is told a language, not a code. */
149
+ export function languageName(code) {
150
+ try {
151
+ return new Intl.DisplayNames(["en"], { type: "language" }).of(code) ?? code;
152
+ }
153
+ catch {
154
+ return code;
155
+ }
156
+ }
157
+ /**
158
+ * Translate a field a translated page left in the source language. Plain text
159
+ * only: a rich-text field would lose its formatting as a string, and is left
160
+ * to the chat, which translates documents block by block.
161
+ */
162
+ async function writeTranslation(finding, page, base, deps) {
163
+ const locale = finding.evidence?.locale;
164
+ if (!locale)
165
+ return { ok: false, error: "the finding does not say which language the page is in" };
166
+ const raw = rawValue(finding, page);
167
+ if (typeof raw !== "string")
168
+ return { ok: false, error: "rich text is translated in the chat, where its formatting survives" };
169
+ const client = deps.client ?? getAnthropicClient();
170
+ const model = deps.model ?? defaultModelLookup().anthropic.balanced;
171
+ const language = languageName(locale);
172
+ const response = await client.messages.create({
173
+ model,
174
+ max_tokens: 1200,
175
+ system: [
176
+ `You translate website copy into ${language}.`,
177
+ "Keep the meaning, the tone and the length close to the original. Keep names, product names and numbers as they are.",
178
+ "The site facts you are given are rules about the site's wording, including its glossary — follow them.",
179
+ 'Reply with JSON: {"text": "..."}.'
180
+ ].join("\n"),
181
+ output_config: { format: { type: "json_schema", schema: TEXT_SCHEMA } },
182
+ messages: [{ role: "user", content: JSON.stringify({ language, text: finding.evidence?.sourceText ?? base, siteFacts: deps.facts ?? [] }) }]
183
+ }, { signal: AbortSignal.timeout(60_000) });
184
+ const out = String(JSON.parse(response.content.find((b) => b.type === "text")?.text || "{}").text ?? "").trim();
185
+ if (!out)
186
+ return { ok: false, error: "the translation came back empty" };
187
+ if (out === base)
188
+ return { ok: false, error: "the translation came back unchanged" };
189
+ return { ok: true, fix: { field: "translation", text: out, base, ops: fixOps(finding, out), by: model, at: Date.now() } };
190
+ }
191
+ /** The field's stored value, untouched — to tell plain text from a rich-text document. */
192
+ function rawValue(finding, page) {
193
+ const { blockId, path } = finding.evidence ?? {};
194
+ if (!blockId || !path)
195
+ return undefined;
196
+ return walkPageFields(page, buildBlockManifest()).find((f) => f.blockId === blockId && f.path === path)?.value;
197
+ }