@avocadostudio-ai/orchestrator-core 0.22.0 → 0.24.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 (77) 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 +157 -0
  13. package/dist/agents/edit-safety.d.ts +82 -0
  14. package/dist/agents/edit-safety.js +425 -0
  15. package/dist/agents/enabled.d.ts +18 -0
  16. package/dist/agents/enabled.js +21 -0
  17. package/dist/agents/fix-writer.d.ts +38 -0
  18. package/dist/agents/fix-writer.js +197 -0
  19. package/dist/agents/inbox.d.ts +141 -0
  20. package/dist/agents/inbox.js +364 -0
  21. package/dist/agents/learning.d.ts +32 -0
  22. package/dist/agents/learning.js +74 -0
  23. package/dist/agents/mention.d.ts +76 -0
  24. package/dist/agents/mention.js +165 -0
  25. package/dist/agents/run-spec.d.ts +27 -0
  26. package/dist/agents/run-spec.js +82 -0
  27. package/dist/agents/schedule.d.ts +22 -0
  28. package/dist/agents/schedule.js +53 -0
  29. package/dist/agents/settings.d.ts +30 -0
  30. package/dist/agents/settings.js +64 -0
  31. package/dist/agents/spec.d.ts +84 -0
  32. package/dist/agents/spec.js +70 -0
  33. package/dist/agents/stats.d.ts +15 -0
  34. package/dist/agents/stats.js +48 -0
  35. package/dist/agents/templates.d.ts +34 -0
  36. package/dist/agents/templates.js +268 -0
  37. package/dist/agents/tick.d.ts +59 -0
  38. package/dist/agents/tick.js +164 -0
  39. package/dist/chat/anthropic-model-caps.d.ts +19 -0
  40. package/dist/chat/anthropic-model-caps.js +56 -0
  41. package/dist/chat/anthropic-planner.d.ts +1 -0
  42. package/dist/chat/anthropic-planner.js +72 -15
  43. package/dist/chat/chat-pipeline.js +54 -1
  44. package/dist/chat/model-defaults.js +6 -3
  45. package/dist/checks/rules-draft.d.ts +6 -1
  46. package/dist/checks/rules-draft.js +10 -7
  47. package/dist/checks/rules-i18n.d.ts +25 -0
  48. package/dist/checks/rules-i18n.js +193 -0
  49. package/dist/checks/run-checks.d.ts +16 -1
  50. package/dist/checks/run-checks.js +80 -63
  51. package/dist/checks/session-runner.js +7 -0
  52. package/dist/checks/types.d.ts +6 -0
  53. package/dist/durable/in-memory-durable-store.d.ts +34 -1
  54. package/dist/durable/in-memory-durable-store.js +122 -1
  55. package/dist/durable/index.d.ts +1 -1
  56. package/dist/durable/sqlite-durable-store.d.ts +19 -1
  57. package/dist/durable/sqlite-durable-store.js +220 -1
  58. package/dist/durable/types.d.ts +146 -0
  59. package/dist/handler/create-orchestrator.js +195 -2
  60. package/dist/http/history-actions.d.ts +22 -0
  61. package/dist/http/history-actions.js +33 -5
  62. package/dist/http/inbox-actions.d.ts +156 -0
  63. package/dist/http/inbox-actions.js +498 -0
  64. package/dist/http/ops-actions.d.ts +17 -1
  65. package/dist/http/ops-actions.js +36 -0
  66. package/dist/http/variation-preview-actions.d.ts +30 -0
  67. package/dist/http/variation-preview-actions.js +75 -0
  68. package/dist/index.d.ts +10 -1
  69. package/dist/index.js +9 -0
  70. package/dist/nlp/deterministic-planner.d.ts +1 -1
  71. package/dist/nlp/intent-detection.d.ts +15 -0
  72. package/dist/nlp/intent-detection.js +5 -0
  73. package/dist/state/session-state.d.ts +11 -3
  74. package/dist/state/session-state.js +8 -0
  75. package/dist/state/sqlite-store.d.ts +12 -0
  76. package/dist/telemetry/usage.js +4 -0
  77. package/package.json +3 -3
