@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
@@ -44,7 +44,9 @@ export async function* runOpenAIAgentLoop(options) {
44
44
  log.info(`[agent-loop-openai] Calling OpenAI API (streaming), turn ${toolCallCount + 1}`);
45
45
  const stream = await client.chat.completions.create({
46
46
  model,
47
- max_tokens: AGENT_MAX_TOKENS,
47
+ // `max_tokens` is rejected by the gpt-5 family (the default agent model);
48
+ // `max_completion_tokens` is accepted by every current chat model.
49
+ max_completion_tokens: AGENT_MAX_TOKENS,
48
50
  messages,
49
51
  tools: openAITools,
50
52
  stream: true,
@@ -20,8 +20,8 @@ export function detectProviderFromKey(key) {
20
20
  // Agent mode defaults (env-configurable)
21
21
  // ---------------------------------------------------------------------------
22
22
  const DEFAULT_MODELS = {
23
- anthropic: process.env.AGENT_ANTHROPIC_MODEL ?? "claude-sonnet-5",
24
- openai: process.env.AGENT_OPENAI_MODEL ?? "gpt-4o",
23
+ anthropic: process.env.AGENT_ANTHROPIC_MODEL ?? "claude-sonnet-5-5",
24
+ openai: process.env.AGENT_OPENAI_MODEL ?? "gpt-5.6-terra",
25
25
  };
26
26
  /** Max tool calls before the agent loop stops. */
27
27
  export const AGENT_MAX_TOOL_CALLS = Number(process.env.AGENT_MAX_TOOL_CALLS) || 20;
@@ -0,0 +1,35 @@
1
+ import { type VersionEntry } from "../state/session-state.ts";
2
+ export type BacktestExample = {
3
+ slug: string;
4
+ text: string;
5
+ at?: string;
6
+ };
7
+ export type AgentBacktest = {
8
+ agentId: string;
9
+ /** Findings today, or — for Edit safety — past edits that would have been flagged. */
10
+ count: number;
11
+ examples: BacktestExample[];
12
+ /** Edit safety only: how many AI edits it looked at. */
13
+ checked?: number;
14
+ };
15
+ export type SiteBacktest = {
16
+ agents: AgentBacktest[];
17
+ pages: number;
18
+ /** The oldest entry the replay could read, ISO. Absent when there is no history. */
19
+ historySince?: string;
20
+ historyEntries: number;
21
+ };
22
+ /**
23
+ * Replay the version log for Edit safety.
24
+ *
25
+ * Only chat and MCP versions are edits it would have checked. Each is diffed
26
+ * against the version of the same page before it. The log does not keep the
27
+ * ops, only their names, so a version whose batch contained a `remove_block`
28
+ * does not report dropped blocks — the replay cannot tell which one was meant.
29
+ */
30
+ export declare function replayEditSafety(entries: VersionEntry[]): {
31
+ flagged: number;
32
+ checked: number;
33
+ examples: BacktestExample[];
34
+ };
35
+ export declare function backtestSite(scopeKey: string): Promise<SiteBacktest>;
@@ -0,0 +1,92 @@
1
+ import { buildBlockManifest } from "@avocadostudio-ai/shared";
2
+ import { evaluateDraftChecks } from "../checks/run-checks.js";
3
+ import { getSessionDraft, getSiteConfig, versionLog } from "../state/session-state.js";
4
+ import { getSiteAssets } from "../state/site-assets.js";
5
+ import { agentForRule, BUILTIN_AGENT_IDS } from "./builtins.js";
6
+ import { evaluateAgentSpec } from "./run-spec.js";
7
+ import { resolveSiteAgents } from "./settings.js";
8
+ import { describeIssues, detectEditDamage } from "./edit-safety.js";
9
+ const EXAMPLES = 3;
10
+ /**
11
+ * Replay the version log for Edit safety.
12
+ *
13
+ * Only chat and MCP versions are edits it would have checked. Each is diffed
14
+ * against the version of the same page before it. The log does not keep the
15
+ * ops, only their names, so a version whose batch contained a `remove_block`
16
+ * does not report dropped blocks — the replay cannot tell which one was meant.
17
+ */
18
+ export function replayEditSafety(entries) {
19
+ const manifest = buildBlockManifest();
20
+ const lastBySlug = new Map();
21
+ const examples = [];
22
+ let flagged = 0;
23
+ let checked = 0;
24
+ for (const entry of entries) {
25
+ const previous = lastBySlug.get(entry.slug);
26
+ if (entry.snapshot !== undefined)
27
+ lastBySlug.set(entry.slug, entry);
28
+ const byMachine = entry.source === "chat" || entry.actor?.kind === "mcp";
29
+ if (!byMachine || !previous?.snapshot || !entry.snapshot)
30
+ continue;
31
+ checked += 1;
32
+ let issues = detectEditDamage({ slug: entry.slug, before: previous.snapshot, after: entry.snapshot, ops: [], manifest });
33
+ if (entry.opTypes.includes("remove_block"))
34
+ issues = issues.filter((issue) => issue.kind !== "block_dropped");
35
+ if (issues.length === 0)
36
+ continue;
37
+ flagged += 1;
38
+ if (examples.length < EXAMPLES) {
39
+ examples.push({ slug: entry.slug, text: `“${entry.summary.slice(0, 80)}” also ${describeIssues(issues)}`, at: entry.at });
40
+ }
41
+ }
42
+ return { flagged, checked, examples };
43
+ }
44
+ export async function backtestSite(scopeKey) {
45
+ const pages = [...getSessionDraft(scopeKey).values()];
46
+ const assets = await getSiteAssets();
47
+ const { findings } = evaluateDraftChecks({
48
+ scopeKey,
49
+ pages,
50
+ manifest: buildBlockManifest(),
51
+ siteConfig: getSiteConfig(scopeKey),
52
+ ...(assets ? { assets } : {})
53
+ });
54
+ findings.sort((a, b) => (b.impact ?? 0) - (a.impact ?? 0));
55
+ const entries = versionLog.get(scopeKey) ?? [];
56
+ const replay = replayEditSafety(entries);
57
+ const agents = BUILTIN_AGENT_IDS.map((agentId) => {
58
+ if (agentId === "edit-safety") {
59
+ return { agentId, count: replay.flagged, examples: replay.examples, checked: replay.checked };
60
+ }
61
+ const mine = findings.filter((f) => agentForRule(f.ruleId) === agentId);
62
+ return {
63
+ agentId,
64
+ count: mine.length,
65
+ examples: mine.slice(0, EXAMPLES).map((f) => ({ slug: f.slug, text: f.title }))
66
+ };
67
+ });
68
+ // Custom agents are tried the same way: their spec, over today's pages.
69
+ const site = await resolveSiteAgents(scopeKey);
70
+ for (const agent of site.agents) {
71
+ if (agent.builtin)
72
+ continue;
73
+ try {
74
+ const found = await evaluateAgentSpec(scopeKey, agent.spec);
75
+ found.sort((a, b) => (b.impact ?? 0) - (a.impact ?? 0));
76
+ agents.push({
77
+ agentId: agent.agentId,
78
+ count: found.length,
79
+ examples: found.slice(0, EXAMPLES).map((f) => ({ slug: f.slug, text: f.title }))
80
+ });
81
+ }
82
+ catch {
83
+ agents.push({ agentId: agent.agentId, count: 0, examples: [] });
84
+ }
85
+ }
86
+ return {
87
+ agents,
88
+ pages: pages.length,
89
+ ...(entries[0]?.at ? { historySince: entries[0].at } : {}),
90
+ historyEntries: entries.length
91
+ };
92
+ }
@@ -0,0 +1,21 @@
1
+ import type { AgentSpec } from "./spec.ts";
2
+ export declare const BUILTIN_AGENT_IDS: readonly ["edit-safety", "unfinished-work", "search-health", "translation-drift"];
3
+ export type BuiltinAgentId = (typeof BUILTIN_AGENT_IDS)[number];
4
+ export declare function isBuiltinAgentId(value: unknown): value is BuiltinAgentId;
5
+ /**
6
+ * Whether an agent is on for a site nobody has set up yet.
7
+ *
8
+ * "Degrade to exactly today" (site-ops-agents.md §7): the draft checker ran
9
+ * before any of this existed, so its two agents stay on. Edit safety is new
10
+ * and holds publish when it fires, so it waits until someone switches it on
11
+ * and names who hears from it.
12
+ */
13
+ export declare const DEFAULT_ENABLED: Record<BuiltinAgentId, boolean>;
14
+ export declare function agentForRule(ruleId: string): BuiltinAgentId;
15
+ /**
16
+ * The three built-ins in the same shape a custom agent has. Search health and
17
+ * Unfinished work are the draft checker's rules, split by job; Edit safety is
18
+ * its before/after diff. Turning one on and writing a custom agent are the
19
+ * same action with a different starting spec.
20
+ */
21
+ export declare const BUILTIN_SPECS: Record<BuiltinAgentId, AgentSpec>;
@@ -0,0 +1,102 @@
1
+ import { DRAFT_RULES } from "../checks/rules-draft.js";
2
+ import { I18N_RULES } from "../checks/rules-i18n.js";
3
+ /*
4
+ * The built-in agents of phase 1 (docs/ideas/site-ops-agents.md §8 items 0–3).
5
+ *
6
+ * Three, not the four the plan launches with: Freshness is deferred. Each is a
7
+ * fixed-rule agent — none calls a model — and two of them are the draft
8
+ * checker that already ships, split by the job an ops person would name
9
+ * rather than by the `agent` string the rules happen to carry.
10
+ *
11
+ * Copy (names, the one-line job) lives in the editor's locale files, not
12
+ * here: the server hands out ids and state, the reader's language is the
13
+ * editor's business.
14
+ */
15
+ export const BUILTIN_AGENT_IDS = ["edit-safety", "unfinished-work", "search-health", "translation-drift"];
16
+ export function isBuiltinAgentId(value) {
17
+ return typeof value === "string" && BUILTIN_AGENT_IDS.includes(value);
18
+ }
19
+ /**
20
+ * Whether an agent is on for a site nobody has set up yet.
21
+ *
22
+ * "Degrade to exactly today" (site-ops-agents.md §7): the draft checker ran
23
+ * before any of this existed, so its two agents stay on. Edit safety is new
24
+ * and holds publish when it fires, so it waits until someone switches it on
25
+ * and names who hears from it.
26
+ */
27
+ export const DEFAULT_ENABLED = {
28
+ "edit-safety": false,
29
+ "unfinished-work": true,
30
+ "search-health": true,
31
+ // Off until someone switches it on: it means nothing on a site with one language.
32
+ "translation-drift": false
33
+ };
34
+ /** The rule ids Unfinished work owns. Every other draft rule is Search health. */
35
+ const UNFINISHED_WORK_RULES = new Set(["content.unfinished"]);
36
+ /** Which built-in agent answers for a finding from the draft checker. */
37
+ const TRANSLATION_RULES = new Set(I18N_RULES.map((rule) => rule.id));
38
+ export function agentForRule(ruleId) {
39
+ if (UNFINISHED_WORK_RULES.has(ruleId))
40
+ return "unfinished-work";
41
+ if (TRANSLATION_RULES.has(ruleId))
42
+ return "translation-drift";
43
+ return "search-health";
44
+ }
45
+ // ---------------------------------------------------------------------------
46
+ // The built-ins as specs (site-ops-agents.md SO-04)
47
+ // ---------------------------------------------------------------------------
48
+ /**
49
+ * The three built-ins in the same shape a custom agent has. Search health and
50
+ * Unfinished work are the draft checker's rules, split by job; Edit safety is
51
+ * its before/after diff. Turning one on and writing a custom agent are the
52
+ * same action with a different starting spec.
53
+ */
54
+ export const BUILTIN_SPECS = {
55
+ "edit-safety": {
56
+ id: "edit-safety",
57
+ name: "Edit safety",
58
+ brief: "Check every AI and MCP edit for damage nobody asked for, and offer a one-click revert.",
59
+ origin: "builtin",
60
+ trigger: { kind: "event", on: "apply" },
61
+ scope: {},
62
+ detect: [{ template: "edit-damage", params: {} }],
63
+ severity: "error"
64
+ },
65
+ "unfinished-work": {
66
+ id: "unfinished-work",
67
+ name: "Unfinished work",
68
+ brief: "Placeholder text the editor inserted that nobody replaced.",
69
+ origin: "builtin",
70
+ trigger: { kind: "event", on: "publish" },
71
+ scope: {},
72
+ detect: [{ template: "draft-rule", params: { ruleIds: [...UNFINISHED_WORK_RULES] } }],
73
+ severity: "warning"
74
+ },
75
+ "search-health": {
76
+ id: "search-health",
77
+ name: "Search health",
78
+ brief: "Titles, descriptions, headings, alt text and dead links, ranked by how much each page matters.",
79
+ origin: "builtin",
80
+ trigger: { kind: "event", on: "publish" },
81
+ scope: {},
82
+ detect: [
83
+ {
84
+ template: "draft-rule",
85
+ params: {
86
+ ruleIds: DRAFT_RULES.map((rule) => rule.id).filter((id) => !UNFINISHED_WORK_RULES.has(id) && !TRANSLATION_RULES.has(id))
87
+ }
88
+ }
89
+ ],
90
+ severity: "warning"
91
+ },
92
+ "translation-drift": {
93
+ id: "translation-drift",
94
+ name: "Translation drift",
95
+ brief: "Translated pages that no longer match their source page: missing blocks, text left untranslated, and source edits since.",
96
+ origin: "builtin",
97
+ trigger: { kind: "event", on: "publish" },
98
+ scope: {},
99
+ detect: [{ template: "draft-rule", params: { ruleIds: [...TRANSLATION_RULES] } }],
100
+ severity: "warning"
101
+ }
102
+ };
@@ -0,0 +1,44 @@
1
+ import { type PageDoc } from "@avocadostudio-ai/shared";
2
+ import type { Logger } from "../logger.ts";
3
+ import { type AgentSpec } from "./spec.ts";
4
+ export type DraftClient = {
5
+ messages: {
6
+ create(params: Record<string, unknown>, options?: {
7
+ signal?: AbortSignal;
8
+ }): Promise<{
9
+ content: Array<{
10
+ type: string;
11
+ text?: string;
12
+ }>;
13
+ stop_reason?: string | null;
14
+ }>;
15
+ };
16
+ };
17
+ export type DraftResult = {
18
+ ok: true;
19
+ spec: AgentSpec;
20
+ } | {
21
+ ok: false;
22
+ reason: "cannot";
23
+ message: string;
24
+ } | {
25
+ ok: false;
26
+ reason: "invalid";
27
+ message: string;
28
+ };
29
+ /** What the model needs to name real fields: each block type on the site and its field names. */
30
+ export declare function siteSummary(pages: PageDoc[]): {
31
+ pages: string[];
32
+ blocks: Record<string, string[]>;
33
+ };
34
+ /** `custom:` + the name, lowercased and dashed, made unique among the site's agents. */
35
+ export declare function customIdFor(name: string, taken: Set<string>): string;
36
+ export declare function draftAgentSpec(args: {
37
+ scopeKey: string;
38
+ sentence: string;
39
+ /** The editor's locale; English when absent or unknown. */
40
+ locale?: string;
41
+ client?: DraftClient;
42
+ model?: string;
43
+ log?: Logger;
44
+ }): Promise<DraftResult>;
@@ -0,0 +1,195 @@
1
+ import { buildBlockManifest } from "@avocadostudio-ai/shared";
2
+ import { walkPageFields, fieldText } from "../checks/field-walk.js";
3
+ import { getAnthropicClient } from "../chat/anthropic-planner.js";
4
+ import { defaultModelLookup } from "../chat/model-defaults.js";
5
+ import { getSessionDraft } from "../state/session-state.js";
6
+ import { resolveSiteAgents } from "./settings.js";
7
+ import { parseCustomSpec } from "./spec.js";
8
+ import { compileSpec, RULE_TEMPLATES } from "./templates.js";
9
+ const MAX_SENTENCE = 500;
10
+ const TIMEOUT_MS = 60_000;
11
+ /** Field names shown per block type. Enough to name the right one; bounded so a big site stays cheap. */
12
+ const MAX_FIELDS_PER_TYPE = 40;
13
+ /*
14
+ * Every property is required and an absent one is null — the shape
15
+ * structured outputs accept, written the way plan-json-schema.ts writes it.
16
+ */
17
+ const nullable = (schema) => ({ anyOf: [schema, { type: "null" }] });
18
+ const str = { type: "string" };
19
+ const strList = { type: "array", items: str };
20
+ const DRAFT_JSON_SCHEMA = {
21
+ type: "object",
22
+ additionalProperties: false,
23
+ required: ["cannot", "name", "brief", "trigger", "scope", "detect", "severity"],
24
+ properties: {
25
+ cannot: { ...nullable(str), description: "Set only when no template can check what was asked; say what is missing." },
26
+ name: { type: "string" },
27
+ brief: { type: "string" },
28
+ trigger: {
29
+ type: "object",
30
+ additionalProperties: false,
31
+ required: ["kind", "on", "every", "hour", "weekday"],
32
+ properties: {
33
+ kind: { type: "string", enum: ["manual", "event", "schedule"] },
34
+ on: nullable({ type: "string", enum: ["apply", "publish"] }),
35
+ every: nullable({ type: "string", enum: ["day", "week"] }),
36
+ hour: nullable({ type: "integer" }),
37
+ weekday: nullable({ type: "integer" })
38
+ }
39
+ },
40
+ scope: {
41
+ type: "object",
42
+ additionalProperties: false,
43
+ required: ["slugs", "exclude", "blockTypes"],
44
+ properties: {
45
+ slugs: nullable(strList),
46
+ exclude: nullable(strList),
47
+ blockTypes: nullable(strList)
48
+ }
49
+ },
50
+ detect: {
51
+ type: "array",
52
+ items: {
53
+ type: "object",
54
+ additionalProperties: false,
55
+ required: ["template", "params"],
56
+ properties: {
57
+ template: { type: "string" },
58
+ params: {
59
+ type: "object",
60
+ additionalProperties: false,
61
+ required: ["field", "blockType", "pattern", "caseSensitive", "fields", "title", "days"],
62
+ properties: {
63
+ field: nullable(str),
64
+ blockType: nullable(str),
65
+ pattern: nullable(str),
66
+ caseSensitive: nullable({ type: "boolean" }),
67
+ fields: nullable(strList),
68
+ title: nullable(str),
69
+ days: nullable({ type: "integer" })
70
+ }
71
+ }
72
+ }
73
+ }
74
+ },
75
+ severity: { type: "string", enum: ["error", "warning", "info"] }
76
+ }
77
+ };
78
+ function systemPrompt() {
79
+ const templates = RULE_TEMPLATES.filter((template) => template.runnable && template.id !== "draft-rule")
80
+ .map((template) => `- ${template.id}: ${template.description} Example params: ${JSON.stringify(template.example)}`)
81
+ .join("\n");
82
+ return [
83
+ "You turn one sentence from a website operator into a monitoring agent for their site.",
84
+ "An agent is a list of detect steps, each one of these rule templates:",
85
+ templates,
86
+ "",
87
+ "Rules:",
88
+ "- Use only field names and block types from the site summary you are given. A template matches a field by its own name (the last part of its path), so `date` matches `items[2].date`.",
89
+ "- Set a param only if the template takes it; leave every other param null.",
90
+ "- `text-matches` takes a JavaScript regular expression. Escape it properly; prefer word boundaries (\\b). Leave its `fields` null — that searches every text field — unless the sentence names particular fields.",
91
+ "- Trigger: `schedule` for anything about time passing (dates, staleness, \"every Monday\", \"daily\"); `event` with `on: apply` to check each edit, `on: publish` to check after publishing; `manual` only if the person asked to run it themselves. Schedules are UTC; default to hour 6, and weekday 1 (Monday) for weekly. For `event` leave every/hour/weekday null; for `schedule` leave `on` null.",
92
+ "- Scope narrows the agent to pages (slugs) or block types only when the sentence says so.",
93
+ "- `name`: 2–4 words. `brief`: the job in one sentence. Write both in the `language` you are given, whatever language the sentence or the site is in.",
94
+ "- `severity`: `error` only for something broken or wrong in public; `warning` by default.",
95
+ "- If none of the templates can check what is asked — it needs traffic data, an outside service, judgement about tone, or anything not in the page content — set `cannot` to one sentence saying what is missing, and return an empty `detect`. Never approximate the request with a template that checks something else."
96
+ ].join("\n");
97
+ }
98
+ /** What the model needs to name real fields: each block type on the site and its field names. */
99
+ export function siteSummary(pages) {
100
+ const manifest = buildBlockManifest();
101
+ const blocks = new Map();
102
+ for (const page of pages) {
103
+ for (const entry of walkPageFields(page, manifest)) {
104
+ const name = entry.path.split(".").pop().replace(/\[\d+\]/g, "[]");
105
+ let fields = blocks.get(entry.blockType);
106
+ if (!fields)
107
+ blocks.set(entry.blockType, (fields = new Map()));
108
+ if (fields.size >= MAX_FIELDS_PER_TYPE || fields.has(name))
109
+ continue;
110
+ // One sample value, so "the date field" can be told from "the date label".
111
+ fields.set(name, fieldText(entry.value).slice(0, 40));
112
+ }
113
+ }
114
+ return {
115
+ pages: pages.map((page) => page.slug).slice(0, 100),
116
+ blocks: Object.fromEntries([...blocks].map(([type, fields]) => [type, [...fields].map(([name, sample]) => (sample ? `${name} (e.g. "${sample}")` : name))]))
117
+ };
118
+ }
119
+ function dropNulls(value) {
120
+ if (Array.isArray(value))
121
+ return value.map(dropNulls);
122
+ if (value && typeof value === "object") {
123
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== null).map(([k, v]) => [k, dropNulls(v)]));
124
+ }
125
+ return value;
126
+ }
127
+ /** `custom:` + the name, lowercased and dashed, made unique among the site's agents. */
128
+ export function customIdFor(name, taken) {
129
+ const base = name
130
+ .normalize("NFKD")
131
+ .replace(/[\u0300-\u036f]/g, "")
132
+ .toLowerCase()
133
+ .replace(/[^a-z0-9]+/g, "-")
134
+ .replace(/^-+|-+$/g, "")
135
+ .slice(0, 40) || "agent";
136
+ let id = `custom:${base}`;
137
+ for (let n = 2; taken.has(id); n++)
138
+ id = `custom:${base}-${n}`;
139
+ return id;
140
+ }
141
+ async function callModel(client, model, user) {
142
+ const response = await client.messages.create({
143
+ model,
144
+ max_tokens: 2048,
145
+ system: systemPrompt(),
146
+ output_config: { format: { type: "json_schema", schema: DRAFT_JSON_SCHEMA } },
147
+ messages: [{ role: "user", content: user }]
148
+ }, { signal: AbortSignal.timeout(TIMEOUT_MS) });
149
+ if (response.stop_reason === "max_tokens")
150
+ throw new Error("the draft was cut off");
151
+ const text = response.content.find((block) => block.type === "text")?.text ?? "";
152
+ if (!text.trim())
153
+ throw new Error("the model returned nothing");
154
+ return JSON.parse(text);
155
+ }
156
+ /** The editor's languages. The name and brief are written in the reader's, not guessed from the sentence. */
157
+ const LANGUAGES = { en: "English", de: "German" };
158
+ export async function draftAgentSpec(args) {
159
+ const sentence = args.sentence.trim().slice(0, MAX_SENTENCE);
160
+ const client = args.client ?? getAnthropicClient();
161
+ const model = args.model ?? defaultModelLookup().anthropic.balanced;
162
+ const site = await resolveSiteAgents(args.scopeKey);
163
+ const taken = new Set(site.agents.map((agent) => agent.agentId));
164
+ const summary = siteSummary([...getSessionDraft(args.scopeKey).values()]);
165
+ const language = LANGUAGES[args.locale ?? "en"] ?? "English";
166
+ let user = JSON.stringify({ sentence, language, site: summary });
167
+ let lastError = "";
168
+ // One retry, with the reason, when the draft does not compile: the model
169
+ // usually fixes a bad regex or a param the template does not take.
170
+ for (let attempt = 0; attempt < 2; attempt++) {
171
+ const raw = await callModel(client, model, user);
172
+ if (typeof raw.cannot === "string" && raw.cannot.trim()) {
173
+ return { ok: false, reason: "cannot", message: raw.cannot.trim() };
174
+ }
175
+ const cleaned = dropNulls(raw);
176
+ delete cleaned.cannot;
177
+ const name = typeof cleaned.name === "string" ? cleaned.name : "Agent";
178
+ const parsed = parseCustomSpec({ ...cleaned, id: customIdFor(name, taken) });
179
+ if (parsed.ok) {
180
+ try {
181
+ compileSpec(parsed.spec);
182
+ return { ok: true, spec: parsed.spec };
183
+ }
184
+ catch (error) {
185
+ lastError = error instanceof Error ? error.message : String(error);
186
+ }
187
+ }
188
+ else {
189
+ lastError = parsed.error;
190
+ }
191
+ args.log?.warn({ attempt, error: lastError }, "[agents] drafted spec did not compile");
192
+ user = JSON.stringify({ sentence, language, site: summary, previousAttempt: raw, error: lastError });
193
+ }
194
+ return { ok: false, reason: "invalid", message: lastError };
195
+ }
@@ -0,0 +1,70 @@
1
+ import type { PageDoc } from "@avocadostudio-ai/shared";
2
+ import type { InboxItemRecord } from "../durable/types.ts";
3
+ import { type Logger } from "../logger.ts";
4
+ export type EditSafetyDecision = "revert" | "intended";
5
+ export type RevertOutcome = {
6
+ applied: number;
7
+ /** Issues skipped because the field changed after the edit. */
8
+ changedSince: number;
9
+ failed: Array<{
10
+ path?: string;
11
+ blockId?: string;
12
+ error: string;
13
+ }>;
14
+ previewVersion?: number;
15
+ };
16
+ export type ResolveResult = {
17
+ ok: true;
18
+ item: InboxItemRecord;
19
+ revert?: RevertOutcome;
20
+ } | {
21
+ ok: false;
22
+ code: number;
23
+ error: string;
24
+ };
25
+ /**
26
+ * Each page a revert touched, as it was before and a fingerprint of it after —
27
+ * what "Undo" needs to put the revert back, and to refuse when somebody has
28
+ * changed the page since. Stored on the alert, never sent to the editor.
29
+ */
30
+ export type RevertedPages = Record<string, {
31
+ before: PageDoc | null;
32
+ afterHash: string;
33
+ }>;
34
+ /** A page's content, without the timestamp every write restamps. */
35
+ export declare function contentHash(page: PageDoc | null): string;
36
+ export declare function resolveEditSafetyAlert(args: {
37
+ scopeKey: string;
38
+ itemId: string;
39
+ decision: EditSafetyDecision;
40
+ resolvedBy?: string;
41
+ log?: Logger;
42
+ }): Promise<ResolveResult>;
43
+ /** The stored page snapshots are the server's business; the editor never needs them. */
44
+ export declare function withoutPages(item: InboxItemRecord): InboxItemRecord;
45
+ export type UndoRevertResult = {
46
+ ok: true;
47
+ item: InboxItemRecord;
48
+ previewVersion: number;
49
+ slugs: string[];
50
+ } | {
51
+ ok: false;
52
+ code: number;
53
+ error: string;
54
+ changedSlugs?: string[];
55
+ };
56
+ /**
57
+ * Undo "Put it back": the pages go back to what the edit left, and the alert
58
+ * reopens — the damage is back, so is the publish hold, and the person can
59
+ * decide again (site-ops-agents.md SO-08, "Undo everywhere").
60
+ *
61
+ * All or nothing, and only while every page the revert touched is exactly as
62
+ * the revert left it. A page somebody has edited since is not rolled back
63
+ * under them; the answer names it and points at History instead.
64
+ */
65
+ export declare function undoRevert(args: {
66
+ scopeKey: string;
67
+ itemId: string;
68
+ by?: string;
69
+ log?: Logger;
70
+ }): Promise<UndoRevertResult>;