@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,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>;
@@ -0,0 +1,360 @@
1
+ import { buildBlockManifest } from "@avocadostudio-ai/shared";
2
+ import { evaluateDraftChecks } from "../checks/run-checks.js";
3
+ import { getDurableStore } from "../durable/durable-store-singleton.js";
4
+ import { consoleLogger } from "../logger.js";
5
+ import { applyOpsAtomically, toErrorDetail } from "../ops/ops-engine.js";
6
+ import { bumpVersion, getPage, getSessionDraft, getSiteConfig, pushUndo, pushVersionEntry, schedulePersistState } from "../state/session-state.js";
7
+ import { getSiteAssets } from "../state/site-assets.js";
8
+ import { agentForRule } from "./builtins.js";
9
+ import { isCustomAgentId } from "./spec.js";
10
+ import { withoutPages } from "./edit-safety-resolve.js";
11
+ import { currentValue, fixFieldFor, fixOps, writeFix } from "./fix-writer.js";
12
+ import { listSiteFacts, recordOutcome } from "./learning.js";
13
+ import { resolveSiteAgents } from "./settings.js";
14
+ /** How many closed items the Done tab shows. */
15
+ export const DONE_TAB_LIMIT = 50;
16
+ function entryFromItem(stored) {
17
+ const item = withoutPages(stored);
18
+ const pages = stored.payload?.revertedPages;
19
+ const undoable = stored.status === "resolved" && stored.resolution === "reverted" && !!pages && Object.keys(pages).length > 0;
20
+ return {
21
+ ...(undoable ? { undoable: true } : {}),
22
+ id: `item:${item.id}`,
23
+ source: "item",
24
+ kind: item.kind,
25
+ agent: item.agent,
26
+ object: item.object,
27
+ verb: item.verb,
28
+ title: item.title,
29
+ ...(item.summary ? { summary: item.summary } : {}),
30
+ slugs: item.slugs,
31
+ impact: item.impact ?? 0,
32
+ status: item.status,
33
+ createdAt: item.createdAt,
34
+ updatedAt: item.updatedAt,
35
+ revision: item.revision,
36
+ ...(item.assignee ? { assignee: item.assignee } : {}),
37
+ ...(item.blocksPublish ? { blocksPublish: true } : {}),
38
+ ...(item.payload ? { payload: item.payload } : {}),
39
+ ...(item.resolution ? { resolution: item.resolution } : {}),
40
+ ...(item.resolvedBy ? { resolvedBy: item.resolvedBy } : {}),
41
+ ...(item.resolvedAt != null ? { resolvedAt: item.resolvedAt } : {}),
42
+ ...(item.note ? { note: item.note } : {})
43
+ };
44
+ }
45
+ function pagesLabel(slugs) {
46
+ return slugs.length === 1 ? slugs[0] : `${slugs.length} pages`;
47
+ }
48
+ /** One card per rule, over every open finding of an enabled agent. */
49
+ /** The agent a finding answers to: a custom agent by its own id, a draft rule by its job. */
50
+ export function agentOfFinding(finding) {
51
+ return isCustomAgentId(finding.agent) ? finding.agent : agentForRule(finding.ruleId);
52
+ }
53
+ export function groupFindings(findings, enabled) {
54
+ const byRule = new Map();
55
+ for (const finding of findings) {
56
+ if (finding.status !== "open")
57
+ continue;
58
+ if (!enabled(agentOfFinding(finding)))
59
+ continue;
60
+ const list = byRule.get(finding.ruleId) ?? [];
61
+ list.push(finding);
62
+ byRule.set(finding.ruleId, list);
63
+ }
64
+ const out = [];
65
+ for (const [ruleId, list] of byRule) {
66
+ list.sort((a, b) => b.impact - a.impact);
67
+ const first = list[0];
68
+ const slugs = [...new Set(list.map((f) => f.slug))];
69
+ const fixable = list.filter((f) => (f.proposedOps?.length ?? 0) > 0 || (f.fix?.ops.length ?? 0) > 0).length;
70
+ const writable = list.filter((f) => fixFieldFor(f.ruleId) && !f.fix).length;
71
+ const agent = agentOfFinding(first);
72
+ out.push({
73
+ id: `findings:${ruleId}`,
74
+ source: "findings",
75
+ kind: "finding",
76
+ agent,
77
+ object: pagesLabel(slugs),
78
+ verb: list.length === 1 ? `found: ${first.title}` : `found ${list.length} like: ${first.title}`,
79
+ title: list.length === 1
80
+ ? `${first.title} on ${first.slug}`
81
+ : `${first.title} — and ${list.length - 1} more like it on ${pagesLabel(slugs)}`,
82
+ ...(first.detail ? { summary: first.detail } : {}),
83
+ slugs,
84
+ impact: first.impact,
85
+ status: "open",
86
+ createdAt: Math.min(...list.map((f) => f.firstSeenAt)),
87
+ updatedAt: Math.max(...list.map((f) => f.lastSeenAt)),
88
+ findings: list,
89
+ fixable,
90
+ ...(writable > 0 ? { writable } : {})
91
+ });
92
+ }
93
+ return out;
94
+ }
95
+ export async function buildInboxView(scopeKey, now = Date.now()) {
96
+ const store = getDurableStore();
97
+ const agents = await resolveSiteAgents(scopeKey, store);
98
+ const isOn = (agentId) => agents.agents.find((a) => a.agentId === agentId)?.enabled ?? false;
99
+ await store.wakeSnoozedFindings(scopeKey, now);
100
+ const [openItems, closedItems, findings] = await Promise.all([
101
+ store.listInboxItems({ scopeKey, status: "open", limit: 500 }),
102
+ store.listInboxItems({ scopeKey, status: ["resolved", "expired"], limit: DONE_TAB_LIMIT }),
103
+ store.listFindings({ scopeKey, status: "open" })
104
+ ]);
105
+ // Held back while it is still in front of the person in the chat turn —
106
+ // but it holds publish from the first second regardless.
107
+ const visibleOpen = openItems.filter((item) => item.visibleFrom == null || item.visibleFrom <= now);
108
+ const entries = [
109
+ ...visibleOpen.map(entryFromItem),
110
+ ...groupFindings(findings, isOn),
111
+ ...closedItems.map(entryFromItem)
112
+ ];
113
+ entries.sort((a, b) => {
114
+ if (a.status === "open" && b.status !== "open")
115
+ return -1;
116
+ if (b.status === "open" && a.status !== "open")
117
+ return 1;
118
+ if (a.status === "open")
119
+ return b.impact - a.impact || b.updatedAt - a.updatedAt;
120
+ return (b.resolvedAt ?? b.updatedAt) - (a.resolvedAt ?? a.updatedAt);
121
+ });
122
+ return {
123
+ entries,
124
+ counts: {
125
+ needsYou: entries.filter((e) => e.status === "open").length,
126
+ done: entries.filter((e) => e.status !== "open").length
127
+ },
128
+ blocking: openItems
129
+ .filter((item) => item.blocksPublish)
130
+ .map((item) => ({ id: item.id, slugs: item.slugs, title: item.title })),
131
+ agents,
132
+ cursor: now
133
+ };
134
+ }
135
+ /**
136
+ * Open alerts that hold publishing any of `slugs`. With no slugs, every open
137
+ * hold for the site — a whole-site publish ships every page.
138
+ */
139
+ export async function publishHolds(scopeKey, slugs) {
140
+ const open = await getDurableStore().listInboxItems({ scopeKey, status: "open", limit: 500 });
141
+ const wanted = slugs && slugs.length > 0 ? new Set(slugs) : null;
142
+ return open.filter((item) => item.blocksPublish && (!wanted || item.slugs.some((slug) => wanted.has(slug))));
143
+ }
144
+ async function freshFindings(scopeKey, slugs) {
145
+ const assets = await getSiteAssets();
146
+ return evaluateDraftChecks({
147
+ scopeKey,
148
+ pages: [...getSessionDraft(scopeKey).values()],
149
+ manifest: buildBlockManifest(),
150
+ siteConfig: getSiteConfig(scopeKey),
151
+ slugs,
152
+ ...(assets ? { assets } : {})
153
+ }).findings;
154
+ }
155
+ /**
156
+ * Approve the fixes of one or more findings.
157
+ *
158
+ * A finding's ops were written when the checker last ran, and the page may
159
+ * have changed since. So approving re-runs the rule over the page first
160
+ * (rules are free and instant) and applies the ops the rule proposes *now*:
161
+ * if the problem is gone the finding closes as already fixed, and if it is
162
+ * still there the fix is written against the page as it is (SO-17).
163
+ */
164
+ export async function approveFindings(args) {
165
+ const store = getDurableStore();
166
+ const outcome = { applied: [], alreadyFixed: [], failed: [] };
167
+ const records = [];
168
+ for (const id of args.ids) {
169
+ const record = await store.getFinding(id);
170
+ if (!record || record.scopeKey !== args.scopeKey) {
171
+ outcome.failed.push({ id, error: "finding not found" });
172
+ continue;
173
+ }
174
+ if (record.status !== "open") {
175
+ outcome.failed.push({ id, error: `finding is ${record.status}` });
176
+ continue;
177
+ }
178
+ records.push(record);
179
+ }
180
+ if (records.length === 0)
181
+ return outcome;
182
+ const fresh = new Map((await freshFindings(args.scopeKey, [...new Set(records.map((r) => r.slug))])).map((f) => [f.fingerprint, f]));
183
+ const touched = new Map();
184
+ const appliedOps = [];
185
+ let lastSlug;
186
+ for (const record of records) {
187
+ const current = fresh.get(record.fingerprint);
188
+ if (!current) {
189
+ await store.setFindingStatus(record.id, "fixed");
190
+ outcome.alreadyFixed.push(record.id);
191
+ continue;
192
+ }
193
+ const page = getPage(args.scopeKey, record.slug);
194
+ let ops = current.proposedOps ?? [];
195
+ let edited = false;
196
+ if (ops.length === 0 && record.fix) {
197
+ // A written fix is applied only to the value it was written against
198
+ // (SO-17). Somebody changed the field since: the fix is stale, and is
199
+ // dropped rather than laid over their change.
200
+ if (!page || currentValue(record, page) !== record.fix.base) {
201
+ await store.setFindingFix(record.id, null);
202
+ outcome.failed.push({ id: record.id, error: "the page changed since this fix was written — write it again" });
203
+ continue;
204
+ }
205
+ const edit = args.edits?.[record.id]?.replace(/\s+/g, " ").trim();
206
+ edited = !!edit && edit !== record.fix.text;
207
+ ops = edited ? fixOps(record, edit) : record.fix.ops;
208
+ }
209
+ if (ops.length === 0) {
210
+ outcome.failed.push({ id: record.id, error: "this finding has no fix to apply" });
211
+ continue;
212
+ }
213
+ try {
214
+ const before = page ? structuredClone(page) : null;
215
+ await applyOpsAtomically(args.scopeKey, ops);
216
+ if (!touched.has(record.slug))
217
+ touched.set(record.slug, before);
218
+ appliedOps.push(...ops);
219
+ lastSlug = record.slug;
220
+ await store.setFindingStatus(record.id, "fixed");
221
+ outcome.applied.push(record.id);
222
+ await recordOutcome({
223
+ scopeKey: args.scopeKey,
224
+ traceId: record.id,
225
+ slug: record.slug,
226
+ request: record.title,
227
+ proposed: edited && record.fix ? record.fix.ops : ops,
228
+ outcome: edited ? "edited" : "accepted",
229
+ applied: ops,
230
+ log: args.log
231
+ });
232
+ }
233
+ catch (error) {
234
+ outcome.failed.push({ id: record.id, error: toErrorDetail(error) });
235
+ }
236
+ }
237
+ if (appliedOps.length > 0 && lastSlug) {
238
+ for (const [slug, snapshot] of touched)
239
+ pushUndo(args.scopeKey, slug, snapshot);
240
+ const previewVersion = bumpVersion(args.scopeKey);
241
+ const snapshot = getPage(args.scopeKey, lastSlug);
242
+ pushVersionEntry(args.scopeKey, {
243
+ version: previewVersion,
244
+ slug: lastSlug,
245
+ summary: `Approved ${outcome.applied.length} ${outcome.applied.length === 1 ? "fix" : "fixes"} from the Inbox`,
246
+ opTypes: appliedOps.map((op) => op.op),
247
+ opCount: appliedOps.length,
248
+ source: "direct",
249
+ actor: { kind: "user", ...(args.by ? { id: args.by } : {}) },
250
+ snapshot: snapshot ? structuredClone(snapshot) : null
251
+ });
252
+ schedulePersistState(args.log ?? consoleLogger());
253
+ outcome.previewVersion = previewVersion;
254
+ }
255
+ return outcome;
256
+ }
257
+ /** How many fixes one request may write: each is a model call. */
258
+ export const WRITE_FIXES_MAX = 20;
259
+ /**
260
+ * Write fixes for open findings, on request (agents/fix-writer.ts). Nothing
261
+ * is applied: each fix is stored on its finding for a person to approve,
262
+ * edit or reject. Site facts go to the model with every request, so a fix
263
+ * follows the site's own rules about wording.
264
+ */
265
+ export async function writeFixes(args) {
266
+ const store = getDurableStore();
267
+ const outcome = { written: [], failed: [] };
268
+ const facts = args.deps?.facts ?? (await listSiteFacts(args.scopeKey)).map((fact) => fact.value);
269
+ for (const id of args.ids.slice(0, WRITE_FIXES_MAX)) {
270
+ const record = await store.getFinding(id);
271
+ if (!record || record.scopeKey !== args.scopeKey) {
272
+ outcome.failed.push({ id, error: "finding not found" });
273
+ continue;
274
+ }
275
+ if (record.status !== "open") {
276
+ outcome.failed.push({ id, error: `finding is ${record.status}` });
277
+ continue;
278
+ }
279
+ const page = getPage(args.scopeKey, record.slug);
280
+ if (!page) {
281
+ outcome.failed.push({ id, error: `no page ${record.slug}` });
282
+ continue;
283
+ }
284
+ try {
285
+ const result = await writeFix(record, page, { ...args.deps, facts });
286
+ if (!result.ok) {
287
+ outcome.failed.push({ id, error: result.error });
288
+ continue;
289
+ }
290
+ await store.setFindingFix(id, result.fix);
291
+ outcome.written.push({ id, text: result.fix.text });
292
+ }
293
+ catch (error) {
294
+ args.log?.warn({ err: String(error), id }, "[inbox] writing a fix failed");
295
+ outcome.failed.push({ id, error: toErrorDetail(error) });
296
+ }
297
+ }
298
+ return outcome;
299
+ }
300
+ /**
301
+ * Reject findings, with the reason a person gave. Recorded as a correction so
302
+ * the rule's track record counts it; the finding is dismissed, and a dismissal
303
+ * survives every re-run.
304
+ */
305
+ export async function rejectFindings(args) {
306
+ const store = getDurableStore();
307
+ const outcome = { applied: [], alreadyFixed: [], failed: [] };
308
+ for (const id of args.ids) {
309
+ const record = await store.getFinding(id);
310
+ if (!record || record.scopeKey !== args.scopeKey) {
311
+ outcome.failed.push({ id, error: "finding not found" });
312
+ continue;
313
+ }
314
+ await store.setFindingStatus(id, "dismissed");
315
+ outcome.applied.push(id);
316
+ await recordOutcome({
317
+ scopeKey: args.scopeKey,
318
+ traceId: record.id,
319
+ slug: record.slug,
320
+ request: `${record.ruleId}: ${record.title} — rejected${args.by ? ` by ${args.by}` : ""}: ${args.reason}`,
321
+ proposed: record.proposedOps ?? [],
322
+ outcome: "rejected",
323
+ log: args.log
324
+ });
325
+ }
326
+ return outcome;
327
+ }
328
+ /**
329
+ * The publish gate (site-ops-agents.md SO-18): an open Edit safety alert on a
330
+ * page that is about to ship refuses the publish, with no override flag. The
331
+ * way through is one click in the alert — revert, or say it was meant — so the
332
+ * one moment every change is guaranteed to be looked at is not skippable.
333
+ *
334
+ * Returns the 409 to send, or null when nothing holds. With no selection the
335
+ * whole draft ships, so any hold on the site counts.
336
+ */
337
+ export async function publishHoldResponse(scopeKey, selectedSlugs, log) {
338
+ let holds;
339
+ try {
340
+ holds = await publishHolds(scopeKey, selectedSlugs ?? undefined);
341
+ }
342
+ catch (error) {
343
+ // A store that cannot be read holds nothing: the gate must not become a
344
+ // way for a database outage to stop every publish.
345
+ log?.warn({ err: error }, "[inbox] publish holds unavailable; publishing without the Edit safety gate");
346
+ return null;
347
+ }
348
+ if (holds.length === 0)
349
+ return null;
350
+ const slugs = [...new Set(holds.flatMap((hold) => hold.slugs))];
351
+ return {
352
+ code: 409,
353
+ body: {
354
+ status: "failed",
355
+ error: `Publishing is held: Edit safety found damage on ${slugs.join(", ")} that nobody has decided on yet. Revert it or mark it intended, then publish.`,
356
+ errorCode: "edit_safety_hold",
357
+ holds: holds.map((hold) => ({ id: hold.id, slugs: hold.slugs, title: hold.title }))
358
+ }
359
+ };
360
+ }
@@ -0,0 +1,32 @@
1
+ import type { Operation } from "@avocadostudio-ai/shared";
2
+ import type { CorrectionOutcome, MemoryRecord } from "../durable/types.ts";
3
+ import type { Logger } from "../logger.ts";
4
+ /**
5
+ * Record what a person did with something an agent or the planner proposed:
6
+ * approved it, edited it first, rejected it, or undid it. Best-effort — a
7
+ * store failure is logged and never fails the action that produced it.
8
+ */
9
+ export declare function recordOutcome(args: {
10
+ scopeKey: string;
11
+ traceId: string;
12
+ slug?: string;
13
+ request: string;
14
+ proposed: Operation[];
15
+ outcome: CorrectionOutcome;
16
+ applied?: Operation[];
17
+ log?: Logger;
18
+ }): Promise<void>;
19
+ /** The kinds a person can state about their site. `decision` is an agent's to write. */
20
+ export declare const SITE_FACT_KINDS: readonly ["fact", "preference", "glossary"];
21
+ export type SiteFactKind = (typeof SITE_FACT_KINDS)[number];
22
+ export declare function isSiteFactKind(value: unknown): value is SiteFactKind;
23
+ /** How many facts reach the planner. The rest stay visible in the editor. */
24
+ export declare const SITE_FACTS_IN_PROMPT = 40;
25
+ /** Longer than this and a "fact" is a document; it belongs in the site's content. */
26
+ export declare const SITE_FACT_MAX_CHARS = 280;
27
+ export declare function listSiteFacts(scopeKey: string, limit?: number): Promise<MemoryRecord[]>;
28
+ /**
29
+ * The lines the planner is given. Never throws — a planner turn does not fail
30
+ * because memory could not be read; it plans without it, as it did before.
31
+ */
32
+ export declare function siteFactsForPlanner(scopeKey: string, log?: Logger): Promise<string[]>;
@@ -0,0 +1,71 @@
1
+ import { getDurableStore, noteDurableFailure } from "../durable/durable-store-singleton.js";
2
+ /*
3
+ * Memory and corrections, wired in (site-ops-agents.md SO-10, SO-11).
4
+ *
5
+ * Both tables shipped with the durable store and had no caller outside it.
6
+ * This module is the only way the rest of the brain reaches them, so each
7
+ * answer a person gives is recorded one way, and each read the planner makes
8
+ * is fenced the same way.
9
+ */
10
+ /**
11
+ * Record what a person did with something an agent or the planner proposed:
12
+ * approved it, edited it first, rejected it, or undid it. Best-effort — a
13
+ * store failure is logged and never fails the action that produced it.
14
+ */
15
+ export async function recordOutcome(args) {
16
+ try {
17
+ await getDurableStore().recordCorrection({
18
+ traceId: args.traceId,
19
+ scopeKey: args.scopeKey,
20
+ ...(args.slug ? { slug: args.slug } : {}),
21
+ request: args.request.slice(0, 2_000),
22
+ proposed: args.proposed,
23
+ outcome: args.outcome,
24
+ ...(args.applied ? { applied: args.applied } : {})
25
+ });
26
+ }
27
+ catch (error) {
28
+ noteDurableFailure(error instanceof Error ? error.message : String(error));
29
+ args.log?.warn({ err: error, traceId: args.traceId }, "[learning] correction not recorded");
30
+ }
31
+ }
32
+ // ---------------------------------------------------------------------------
33
+ // Site facts
34
+ // ---------------------------------------------------------------------------
35
+ /** The kinds a person can state about their site. `decision` is an agent's to write. */
36
+ export const SITE_FACT_KINDS = ["fact", "preference", "glossary"];
37
+ export function isSiteFactKind(value) {
38
+ return typeof value === "string" && SITE_FACT_KINDS.includes(value);
39
+ }
40
+ /** How many facts reach the planner. The rest stay visible in the editor. */
41
+ export const SITE_FACTS_IN_PROMPT = 40;
42
+ /** Longer than this and a "fact" is a document; it belongs in the site's content. */
43
+ export const SITE_FACT_MAX_CHARS = 280;
44
+ export async function listSiteFacts(scopeKey, limit = 200) {
45
+ return getDurableStore().listMemory({
46
+ scopeKey,
47
+ scope: "site",
48
+ kind: [...SITE_FACT_KINDS],
49
+ status: "active",
50
+ limit
51
+ });
52
+ }
53
+ /**
54
+ * The lines the planner is given. Never throws — a planner turn does not fail
55
+ * because memory could not be read; it plans without it, as it did before.
56
+ */
57
+ export async function siteFactsForPlanner(scopeKey, log) {
58
+ try {
59
+ const facts = await listSiteFacts(scopeKey, SITE_FACTS_IN_PROMPT);
60
+ if (facts.length === 0)
61
+ return [];
62
+ void getDurableStore()
63
+ .touchMemory(facts.map((fact) => fact.id))
64
+ .catch(() => { });
65
+ return facts.map((fact) => fact.value);
66
+ }
67
+ catch (error) {
68
+ log?.warn({ err: error }, "[learning] site facts unavailable; planning without them");
69
+ return [];
70
+ }
71
+ }
@@ -0,0 +1,76 @@
1
+ import type { PageDoc } from "@avocadostudio-ai/shared";
2
+ import type { DurableStore } from "../durable/types.ts";
3
+ export type MentionIntent = "run" | "report" | "pause" | "resume" | "week" | "list" | "help";
4
+ export type MentionFinding = {
5
+ slug: string;
6
+ title: string;
7
+ };
8
+ export type MentionResult = {
9
+ handled: false;
10
+ } | {
11
+ handled: true;
12
+ kind: "ran";
13
+ agentId: string;
14
+ slug?: string;
15
+ opened: number;
16
+ closed: number;
17
+ open: number;
18
+ top: MentionFinding[];
19
+ } | {
20
+ handled: true;
21
+ kind: "report";
22
+ agentId: string;
23
+ open: number;
24
+ top: MentionFinding[];
25
+ } | {
26
+ handled: true;
27
+ kind: "paused" | "resumed" | "already";
28
+ agentId: string;
29
+ enabled: boolean;
30
+ } | {
31
+ handled: true;
32
+ kind: "week";
33
+ since: number;
34
+ agents: Array<{
35
+ agentId: string;
36
+ found: number;
37
+ fixed: number;
38
+ open: number;
39
+ }>;
40
+ caught: number;
41
+ reverted: number;
42
+ } | {
43
+ handled: true;
44
+ kind: "list";
45
+ agents: Array<{
46
+ agentId: string;
47
+ handle: string;
48
+ enabled: boolean;
49
+ open: number;
50
+ }>;
51
+ } | {
52
+ handled: true;
53
+ kind: "help";
54
+ agentId?: string;
55
+ handles: string[];
56
+ } | {
57
+ handled: true;
58
+ kind: "refused";
59
+ agentId: string;
60
+ reason: "not_runnable" | "off" | "needs_owner" | "unknown_page";
61
+ slug?: string;
62
+ };
63
+ /** An agent's handle in chat: its id, without the `custom:` prefix. */
64
+ export declare function handleOf(agentId: string): string;
65
+ export declare function parseIntent(rest: string, all: boolean): MentionIntent;
66
+ /** The first `/path` in the message that is a page on this site. */
67
+ export declare function pageIn(rest: string, pages: PageDoc[]): {
68
+ slug?: string;
69
+ unknown?: string;
70
+ };
71
+ export declare function handleMention(args: {
72
+ scopeKey: string;
73
+ message: string;
74
+ store?: DurableStore;
75
+ now?: number;
76
+ }): Promise<MentionResult>;