@@ -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
+ }
@@ -0,0 +1,141 @@
1
+ import type { FindingRecord, InboxItemKind, InboxItemRecord, InboxItemStatus } from "../durable/types.ts";
2
+ import { type Logger } from "../logger.ts";
3
+ import { type FixDeps } from "./fix-writer.ts";
4
+ import { type SiteAgents } from "./settings.ts";
5
+ export type InboxEntryKind = InboxItemKind | "finding";
6
+ export type InboxEntry = {
7
+ /** `item:<id>` or `findings:<ruleId>` — stable across reads. */
8
+ id: string;
9
+ source: "item" | "findings";
10
+ kind: InboxEntryKind;
11
+ agent: string;
12
+ object: string;
13
+ verb: string;
14
+ title: string;
15
+ summary?: string;
16
+ slugs: string[];
17
+ impact: number;
18
+ status: InboxItemStatus;
19
+ createdAt: number;
20
+ updatedAt: number;
21
+ revision?: number;
22
+ assignee?: string;
23
+ blocksPublish?: boolean;
24
+ payload?: Record<string, unknown>;
25
+ resolution?: string;
26
+ resolvedBy?: string;
27
+ resolvedAt?: number;
28
+ note?: string;
29
+ /** For a findings group: every finding in it, highest impact first. */
30
+ findings?: FindingRecord[];
31
+ /** For a findings group: how many carry ops that fix them — a rule's own, or a written fix. */
32
+ fixable?: number;
33
+ /** For a findings group: how many a fix could be written for and has not been yet. */
34
+ writable?: number;
35
+ /** A reverted alert whose revert can still be taken back from the Inbox. */
36
+ undoable?: boolean;
37
+ };
38
+ export type InboxView = {
39
+ entries: InboxEntry[];
40
+ counts: {
41
+ needsYou: number;
42
+ done: number;
43
+ };
44
+ /** Open alerts holding publish, by slug. The publish dialog reads this. */
45
+ blocking: Array<{
46
+ id: string;
47
+ slugs: string[];
48
+ title: string;
49
+ }>;
50
+ agents: SiteAgents;
51
+ /** Pass back as `since` to learn whether anything changed. */
52
+ cursor: number;
53
+ };
54
+ /** How many closed items the Done tab shows. */
55
+ export declare const DONE_TAB_LIMIT = 50;
56
+ /** One card per rule, over every open finding of an enabled agent. */
57
+ /** The agent a finding answers to: a custom agent by its own id, a draft rule by its job. */
58
+ export declare function agentOfFinding(finding: Pick<FindingRecord, "agent" | "ruleId">): string;
59
+ export declare function groupFindings(findings: FindingRecord[], enabled: (agent: string) => boolean): InboxEntry[];
60
+ export declare function buildInboxView(scopeKey: string, now?: number): Promise<InboxView>;
61
+ /**
62
+ * Open alerts that hold publishing any of `slugs`. With no slugs, every open
63
+ * hold for the site — a whole-site publish ships every page.
64
+ */
65
+ export declare function publishHolds(scopeKey: string, slugs?: string[]): Promise<InboxItemRecord[]>;
66
+ export type FindingDecisionOutcome = {
67
+ applied: string[];
68
+ /** Re-checked on approve and no longer present — closed, nothing applied. */
69
+ alreadyFixed: string[];
70
+ failed: Array<{
71
+ id: string;
72
+ error: string;
73
+ }>;
74
+ previewVersion?: number;
75
+ };
76
+ /**
77
+ * Approve the fixes of one or more findings.
78
+ *
79
+ * A finding's ops were written when the checker last ran, and the page may
80
+ * have changed since. So approving re-runs the rule over the page first
81
+ * (rules are free and instant) and applies the ops the rule proposes *now*:
82
+ * if the problem is gone the finding closes as already fixed, and if it is
83
+ * still there the fix is written against the page as it is (SO-17).
84
+ */
85
+ export declare function approveFindings(args: {
86
+ scopeKey: string;
87
+ ids: string[];
88
+ by?: string;
89
+ /** A written fix's text as the person edited it, by finding id. */
90
+ edits?: Record<string, string>;
91
+ log?: Logger;
92
+ }): Promise<FindingDecisionOutcome>;
93
+ export type WriteFixesOutcome = {
94
+ written: Array<{
95
+ id: string;
96
+ text: string;
97
+ }>;
98
+ failed: Array<{
99
+ id: string;
100
+ error: string;
101
+ }>;
102
+ };
103
+ /** How many fixes one request may write: each is a model call. */
104
+ export declare const WRITE_FIXES_MAX = 20;
105
+ /**
106
+ * Write fixes for open findings, on request (agents/fix-writer.ts). Nothing
107
+ * is applied: each fix is stored on its finding for a person to approve,
108
+ * edit or reject. Site facts go to the model with every request, so a fix
109
+ * follows the site's own rules about wording.
110
+ */
111
+ export declare function writeFixes(args: {
112
+ scopeKey: string;
113
+ ids: string[];
114
+ deps?: FixDeps;
115
+ log?: Logger;
116
+ }): Promise<WriteFixesOutcome>;
117
+ /**
118
+ * Reject findings, with the reason a person gave. Recorded as a correction so
119
+ * the rule's track record counts it; the finding is dismissed, and a dismissal
120
+ * survives every re-run.
121
+ */
122
+ export declare function rejectFindings(args: {
123
+ scopeKey: string;
124
+ ids: string[];
125
+ reason: string;
126
+ by?: string;
127
+ log?: Logger;
128
+ }): Promise<FindingDecisionOutcome>;
129
+ /**
130
+ * The publish gate (site-ops-agents.md SO-18): an open Edit safety alert on a
131
+ * page that is about to ship refuses the publish, with no override flag. The
132
+ * way through is one click in the alert — revert, or say it was meant — so the
133
+ * one moment every change is guaranteed to be looked at is not skippable.
134
+ *
135
+ * Returns the 409 to send, or null when nothing holds. With no selection the
136
+ * whole draft ships, so any hold on the site counts.
137
+ */
138
+ export declare function publishHoldResponse(scopeKey: string, selectedSlugs?: string[] | null, log?: Logger): Promise<{
139
+ code: 409;
140
+ body: Record<string, unknown>;
141
+ } | null>;