@cloudhal/mcp-server 0.0.0-stage → 0.1.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 (105) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +18 -2
  3. package/dist/src/analytics-reports.d.ts +164 -0
  4. package/dist/src/analytics-reports.js +632 -0
  5. package/dist/src/analytics-tools.d.ts +4 -0
  6. package/dist/src/analytics-tools.js +597 -0
  7. package/dist/src/asset-tools.d.ts +5 -0
  8. package/dist/src/asset-tools.js +685 -0
  9. package/dist/src/body-tools.d.ts +46 -0
  10. package/dist/src/body-tools.js +708 -0
  11. package/dist/src/changes.d.ts +10 -0
  12. package/dist/src/changes.js +112 -0
  13. package/dist/src/chrome-tools.d.ts +8 -0
  14. package/dist/src/chrome-tools.js +207 -0
  15. package/dist/src/comment-tools.d.ts +9 -0
  16. package/dist/src/comment-tools.js +743 -0
  17. package/dist/src/comments.d.ts +184 -0
  18. package/dist/src/comments.js +408 -0
  19. package/dist/src/common.d.ts +258 -0
  20. package/dist/src/common.js +488 -0
  21. package/dist/src/content-shared.d.ts +50 -0
  22. package/dist/src/content-shared.js +102 -0
  23. package/dist/src/design-tools.d.ts +9 -0
  24. package/dist/src/design-tools.js +349 -0
  25. package/dist/src/elevation-tools.d.ts +3 -0
  26. package/dist/src/elevation-tools.js +331 -0
  27. package/dist/src/entry-terms.d.ts +14 -0
  28. package/dist/src/entry-terms.js +51 -0
  29. package/dist/src/entry-tools.d.ts +4 -0
  30. package/dist/src/entry-tools.js +442 -0
  31. package/dist/src/http.d.ts +48 -0
  32. package/dist/src/http.js +101 -0
  33. package/dist/src/index.d.ts +10 -0
  34. package/dist/src/index.js +10 -0
  35. package/dist/src/menu-tools.d.ts +41 -0
  36. package/dist/src/menu-tools.js +1058 -0
  37. package/dist/src/page-tools.d.ts +5 -0
  38. package/dist/src/page-tools.js +311 -0
  39. package/dist/src/preview-doc.d.ts +9 -0
  40. package/dist/src/preview-doc.js +56 -0
  41. package/dist/src/preview-view.d.ts +19 -0
  42. package/dist/src/preview-view.js +71 -0
  43. package/dist/src/publish-tools.d.ts +10 -0
  44. package/dist/src/publish-tools.js +248 -0
  45. package/dist/src/render/cdp.d.ts +29 -0
  46. package/dist/src/render/cdp.js +190 -0
  47. package/dist/src/render/checks/components.d.ts +5 -0
  48. package/dist/src/render/checks/components.js +95 -0
  49. package/dist/src/render/checks/content.d.ts +5 -0
  50. package/dist/src/render/checks/content.js +63 -0
  51. package/dist/src/render/checks/contrast.d.ts +5 -0
  52. package/dist/src/render/checks/contrast.js +72 -0
  53. package/dist/src/render/checks/index.d.ts +1 -0
  54. package/dist/src/render/checks/index.js +29 -0
  55. package/dist/src/render/checks/layout.d.ts +5 -0
  56. package/dist/src/render/checks/layout.js +44 -0
  57. package/dist/src/render/checks/motion.d.ts +5 -0
  58. package/dist/src/render/checks/motion.js +66 -0
  59. package/dist/src/render/checks/navigation.d.ts +5 -0
  60. package/dist/src/render/checks/navigation.js +78 -0
  61. package/dist/src/render/checks/prelude.d.ts +1 -0
  62. package/dist/src/render/checks/prelude.js +64 -0
  63. package/dist/src/render/limiter.d.ts +11 -0
  64. package/dist/src/render/limiter.js +29 -0
  65. package/dist/src/render/page.d.ts +19 -0
  66. package/dist/src/render/page.js +135 -0
  67. package/dist/src/render/types.d.ts +89 -0
  68. package/dist/src/render/types.js +140 -0
  69. package/dist/src/render-tools.d.ts +14 -0
  70. package/dist/src/render-tools.js +314 -0
  71. package/dist/src/render.d.ts +14 -0
  72. package/dist/src/render.js +33 -0
  73. package/dist/src/schedule-tools.d.ts +2 -0
  74. package/dist/src/schedule-tools.js +1596 -0
  75. package/dist/src/schedules.d.ts +292 -0
  76. package/dist/src/schedules.js +890 -0
  77. package/dist/src/schema-transforms.d.ts +14 -0
  78. package/dist/src/schema-transforms.js +50 -0
  79. package/dist/src/search-tools.d.ts +2 -0
  80. package/dist/src/search-tools.js +112 -0
  81. package/dist/src/server-approvals.d.ts +51 -0
  82. package/dist/src/server-approvals.js +273 -0
  83. package/dist/src/server-call.d.ts +28 -0
  84. package/dist/src/server-call.js +259 -0
  85. package/dist/src/server-instructions.d.ts +4 -0
  86. package/dist/src/server-instructions.js +74 -0
  87. package/dist/src/server-resources.d.ts +4 -0
  88. package/dist/src/server-resources.js +83 -0
  89. package/dist/src/server-schema.d.ts +20 -0
  90. package/dist/src/server-schema.js +82 -0
  91. package/dist/src/server.d.ts +80 -0
  92. package/dist/src/server.js +135 -0
  93. package/dist/src/stdio.d.ts +9 -0
  94. package/dist/src/stdio.js +18 -0
  95. package/dist/src/surface-tools.d.ts +4 -0
  96. package/dist/src/surface-tools.js +357 -0
  97. package/dist/src/taxonomy-tools.d.ts +16 -0
  98. package/dist/src/taxonomy-tools.js +1433 -0
  99. package/dist/src/terms.d.ts +47 -0
  100. package/dist/src/terms.js +271 -0
  101. package/dist/src/tools.d.ts +39 -0
  102. package/dist/src/tools.js +511 -0
  103. package/dist/src/translation-tools.d.ts +3 -0
  104. package/dist/src/translation-tools.js +422 -0
  105. package/package.json +86 -3
@@ -0,0 +1,1433 @@
1
+ /**
2
+ * Taxonomy, term and redirect tools (RFC-0002 §6.2). Reading the vocabulary and proposing terms are
3
+ * content tools; renaming, merging, retiring, moving, reviewing, redirects and taxonomy settings are
4
+ * structural (`site:write`) and always wait for a person, whose prompt lists the articles touched
5
+ * and every redirect. Each mutation is one store operation followed by exactly one snapshot.
6
+ */
7
+ import {
8
+ reclaimProblem,
9
+ redirectTargetProblem,
10
+ termCreation,
11
+ termReviewProblem,
12
+ } from "@cloudhal/agent-runtime";
13
+
14
+ import {
15
+ RedirectSchema,
16
+ SnapshotSchema,
17
+ TaxonomySchema,
18
+ TermSchema,
19
+ } from "@cloudhal/core-contracts";
20
+
21
+ import {
22
+ REPORT_THRESHOLDS as CORE_REPORT_THRESHOLDS,
23
+ canonicalJson,
24
+ taxonomyReport as coreTaxonomyReport,
25
+ foldName,
26
+ slugify,
27
+ splitTarget,
28
+ termName,
29
+ } from "@cloudhal/core-ts";
30
+ import {
31
+ autoSnapshot,
32
+ id,
33
+
34
+ obj,
35
+ quote,
36
+ requireSite,
37
+ SLUG_PATTERN,
38
+ str,
39
+ strip,
40
+
41
+
42
+ ToolError,
43
+ toToolError,
44
+ } from "./common.js";
45
+
46
+ const TAXONOMY = strip(TaxonomySchema);
47
+ const TERM = strip(TermSchema);
48
+ const REDIRECT = strip(RedirectSchema);
49
+ const SNAPSHOT = strip(SnapshotSchema);
50
+ const TERM_DEFS = (TERM.$defs ?? {}) ;
51
+ const TAX_PROPS = TAXONOMY.properties ;
52
+ const REDIRECT_PROPS = REDIRECT.properties ;
53
+
54
+ /** A sub-schema that uses the Term contract's `$defs` (hoisted to the tool schema's root). */
55
+ const withTermDefs = (schema ) => ({ ...schema, $defs: TERM_DEFS });
56
+ /** A tool schema whose properties come from the Taxonomy contract (its `$defs` at the root). */
57
+ const withTaxonomyDefs = (schema ) => ({
58
+ ...schema,
59
+ $defs: TAXONOMY.$defs ,
60
+ });
61
+
62
+ const READ = {
63
+ readOnlyHint: true,
64
+ destructiveHint: false,
65
+ idempotentHint: true,
66
+ openWorldHint: false,
67
+ } ;
68
+ const WRITE = {
69
+ readOnlyHint: false,
70
+ destructiveHint: false,
71
+ idempotentHint: false,
72
+ openWorldHint: false,
73
+ } ;
74
+
75
+ const USAGE = obj(
76
+ {
77
+ total: { type: "integer", description: "Entries assigned (any status)" },
78
+ published: { type: "integer", description: "Published entries assigned" },
79
+ lastUsedAt: { type: "string", description: "Latest update among those entries" },
80
+ },
81
+ ["total", "published"],
82
+ );
83
+ const TERM_VIEW = {
84
+ ...TERM,
85
+ properties: { ...(TERM.properties ), usage: USAGE },
86
+ required: [...(TERM.required ), "usage"],
87
+ };
88
+ const TERM_REF = obj(
89
+ { termId: { type: "string" }, slug: { type: "string" }, name: { type: "string" } },
90
+ ["termId", "slug", "name"],
91
+ );
92
+ const OPERATION = obj(
93
+ {
94
+ opId: { type: "string", description: "Operation id (audit, snapshot metadata)" },
95
+ entries: { type: "integer", description: "Articles re-filed by the operation" },
96
+ terms: { type: "array", items: TERM, description: "Terms written, as stored" },
97
+ redirects: {
98
+ type: "array",
99
+ items: REDIRECT,
100
+ description: "Redirects created or updated, as stored",
101
+ },
102
+ },
103
+ ["opId", "entries", "terms", "redirects"],
104
+ );
105
+ const GOVERNANCE_OUT = obj({ operation: OPERATION, snapshot: SNAPSHOT }, ["operation", "snapshot"]);
106
+ const TAXONOMY_KEY = str("Taxonomy key, e.g. category or tag (list_taxonomies)", {
107
+ pattern: SLUG_PATTERN,
108
+ maxLength: 128,
109
+ });
110
+
111
+ // ───────────────────────────── helpers ─────────────────────────────
112
+
113
+ function isPublic(t ) {
114
+ return t.status === "active" && t.visibility === "public";
115
+ }
116
+
117
+ function archivePath(tax , slug ) {
118
+ return `${tax.archive.basePath}/${slug}`;
119
+ }
120
+
121
+ async function requireTerm(ctx , siteId , termId ) {
122
+ const term = await ctx.store.getTerm(termId);
123
+ if (!term || term.siteId !== siteId)
124
+ throw new ToolError("not_found", `term ${termId} not found; call list_terms or resolve_terms`);
125
+ return term;
126
+ }
127
+
128
+ async function requireTaxonomy(ctx , siteId , key ) {
129
+ const tax = await ctx.store.getTaxonomyByKey(siteId, key);
130
+ if (!tax) {
131
+ const keys = (await ctx.store.listTaxonomies(siteId)).map((t) => t.key);
132
+ throw new ToolError(
133
+ "unknown_taxonomy",
134
+ `this site has no taxonomy "${key}" (it has: ${keys.sort().join(", ") || "none"}); call list_taxonomies`,
135
+ );
136
+ }
137
+ return tax;
138
+ }
139
+
140
+ async function taxonomyOf(ctx , term ) {
141
+ const tax = await ctx.store.getTaxonomy(term.taxonomyId);
142
+ if (!tax) throw new ToolError("not_found", `taxonomy ${term.taxonomyId} not found`);
143
+ return tax;
144
+ }
145
+
146
+ async function defaultLocale(ctx , siteId ) {
147
+ return (await ctx.store.getSite(siteId))?.defaultLocale;
148
+ }
149
+
150
+ /** `tag "Machine learning" (machine-learning)`, quoted for prompts. */
151
+ function named(tax , t , locale ) {
152
+ return `${tax.key} "${quote(termName(t, locale), 50)}" (${t.slug})`;
153
+ }
154
+
155
+ /** Articles assigned to any of `ids` in taxonomy `key` (by filter, in chunks of ids). */
156
+ async function entriesUsing(
157
+ ctx ,
158
+ siteId ,
159
+ key ,
160
+ ids ,
161
+ ) {
162
+ const seen = new Set ();
163
+ for (let i = 0; i < ids.length; i += 10) {
164
+ const chunk = ids.slice(i, i + 10);
165
+ for (const e of await ctx.store.listContentEntries(siteId, {
166
+ filter: `${key}:[${chunk.join(",")}]`,
167
+ }))
168
+ seen.add(e.id);
169
+ }
170
+ return seen.size;
171
+ }
172
+
173
+ const articles = (n ) => `${n} article${n === 1 ? "" : "s"}`;
174
+
175
+ /**
176
+ * The redirect lines a person sees before a governance operation: new redirects, redirects
177
+ * re-pointed onto the new path (no chains) and redirects removed because their path is served again.
178
+ * Mirrors the store's redirect plan (RFC-0002 §4.3, invariant 14).
179
+ */
180
+ async function redirectLines(
181
+ ctx ,
182
+ siteId ,
183
+ plan
184
+
185
+
186
+
187
+ ,
188
+ ) {
189
+ const existing = await ctx.store.listRedirects(siteId);
190
+ const out = [];
191
+ const gone = new Set ();
192
+ for (const p of plan.serve ?? [])
193
+ for (const r of existing)
194
+ if (r.from === p || r.from.startsWith(`${p}/`)) {
195
+ gone.add(r.id);
196
+ out.push(`− redirect ${r.from} → ${quote(r.to, 80)} (its path is served again)`);
197
+ }
198
+ const repoint = (from , to ) => {
199
+ for (const r of existing) {
200
+ if (gone.has(r.id)) continue;
201
+ const target = splitTarget(r.to);
202
+ if (!target) continue;
203
+ let next ;
204
+ if (target.path === from) next = `${to}${target.suffix}`;
205
+ else if (target.path.startsWith(`${from}/`))
206
+ next = `${to}${target.path.slice(from.length)}${target.suffix}`;
207
+ if (next === undefined) continue;
208
+ if (splitTarget(next)?.path === r.from) {
209
+ gone.add(r.id);
210
+ out.push(`− redirect ${r.from} → ${quote(r.to, 80)} (it would point to itself)`);
211
+ } else out.push(`~ redirect ${r.from} → ${quote(next, 80)} (was ${quote(r.to, 80)})`);
212
+ }
213
+ };
214
+ for (const { from, to } of plan.repoints ?? []) repoint(from, to);
215
+ for (const { from, to } of plan.moves ?? []) {
216
+ repoint(from, to);
217
+ out.push(`+ redirect ${from} → ${to} (301; pages and feed below it follow)`);
218
+ }
219
+ return out;
220
+ }
221
+
222
+ const withLines = (head , lines ) =>
223
+ lines.length ? `${head}\n${lines.join("\n")}` : head;
224
+
225
+ /** One snapshot after a governance operation, its summary in the audit row and the result. */
226
+ async function governed(
227
+ ctx ,
228
+ siteId ,
229
+ tool ,
230
+ op ,
231
+ ) {
232
+ const snapshot = await autoSnapshot(ctx, siteId, tool, { "cloudhal.opId": op.opId });
233
+ ctx.report?.({
234
+ operation: {
235
+ opId: op.opId,
236
+ terms: op.terms.length,
237
+ entries: op.entries,
238
+ redirects: op.redirects.map((r) => `${r.from} → ${r.to}`),
239
+ },
240
+ });
241
+ return {
242
+ operation: {
243
+ opId: op.opId,
244
+ entries: op.entries,
245
+ terms: op.terms,
246
+ redirects: op.redirects,
247
+ },
248
+ snapshot,
249
+ };
250
+ }
251
+
252
+ async function guarded (work ) {
253
+ try {
254
+ return await work();
255
+ } catch (e) {
256
+ throw toToolError(e);
257
+ }
258
+ }
259
+
260
+ function termRef(t , locale ) {
261
+ return { termId: t.id, slug: t.slug, name: termName(t, locale) };
262
+ }
263
+
264
+ /** Exactly one existing live term matching any of `inputs`, in order (propose_term idempotency). */
265
+ async function existingMatch(
266
+ ctx ,
267
+ siteId ,
268
+ key ,
269
+ inputs ,
270
+ ) {
271
+ const values = inputs.filter((x) => x.trim() !== "");
272
+ if (values.length === 0) return undefined;
273
+ for (const r of await guarded(() => ctx.store.resolveTerms(siteId, key, values))) {
274
+ if (r.matched) return r;
275
+ if (r.reason === "ambiguous")
276
+ throw new ToolError(
277
+ "ambiguous_term",
278
+ `"${quote(r.input, 40)}" already matches several ${key} terms (${r.suggestions.map((s) => s.slug).join(", ")}); reuse one of them`,
279
+ { candidates: r.candidates },
280
+ );
281
+ }
282
+ return undefined;
283
+ }
284
+
285
+ /** A term's folded names and slug (`main`) and synonyms (`alt`), for `list_terms` search. */
286
+ function forms(t ) {
287
+ const main = new Set ([t.slug]);
288
+ const alt = new Set ();
289
+ for (const label of Object.values(t.labels)) {
290
+ if (!label) continue;
291
+ main.add(foldName(label.name));
292
+ for (const a of label.altNames ?? []) alt.add(foldName(a));
293
+ }
294
+ return { main: [...main].filter(Boolean), alt: [...alt].filter(Boolean) };
295
+ }
296
+
297
+ // ───────────────────────────── taxonomy_report ─────────────────────────────
298
+
299
+ /**
300
+ * Thresholds of `taxonomy_report` (returned with it): the core report's own, plus how many findings
301
+ * of one kind are listed.
302
+ */
303
+ export const REPORT_THRESHOLDS = {
304
+ ...CORE_REPORT_THRESHOLDS,
305
+ /** Findings listed per kind (counts are complete). */
306
+ maxPerKind: 50,
307
+ } ;
308
+
309
+
310
+
311
+
312
+
313
+
314
+
315
+
316
+
317
+
318
+
319
+
320
+
321
+
322
+
323
+
324
+
325
+
326
+
327
+
328
+ /**
329
+ * The hygiene report agents read. The findings come from core-ts `taxonomyReport` — the function
330
+ * Studio's "Vocabulary health" card calls with the same terms, usage and entries — and are only
331
+ * worded here (a detail with the next step), so both surfaces always agree.
332
+ */
333
+ async function taxonomyReport(ctx , site , only ) {
334
+ const locale = site.defaultLocale;
335
+ const taxonomies = (await ctx.store.listTaxonomies(site.id)).filter(
336
+ (t) => only === undefined || t.key === only,
337
+ );
338
+ if (only !== undefined && taxonomies.length === 0) await requireTaxonomy(ctx, site.id, only);
339
+ const entries = await ctx.store.listContentEntries(site.id, {});
340
+ const findings = [];
341
+ const counts = {};
342
+ const add = (f ) => {
343
+ counts[f.kind] = (counts[f.kind] ?? 0) + 1;
344
+ if (findings.filter((x) => x.kind === f.kind).length < REPORT_THRESHOLDS.maxPerKind)
345
+ findings.push(f);
346
+ };
347
+ for (const tax of taxonomies) {
348
+ const usage = await ctx.store.termUsage(site.id, tax.key);
349
+ const terms = await ctx.store.listTerms(site.id, { taxonomy: tax.key });
350
+ const report = coreTaxonomyReport({ taxonomy: tax, terms, usage, entries, locale });
351
+ const byId = new Map(terms.map((t) => [t.id, t]));
352
+ const use = (id ) => usage.get(id) ?? { total: 0, published: 0 };
353
+ const each = (ids , kind , detail ) => {
354
+ for (const id of ids) {
355
+ const t = byId.get(id);
356
+ if (t) add({ kind, taxonomy: tax.key, terms: [termRef(t, locale)], detail: detail(t) });
357
+ }
358
+ };
359
+ const entryList = (ids ) =>
360
+ `${ids.slice(0, 10).join(", ")}${ids.length > 10 ? ", …" : ""}`;
361
+
362
+ each(
363
+ report.proposed,
364
+ "proposed",
365
+ (t) =>
366
+ `proposed${t.proposedBy ? ` by ${t.proposedBy.name ?? t.proposedBy.id}` : ""} on ${t.createdAt.slice(0, 10)}, used by ${articles(use(t.id).total)}${t.metadata?.["cloudhal.reason"] ? `: "${quote(t.metadata["cloudhal.reason"], 80)}"` : ""}; a person approves, rejects or merges it`,
367
+ );
368
+ each(
369
+ report.emptyCategories,
370
+ "empty-category",
371
+ () =>
372
+ "no published article in it or below it; its archive answers 404 — file articles under it, or merge or retire it",
373
+ );
374
+ each(report.oversizedCategories, "oversized-category", (t) => {
375
+ const total = use(t.id).total;
376
+ return `${articles(total)} (${Math.round((100 * total) / Math.max(report.filed, 1))}% of filed articles) and no sub-categories; consider splitting it (a person creates categories)`;
377
+ });
378
+ each(report.unused, "unused", () => "no article uses it; retire it or file articles under it");
379
+ each(
380
+ report.singleUse,
381
+ "single-use",
382
+ () => "used by one article only; reuse it or merge it into a broader term",
383
+ );
384
+ each(
385
+ report.noIntro,
386
+ "no-intro",
387
+ () => "indexed archive without an intro text; add one with update_term",
388
+ );
389
+ for (const d of report.nearDuplicates) {
390
+ const source = byId.get(d.source);
391
+ const target = byId.get(d.target);
392
+ if (!source || !target) continue;
393
+ const pair = [termRef(source, locale), termRef(target, locale)];
394
+ const merge = `merge "${termName(source, locale)}" into "${termName(target, locale)}"`;
395
+ if (d.reason === "synonym")
396
+ add({
397
+ kind: "synonym-collision",
398
+ taxonomy: tax.key,
399
+ terms: pair,
400
+ detail: `"${d.forms[0]}" is a synonym of one and a name, slug or synonym of the other; ${merge}`,
401
+ });
402
+ else
403
+ add({
404
+ kind: "near-duplicate",
405
+ taxonomy: tax.key,
406
+ terms: pair,
407
+ detail:
408
+ d.reason === "plural"
409
+ ? `singular and plural ("${d.forms[0]}" / "${d.forms[1]}"); ${merge}`
410
+ : `"${d.forms[0]}" and "${d.forms[1]}" differ by ${d.distance ?? 0} character${d.distance === 1 ? "" : "s"}; ${merge} if they mean the same`,
411
+ });
412
+ }
413
+ if (report.missingPrimary.length > 0)
414
+ add({
415
+ kind: "missing-primary",
416
+ taxonomy: tax.key,
417
+ terms: [],
418
+ detail: `${articles(report.missingPrimary.length)} without a primary ${tax.key} (they cannot go to review or published): ${entryList(report.missingPrimary)}`,
419
+ });
420
+ if (report.unfiled.length > 0)
421
+ add({
422
+ kind: "unfiled",
423
+ taxonomy: tax.key,
424
+ terms: [],
425
+ detail: `${articles(report.unfiled.length)} without any ${tax.key}; file them so readers find them in its archives: ${entryList(report.unfiled)}`,
426
+ });
427
+ }
428
+ return { findings, counts, thresholds: REPORT_THRESHOLDS };
429
+ }
430
+
431
+ // ───────────────────────────── tools ─────────────────────────────
432
+
433
+ const LOCALE_KEY = {
434
+ minLength: 2,
435
+ maxLength: 35,
436
+ pattern: "^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$",
437
+ };
438
+
439
+ /** Text fields of a term label that `update_term` may change (all optional). */
440
+ const LABEL_PATCH = obj({
441
+ name: str("Display name", { minLength: 1, maxLength: 200 }),
442
+ altNames: {
443
+ type: "array",
444
+ description: "Synonyms (replaces the list; [] removes them)",
445
+ items: { type: "string", minLength: 1, maxLength: 200 },
446
+ maxItems: 50,
447
+ uniqueItems: true,
448
+ },
449
+ description: str('Markdown intro shown on the archive ("" removes it)', { maxLength: 20000 }),
450
+ seo: withTermDefs({ $ref: "#/$defs/Seo" }),
451
+ });
452
+
453
+ export function taxonomyTools() {
454
+ return [
455
+ {
456
+ name: "list_taxonomies",
457
+ title: "List taxonomies",
458
+ description:
459
+ "The site's taxonomies (e.g. category, tag) and their rules: `required` (review/published articles need a primary term), `maxPerEntry`, `allowNewTerms` (none: only people add terms; propose: you may propose terms a person approves; free), `collections`, `hierarchical` and the archive path. Read this before filing articles.",
460
+ inputSchema: obj({ siteId: id }, ["siteId"]),
461
+ outputSchema: obj({ taxonomies: { type: "array", items: TAXONOMY } }, ["taxonomies"]),
462
+ annotations: READ,
463
+ scopes: ["site:read"],
464
+ handler: async (a, ctx) => {
465
+ await requireSite(ctx, a.siteId );
466
+ const taxonomies = await ctx.store.listTaxonomies(a.siteId );
467
+ return { taxonomies: taxonomies.sort((x, y) => x.key.localeCompare(y.key)) };
468
+ },
469
+ },
470
+ {
471
+ name: "list_terms",
472
+ title: "List terms",
473
+ description:
474
+ "Terms of one taxonomy with names per locale, synonyms (altNames), old slugs (slugHistory), parent, status, visibility and usage (articles in total and published, last used). Default: active and proposed terms. Reuse these before proposing anything new.",
475
+ inputSchema: obj(
476
+ {
477
+ siteId: id,
478
+ taxonomy: TAXONOMY_KEY,
479
+ query: str("Only terms whose slug, name or synonym contains this", {
480
+ minLength: 1,
481
+ maxLength: 200,
482
+ }),
483
+ status: {
484
+ type: "array",
485
+ description: "Statuses to include (default: active, proposed)",
486
+ items: { type: "string", enum: ["active", "proposed", "retired"] },
487
+ minItems: 1,
488
+ uniqueItems: true,
489
+ },
490
+ parentId: {
491
+ type: ["string", "null"],
492
+ description: "Only children of this term; null: top-level terms only",
493
+ },
494
+ cursor: str("nextCursor of the previous page", { pattern: "^[0-9]{1,6}$" }),
495
+ limit: { type: "integer", minimum: 1, maximum: 200, default: 100 },
496
+ },
497
+ ["siteId", "taxonomy"],
498
+ ),
499
+ outputSchema: obj(
500
+ {
501
+ terms: { type: "array", items: TERM_VIEW },
502
+ nextCursor: { type: "string" },
503
+ },
504
+ ["terms"],
505
+ ),
506
+ annotations: READ,
507
+ scopes: ["content:read"],
508
+ handler: async (a, ctx) => {
509
+ const siteId = a.siteId ;
510
+ await requireSite(ctx, siteId);
511
+ const key = a.taxonomy ;
512
+ await requireTaxonomy(ctx, siteId, key);
513
+ let terms = await ctx.store.listTerms(siteId, {
514
+ taxonomy: key,
515
+ status: (a.status ) ?? ["active", "proposed"],
516
+ ...(a.parentId !== undefined ? { parentId: a.parentId } : {}),
517
+ });
518
+ if (typeof a.query === "string") {
519
+ const q = foldName(a.query);
520
+ terms = terms.filter((t) => {
521
+ const f = forms(t);
522
+ return [...f.main, ...f.alt, ...(t.slugHistory ?? [])].some((x) => x.includes(q));
523
+ });
524
+ }
525
+ const usage = await ctx.store.termUsage(siteId, key);
526
+ const offset = Number((a.cursor ) ?? 0);
527
+ const limit = (a.limit ) ?? 100;
528
+ const page = terms.slice(offset, offset + limit).map((t) => {
529
+ const u = usage.get(t.id);
530
+ return {
531
+ ...t,
532
+ usage: {
533
+ total: u?.total ?? 0,
534
+ published: u?.published ?? 0,
535
+ ...(u?.lastUsedAt ? { lastUsedAt: u.lastUsedAt } : {}),
536
+ },
537
+ };
538
+ });
539
+ return {
540
+ terms: page,
541
+ ...(offset + limit < terms.length ? { nextCursor: String(offset + limit) } : {}),
542
+ };
543
+ },
544
+ },
545
+ {
546
+ name: "resolve_terms",
547
+ title: "Resolve terms",
548
+ description:
549
+ "Which existing terms of a taxonomy the given ids, slugs, names or synonyms mean (the same rules as assigning `terms`: id → slug → old slug → name → synonym → singular/plural). Unmatched or ambiguous inputs come with up to 5 suggestions. Never creates anything.",
550
+ inputSchema: obj(
551
+ {
552
+ siteId: id,
553
+ taxonomy: TAXONOMY_KEY,
554
+ inputs: {
555
+ type: "array",
556
+ items: { type: "string", minLength: 1, maxLength: 200 },
557
+ minItems: 1,
558
+ maxItems: 50,
559
+ },
560
+ },
561
+ ["siteId", "taxonomy", "inputs"],
562
+ ),
563
+ outputSchema: obj(
564
+ {
565
+ results: {
566
+ type: "array",
567
+ items: obj(
568
+ {
569
+ input: { type: "string" },
570
+ matched: { type: "boolean" },
571
+ termId: { type: "string" },
572
+ slug: { type: "string" },
573
+ name: { type: "string" },
574
+ via: {
575
+ type: "string",
576
+ enum: ["id", "slug", "slugHistory", "name", "altName", "plural"],
577
+ },
578
+ reason: { type: "string", enum: ["none", "ambiguous"] },
579
+ suggestions: { type: "array", items: TERM_REF },
580
+ },
581
+ ["input", "matched"],
582
+ ),
583
+ },
584
+ },
585
+ ["results"],
586
+ ),
587
+ annotations: READ,
588
+ scopes: ["content:read"],
589
+ handler: async (a, ctx) => {
590
+ const site = await requireSite(ctx, a.siteId );
591
+ const key = a.taxonomy ;
592
+ await requireTaxonomy(ctx, site.id, key);
593
+ const resolutions = await guarded(() =>
594
+ ctx.store.resolveTerms(site.id, key, a.inputs ),
595
+ );
596
+ const results = [];
597
+ for (const r of resolutions) {
598
+ if (r.matched) {
599
+ const t = await ctx.store.getTerm(r.termId);
600
+ results.push({
601
+ input: r.input,
602
+ matched: true,
603
+ termId: r.termId,
604
+ via: r.via,
605
+ ...(t ? { slug: t.slug, name: termName(t, site.defaultLocale) } : {}),
606
+ });
607
+ } else
608
+ results.push({
609
+ input: r.input,
610
+ matched: false,
611
+ reason: r.reason,
612
+ suggestions: r.suggestions.map((s) => ({ ...s })),
613
+ });
614
+ }
615
+ return { results };
616
+ },
617
+ },
618
+ {
619
+ name: "propose_term",
620
+ title: "Propose a term",
621
+ description:
622
+ "Suggest a new term when no existing one fits (check list_terms / resolve_terms first). Under allowNewTerms `propose` it becomes a proposed term: assignable, never public, until a person approves it; under `free` it is active; under `none` (categories) only a person adds terms — ask instead. If the name, slug or a synonym already matches a term, that term is returned with existing: true and nothing is created.",
623
+ inputSchema: obj(
624
+ {
625
+ siteId: id,
626
+ taxonomy: TAXONOMY_KEY,
627
+ name: str("Display name in `locale`", { minLength: 1, maxLength: 200 }),
628
+ locale: str("Locale of the name; defaults to the site's default locale", LOCALE_KEY),
629
+ slug: str("URL slug; derived from the name when absent", {
630
+ pattern: SLUG_PATTERN,
631
+ maxLength: 128,
632
+ }),
633
+ altNames: {
634
+ type: "array",
635
+ description: "Synonyms",
636
+ items: { type: "string", minLength: 1, maxLength: 200 },
637
+ minItems: 1,
638
+ maxItems: 50,
639
+ uniqueItems: true,
640
+ },
641
+ parentId: { ...id, description: "Parent term (hierarchical taxonomies)" },
642
+ reason: str("Why this term is needed, for the person who reviews it", {
643
+ minLength: 3,
644
+ maxLength: 300,
645
+ }),
646
+ },
647
+ ["siteId", "taxonomy", "name", "reason"],
648
+ ),
649
+ outputSchema: obj(
650
+ {
651
+ term: TERM,
652
+ existing: { type: "boolean" },
653
+ via: { type: "string" },
654
+ snapshot: SNAPSHOT,
655
+ },
656
+ ["term", "existing"],
657
+ ),
658
+ annotations: { ...WRITE, idempotentHint: true },
659
+ scopes: ["content:draft:own"],
660
+ precheck: async (a, ctx) => {
661
+ const siteId = a.siteId ;
662
+ const tax = await requireTaxonomy(ctx, siteId, a.taxonomy );
663
+ const rule = termCreation(ctx.principal, tax.allowNewTerms);
664
+ if (rule.allowed) return;
665
+ const inputs = [a.name , (a.slug ) ?? ""];
666
+ if (await existingMatch(ctx, siteId, tax.key, inputs)) return;
667
+ throw new ToolError(
668
+ "denied",
669
+ `${rule.reason}. "${tax.key}" takes new terms from people only: pick an existing one (list_terms) or ask a person to add "${quote(a.name , 40)}"`,
670
+ );
671
+ },
672
+ approvalPrompt: async (a, ctx) => {
673
+ const tax = await requireTaxonomy(ctx, a.siteId , a.taxonomy );
674
+ const rule = termCreation(ctx.principal, tax.allowNewTerms);
675
+ const status = rule.allowed ? rule.status : "proposed";
676
+ return `Add ${status} ${tax.key} "${quote(a.name , 50)}" (${(a.slug ) ?? slugify(a.name )})? Reason: "${quote(a.reason , 120)}"`;
677
+ },
678
+ handler: async (a, ctx) => {
679
+ const site = await requireSite(ctx, a.siteId );
680
+ const tax = await requireTaxonomy(ctx, site.id, a.taxonomy );
681
+ const name = (a.name ).replace(/\s+/g, " ").trim();
682
+ const altNames = (a.altNames ) ?? [];
683
+ const match = await existingMatch(ctx, site.id, tax.key, [
684
+ name,
685
+ (a.slug ) ?? "",
686
+ ...altNames,
687
+ ]);
688
+ if (match)
689
+ return {
690
+ term: await requireTerm(ctx, site.id, match.termId),
691
+ existing: true,
692
+ via: match.via,
693
+ };
694
+ const rule = termCreation(ctx.principal, tax.allowNewTerms);
695
+ if (!rule.allowed) throw new ToolError("denied", rule.reason);
696
+ const slug = (a.slug ) ?? slugify(name);
697
+ if (!slug)
698
+ throw new ToolError(
699
+ "invalid",
700
+ "the name has no letters or digits for a slug; pass `slug`",
701
+ );
702
+ const locale = (a.locale ) ?? site.defaultLocale;
703
+ const label = { name, ...(altNames.length ? { altNames } : {}) };
704
+ const actor = ctx.principal.actor;
705
+ const term = await guarded(() =>
706
+ ctx.store.createTerm({
707
+ siteId: site.id,
708
+ taxonomyId: tax.id,
709
+ slug,
710
+ status: rule.status,
711
+ visibility: "public",
712
+ labels: {
713
+ [site.defaultLocale]: { name },
714
+ [locale]: label,
715
+ } ,
716
+ ...(a.parentId ? { parentId: a.parentId } : {}),
717
+ ...(actor.kind === "agent" ? { proposedBy: actor } : {}),
718
+ metadata: { "cloudhal.reason": quote(a.reason , 300) },
719
+ }),
720
+ );
721
+ const snapshot = await autoSnapshot(ctx, site.id, "propose_term");
722
+ return { term, existing: false, snapshot };
723
+ },
724
+ },
725
+ {
726
+ name: "update_term",
727
+ title: "Update term text",
728
+ description:
729
+ "Change a term's text only: names, synonyms, archive intro (description) and SEO per locale, and its image. Slugs, status, parent and visibility change through the approval-gated tools. Requires the current `rev`. Snapshots afterwards.",
730
+ inputSchema: obj(
731
+ {
732
+ siteId: id,
733
+ termId: id,
734
+ rev: { type: "integer", minimum: 0 },
735
+ labels: {
736
+ type: "object",
737
+ description: "Per locale, the fields to change; others stay",
738
+ propertyNames: LOCALE_KEY,
739
+ minProperties: 1,
740
+ maxProperties: 20,
741
+ additionalProperties: LABEL_PATCH,
742
+ },
743
+ image: {
744
+ anyOf: [withTermDefs({ $ref: "#/$defs/ImageRef" }), { type: "null" }],
745
+ description: "Image shared across locales; null removes it",
746
+ },
747
+ },
748
+ ["siteId", "termId", "rev"],
749
+ ),
750
+ outputSchema: obj({ term: TERM, snapshot: SNAPSHOT }, ["term", "snapshot"]),
751
+ annotations: WRITE,
752
+ scopes: ["content:write"],
753
+ approvalPrompt: async (a, ctx) => {
754
+ const term = await requireTerm(ctx, a.siteId , a.termId );
755
+ const tax = await taxonomyOf(ctx, term);
756
+ const locale = await defaultLocale(ctx, term.siteId);
757
+ const fields = Object.entries((a.labels ) ?? {}).map(
758
+ ([loc, l]) => `${loc}: ${Object.keys(l).join(", ")}`,
759
+ );
760
+ if (a.image !== undefined) fields.push("image");
761
+ return `Change the text of ${named(tax, term, locale)} (${fields.join("; ") || "nothing"})?`;
762
+ },
763
+ handler: async (a, ctx) => {
764
+ const term = await requireTerm(ctx, a.siteId , a.termId );
765
+ if (term.status === "retired")
766
+ throw new ToolError("invalid_status", "a retired term cannot be edited");
767
+ if (term.rev !== a.rev)
768
+ throw new ToolError(
769
+ "conflict",
770
+ `term ${term.id} is at rev ${term.rev}, you sent ${a.rev}; reload and retry`,
771
+ );
772
+ const labels = JSON.parse(JSON.stringify(term.labels));
773
+ for (const [loc, patch] of Object.entries(
774
+ (a.labels ) ?? {},
775
+ )) {
776
+ const cur = labels[loc] ;
777
+ const next = { ...(cur ?? {}), ...patch };
778
+ if (typeof next.name !== "string" || next.name.trim() === "")
779
+ throw new ToolError("invalid", `labels.${loc}.name is required for a new locale`);
780
+ if (Array.isArray(next.altNames) && next.altNames.length === 0) delete next.altNames;
781
+ if (next.description === "") delete next.description;
782
+ if (next.seo && Object.keys(next.seo ).length === 0) delete next.seo;
783
+ labels[loc] = next ;
784
+ }
785
+ const { image: _i, ...rest } = term;
786
+ const next = {
787
+ ...rest,
788
+ labels,
789
+ ...(a.image === undefined
790
+ ? term.image
791
+ ? { image: term.image }
792
+ : {}
793
+ : a.image === null
794
+ ? {}
795
+ : { image: a.image }),
796
+ };
797
+ const saved = await guarded(() => ctx.store.updateTerm(next));
798
+ const snapshot = await autoSnapshot(ctx, term.siteId, "update_term");
799
+ return { term: saved, snapshot };
800
+ },
801
+ },
802
+ {
803
+ name: "rename_term",
804
+ title: "Rename a term's slug",
805
+ description:
806
+ "Change a term's slug (its archive URL). The old slug keeps resolving and its archive redirects (301) to the new one; no article changes. Always waits for a person, who sees the redirects. Snapshots afterwards.",
807
+ inputSchema: obj(
808
+ {
809
+ siteId: id,
810
+ termId: id,
811
+ slug: str("New slug", { pattern: SLUG_PATTERN, maxLength: 128 }),
812
+ reclaim: {
813
+ type: "boolean",
814
+ description: "People only: take a slug back from another term's history",
815
+ },
816
+ },
817
+ ["siteId", "termId", "slug"],
818
+ ),
819
+ outputSchema: GOVERNANCE_OUT,
820
+ annotations: WRITE,
821
+ scopes: ["site:write"],
822
+ requiresApproval: true,
823
+ precheck: async (a, ctx) => {
824
+ const problem = a.reclaim === true ? reclaimProblem(ctx.principal.actor) : undefined;
825
+ if (problem) throw new ToolError("denied", problem);
826
+ },
827
+ approvalPrompt: async (a, ctx) => {
828
+ const term = await requireTerm(ctx, a.siteId , a.termId );
829
+ const tax = await taxonomyOf(ctx, term);
830
+ const locale = await defaultLocale(ctx, term.siteId);
831
+ const to = archivePath(tax, a.slug );
832
+ const lines =
833
+ tax.archive.enabled && (isPublic(term) || a.reclaim === true)
834
+ ? await redirectLines(ctx, term.siteId, {
835
+ serve: [to],
836
+ ...(isPublic(term) ? { moves: [{ from: archivePath(tax, term.slug), to }] } : {}),
837
+ })
838
+ : [];
839
+ return withLines(
840
+ `Rename ${named(tax, term, locale)} to slug "${a.slug }"? No article changes${lines.length ? "; redirects:" : "; no public URL changes."}`,
841
+ lines,
842
+ );
843
+ },
844
+ handler: async (a, ctx) => {
845
+ const term = await requireTerm(ctx, a.siteId , a.termId );
846
+ const problem = a.reclaim === true ? reclaimProblem(ctx.principal.actor) : undefined;
847
+ if (problem) throw new ToolError("denied", problem);
848
+ const op = await guarded(() =>
849
+ ctx.store.renameTerm(term, a.slug , { reclaim: a.reclaim === true }),
850
+ );
851
+ return governed(ctx, term.siteId, "rename_term", op);
852
+ },
853
+ },
854
+ {
855
+ name: "merge_terms",
856
+ title: "Merge terms",
857
+ description:
858
+ "Merge duplicate terms into one target term of the same taxonomy: every article is re-filed under the target (primary terms follow), the sources retire and their names and slugs become the target's synonyms and old slugs, and their archives redirect (301) to the target's. Always waits for a person, who sees the article count and the redirects. Snapshots afterwards.",
859
+ inputSchema: obj(
860
+ {
861
+ siteId: id,
862
+ sourceIds: {
863
+ type: "array",
864
+ description: "Terms to merge away (list_terms ids)",
865
+ items: id,
866
+ minItems: 1,
867
+ maxItems: 20,
868
+ uniqueItems: true,
869
+ },
870
+ targetId: { ...id, description: "The active term that stays" },
871
+ },
872
+ ["siteId", "sourceIds", "targetId"],
873
+ ),
874
+ outputSchema: GOVERNANCE_OUT,
875
+ annotations: WRITE,
876
+ scopes: ["site:write"],
877
+ requiresApproval: true,
878
+ approvalPrompt: async (a, ctx) => {
879
+ const siteId = a.siteId ;
880
+ const target = await requireTerm(ctx, siteId, a.targetId );
881
+ const sources = await Promise.all(
882
+ (a.sourceIds ).map((x) => requireTerm(ctx, siteId, x)),
883
+ );
884
+ return mergePrompt(ctx, sources, target);
885
+ },
886
+ handler: async (a, ctx) => {
887
+ const siteId = a.siteId ;
888
+ const target = await requireTerm(ctx, siteId, a.targetId );
889
+ const sources = await Promise.all(
890
+ (a.sourceIds ).map((x) => requireTerm(ctx, siteId, x)),
891
+ );
892
+ const op = await guarded(() => ctx.store.mergeTerms(sources, target));
893
+ return governed(ctx, siteId, "merge_terms", op);
894
+ },
895
+ },
896
+ {
897
+ name: "retire_term",
898
+ title: "Retire a term",
899
+ description:
900
+ "Retire a term: it is removed from every article, its sub-terms move up, and its archive redirects (301) to `redirectToId` (or, for categories, to its parent) — without a redirect it answers 410 Gone. Refused when a review or published article would lose its required primary term: merge instead. Always waits for a person. Snapshots afterwards.",
901
+ inputSchema: obj(
902
+ {
903
+ siteId: id,
904
+ termId: id,
905
+ redirectToId: { ...id, description: "Active term of the same taxonomy to redirect to" },
906
+ },
907
+ ["siteId", "termId"],
908
+ ),
909
+ outputSchema: GOVERNANCE_OUT,
910
+ annotations: { ...WRITE, destructiveHint: true },
911
+ scopes: ["site:write"],
912
+ requiresApproval: true,
913
+ approvalPrompt: async (a, ctx) => {
914
+ const siteId = a.siteId ;
915
+ const term = await requireTerm(ctx, siteId, a.termId );
916
+ const target = a.redirectToId
917
+ ? await requireTerm(ctx, siteId, a.redirectToId )
918
+ : undefined;
919
+ return retirePrompt(ctx, term, target);
920
+ },
921
+ handler: async (a, ctx) => {
922
+ const siteId = a.siteId ;
923
+ const term = await requireTerm(ctx, siteId, a.termId );
924
+ const target = a.redirectToId
925
+ ? await requireTerm(ctx, siteId, a.redirectToId )
926
+ : undefined;
927
+ const op = await guarded(() =>
928
+ ctx.store.retireTerm(term, target ? { redirectTo: target } : {}),
929
+ );
930
+ return governed(ctx, siteId, "retire_term", op);
931
+ },
932
+ },
933
+ {
934
+ name: "set_term_parent",
935
+ title: "Move a term",
936
+ description:
937
+ "Move a term under another term of the same hierarchical taxonomy (null: to the top level). No URL changes; category archives include sub-categories. Always waits for a person. Snapshots afterwards.",
938
+ inputSchema: obj(
939
+ {
940
+ siteId: id,
941
+ termId: id,
942
+ parentId: { anyOf: [id, { type: "null" }], description: "New parent; null: top level" },
943
+ },
944
+ ["siteId", "termId", "parentId"],
945
+ ),
946
+ outputSchema: GOVERNANCE_OUT,
947
+ annotations: WRITE,
948
+ scopes: ["site:write"],
949
+ requiresApproval: true,
950
+ approvalPrompt: async (a, ctx) => {
951
+ const siteId = a.siteId ;
952
+ const term = await requireTerm(ctx, siteId, a.termId );
953
+ const tax = await taxonomyOf(ctx, term);
954
+ const locale = await defaultLocale(ctx, siteId);
955
+ const where = async (pid ) => {
956
+ if (!pid) return "the top level";
957
+ const p = await ctx.store.getTerm(pid);
958
+ return p ? `"${quote(termName(p, locale), 50)}"` : pid;
959
+ };
960
+ return `Move ${named(tax, term, locale)} from ${await where(term.parentId)} to ${await where(a.parentId )}? No URLs change.`;
961
+ },
962
+ handler: async (a, ctx) => {
963
+ const term = await requireTerm(ctx, a.siteId , a.termId );
964
+ const op = await guarded(() =>
965
+ ctx.store.setTermParent(term, (a.parentId ) ?? null),
966
+ );
967
+ return governed(ctx, term.siteId, "set_term_parent", op);
968
+ },
969
+ },
970
+ {
971
+ name: "set_term_visibility",
972
+ title: "Set term visibility",
973
+ description:
974
+ "Make a term public (archive, feed and sitemap entry; shown on articles) or internal (no public URL; themes and filters may still use it). Always waits for a person. Snapshots afterwards.",
975
+ inputSchema: obj(
976
+ { siteId: id, termId: id, visibility: { type: "string", enum: ["public", "internal"] } },
977
+ ["siteId", "termId", "visibility"],
978
+ ),
979
+ outputSchema: GOVERNANCE_OUT,
980
+ annotations: WRITE,
981
+ scopes: ["site:write"],
982
+ requiresApproval: true,
983
+ approvalPrompt: async (a, ctx) => {
984
+ const term = await requireTerm(ctx, a.siteId , a.termId );
985
+ const tax = await taxonomyOf(ctx, term);
986
+ const locale = await defaultLocale(ctx, term.siteId);
987
+ const path = archivePath(tax, term.slug);
988
+ const live = tax.archive.enabled && term.status === "active";
989
+ if (a.visibility === "internal")
990
+ return `Make ${named(tax, term, locale)} internal?${live && term.visibility === "public" ? ` Its archive ${path} and feed go offline (no redirect).` : ""}`;
991
+ const lines = live ? await redirectLines(ctx, term.siteId, { serve: [path] }) : [];
992
+ return withLines(
993
+ `Make ${named(tax, term, locale)} public?${live ? ` ${path} becomes a public archive.` : ""}`,
994
+ lines,
995
+ );
996
+ },
997
+ handler: async (a, ctx) => {
998
+ const term = await requireTerm(ctx, a.siteId , a.termId );
999
+ const op = await guarded(() =>
1000
+ ctx.store.setTermVisibility(term, a.visibility ),
1001
+ );
1002
+ return governed(ctx, term.siteId, "set_term_visibility", op);
1003
+ },
1004
+ },
1005
+ {
1006
+ name: "review_term",
1007
+ title: "Review a proposed term",
1008
+ description:
1009
+ "Decide on a proposed term: approve (it becomes active and, if public, gets its archive), reject (retired and removed from articles) or merge (into an existing term, `mergeIntoId`). People normally do this in Studio; an agent never approves a term it proposed. Always waits for a person. Snapshots afterwards.",
1010
+ inputSchema: obj(
1011
+ {
1012
+ siteId: id,
1013
+ termId: id,
1014
+ decision: { type: "string", enum: ["approve", "reject", "merge"] },
1015
+ mergeIntoId: { ...id, description: "With decision merge: the active term to merge into" },
1016
+ },
1017
+ ["siteId", "termId", "decision"],
1018
+ ),
1019
+ outputSchema: GOVERNANCE_OUT,
1020
+ annotations: WRITE,
1021
+ scopes: ["site:write"],
1022
+ requiresApproval: true,
1023
+ precheck: async (a, ctx) => {
1024
+ const term = await requireTerm(ctx, a.siteId , a.termId );
1025
+ const problem = termReviewProblem(
1026
+ ctx.principal.actor,
1027
+ term,
1028
+ a.decision ,
1029
+ );
1030
+ if (problem) throw new ToolError("denied", problem);
1031
+ if (a.decision === "merge" && !a.mergeIntoId)
1032
+ throw new ToolError("invalid", "decision merge needs mergeIntoId");
1033
+ },
1034
+ approvalPrompt: async (a, ctx) => {
1035
+ const siteId = a.siteId ;
1036
+ const term = await requireTerm(ctx, siteId, a.termId );
1037
+ const tax = await taxonomyOf(ctx, term);
1038
+ const locale = await defaultLocale(ctx, siteId);
1039
+ const by = term.proposedBy
1040
+ ? ` proposed by ${term.proposedBy.name ?? term.proposedBy.id}`
1041
+ : "";
1042
+ const why = term.metadata?.["cloudhal.reason"]
1043
+ ? ` ("${quote(term.metadata["cloudhal.reason"], 120)}")`
1044
+ : "";
1045
+ if (a.decision === "merge") {
1046
+ const target = await requireTerm(ctx, siteId, a.mergeIntoId );
1047
+ return mergePrompt(ctx, [term], target, `Review${by}${why}: `);
1048
+ }
1049
+ if (a.decision === "reject")
1050
+ return retirePrompt(ctx, term, undefined, `Reject${by}${why}: `);
1051
+ const path = archivePath(tax, term.slug);
1052
+ const opens = tax.archive.enabled && term.visibility === "public";
1053
+ const lines = opens ? await redirectLines(ctx, siteId, { serve: [path] }) : [];
1054
+ return withLines(
1055
+ `Approve ${named(tax, term, locale)}${by}${why}? It becomes active${opens ? ` with a public archive at ${path}` : ""}.`,
1056
+ lines,
1057
+ );
1058
+ },
1059
+ handler: async (a, ctx) => {
1060
+ const siteId = a.siteId ;
1061
+ const term = await requireTerm(ctx, siteId, a.termId );
1062
+ const decision = a.decision ;
1063
+ const problem = termReviewProblem(ctx.principal.actor, term, decision);
1064
+ if (problem) throw new ToolError("denied", problem);
1065
+ let target ;
1066
+ if (decision === "merge") {
1067
+ if (!a.mergeIntoId) throw new ToolError("invalid", "decision merge needs mergeIntoId");
1068
+ target = await requireTerm(ctx, siteId, a.mergeIntoId );
1069
+ }
1070
+ const op = await guarded(() =>
1071
+ ctx.store.reviewTerm(
1072
+ term,
1073
+ decision === "approve"
1074
+ ? { approve: true }
1075
+ : decision === "reject"
1076
+ ? { reject: true }
1077
+ : { mergeInto: target },
1078
+ ),
1079
+ );
1080
+ return governed(ctx, siteId, "review_term", op);
1081
+ },
1082
+ },
1083
+ {
1084
+ name: "taxonomy_report",
1085
+ title: "Taxonomy hygiene report",
1086
+ description:
1087
+ "Findings about the vocabulary: single-use and unused terms, near-duplicates (edit distance ≤ 2, singular/plural) and synonym collisions, empty or oversized categories, archives without an intro, articles without a required primary term or without any category, and the backlog of proposed terms. Near-duplicates name the term to merge away first and the one to keep second. The same report as Studio's vocabulary health card. Read-only: suggest merges or retirements to a person.",
1088
+ inputSchema: obj({ siteId: id, taxonomy: TAXONOMY_KEY }, ["siteId"]),
1089
+ outputSchema: obj(
1090
+ {
1091
+ findings: {
1092
+ type: "array",
1093
+ items: obj(
1094
+ {
1095
+ kind: {
1096
+ type: "string",
1097
+ enum: [
1098
+ "single-use",
1099
+ "unused",
1100
+ "near-duplicate",
1101
+ "synonym-collision",
1102
+ "empty-category",
1103
+ "oversized-category",
1104
+ "proposed",
1105
+ "no-intro",
1106
+ "missing-primary",
1107
+ "unfiled",
1108
+ ],
1109
+ },
1110
+ taxonomy: { type: "string" },
1111
+ terms: { type: "array", items: TERM_REF },
1112
+ detail: { type: "string" },
1113
+ },
1114
+ ["kind", "taxonomy", "terms", "detail"],
1115
+ ),
1116
+ },
1117
+ counts: {
1118
+ type: "object",
1119
+ description: "Findings per kind (complete; `findings` lists at most maxPerKind each)",
1120
+ additionalProperties: { type: "integer" },
1121
+ },
1122
+ thresholds: {
1123
+ type: "object",
1124
+ additionalProperties: { type: "number" },
1125
+ },
1126
+ },
1127
+ ["findings", "counts", "thresholds"],
1128
+ ),
1129
+ annotations: READ,
1130
+ scopes: ["content:read"],
1131
+ handler: async (a, ctx) => {
1132
+ const site = await requireSite(ctx, a.siteId );
1133
+ return taxonomyReport(ctx, site, a.taxonomy );
1134
+ },
1135
+ },
1136
+ {
1137
+ name: "create_taxonomy",
1138
+ title: "Create taxonomy",
1139
+ description:
1140
+ "Create a taxonomy (e.g. series, region) with its rules and archive path. Always waits for a person. Snapshots afterwards.",
1141
+ inputSchema: withTaxonomyDefs(
1142
+ obj(
1143
+ {
1144
+ siteId: id,
1145
+ key: TAX_PROPS.key ,
1146
+ labels: TAX_PROPS.labels ,
1147
+ hierarchical: TAX_PROPS.hierarchical ,
1148
+ allowNewTerms: TAX_PROPS.allowNewTerms ,
1149
+ required: TAX_PROPS.required ,
1150
+ maxPerEntry: TAX_PROPS.maxPerEntry ,
1151
+ collections: TAX_PROPS.collections ,
1152
+ archive: TAX_PROPS.archive ,
1153
+ feed: TAX_PROPS.feed ,
1154
+ minEntriesToIndex: TAX_PROPS.minEntriesToIndex ,
1155
+ },
1156
+ [
1157
+ "siteId",
1158
+ "key",
1159
+ "labels",
1160
+ "hierarchical",
1161
+ "allowNewTerms",
1162
+ "required",
1163
+ "archive",
1164
+ "feed",
1165
+ "minEntriesToIndex",
1166
+ ],
1167
+ ),
1168
+ ),
1169
+ outputSchema: obj({ taxonomy: TAXONOMY, snapshot: SNAPSHOT }, ["taxonomy", "snapshot"]),
1170
+ annotations: WRITE,
1171
+ scopes: ["site:write"],
1172
+ requiresApproval: true,
1173
+ approvalPrompt: async (a) => {
1174
+ const archive = a.archive ;
1175
+ return `Create taxonomy "${a.key }": ${a.hierarchical ? "hierarchical" : "flat"}, new terms by ${a.allowNewTerms === "none" ? "people only" : a.allowNewTerms === "propose" ? "agent proposals a person approves" : "agents freely"}, ${a.required ? "required on review/published articles" : "optional"}${a.maxPerEntry ? `, at most ${a.maxPerEntry} per article` : ""}, archive ${archive?.enabled ? `at ${archive.basePath}` : "off"}${a.feed ? " with feeds" : ""}?`;
1176
+ },
1177
+ handler: async (a, ctx) => {
1178
+ const site = await requireSite(ctx, a.siteId );
1179
+ const { siteId: _s, ...fields } = a;
1180
+ const input = { ...fields, siteId: site.id }
1181
+
1182
+ ;
1183
+ const taxonomy = await guarded(() => ctx.store.createTaxonomy(input));
1184
+ const snapshot = await autoSnapshot(ctx, site.id, "create_taxonomy");
1185
+ return { taxonomy, snapshot };
1186
+ },
1187
+ },
1188
+ {
1189
+ name: "update_taxonomy",
1190
+ title: "Update taxonomy",
1191
+ description:
1192
+ "Change a taxonomy's rules, labels or archive (the key never changes). Moving an enabled archive leaves a prefix redirect. Requires the current `rev`. Always waits for a person. Snapshots afterwards.",
1193
+ inputSchema: withTaxonomyDefs(
1194
+ obj(
1195
+ {
1196
+ siteId: id,
1197
+ taxonomyId: id,
1198
+ rev: { type: "integer", minimum: 0 },
1199
+ labels: TAX_PROPS.labels ,
1200
+ hierarchical: TAX_PROPS.hierarchical ,
1201
+ allowNewTerms: TAX_PROPS.allowNewTerms ,
1202
+ required: TAX_PROPS.required ,
1203
+ maxPerEntry: { anyOf: [TAX_PROPS.maxPerEntry, { type: "null" }] },
1204
+ collections: { anyOf: [TAX_PROPS.collections, { type: "null" }] },
1205
+ archive: TAX_PROPS.archive ,
1206
+ feed: TAX_PROPS.feed ,
1207
+ minEntriesToIndex: TAX_PROPS.minEntriesToIndex ,
1208
+ },
1209
+ ["siteId", "taxonomyId", "rev"],
1210
+ ),
1211
+ ),
1212
+ outputSchema: obj({ taxonomy: TAXONOMY, snapshot: SNAPSHOT }, ["taxonomy", "snapshot"]),
1213
+ annotations: WRITE,
1214
+ scopes: ["site:write"],
1215
+ requiresApproval: true,
1216
+ approvalPrompt: async (a, ctx) => {
1217
+ const tax = await ctx.store.getTaxonomy(a.taxonomyId );
1218
+ if (!tax || tax.siteId !== a.siteId) return `Update taxonomy ${a.taxonomyId}?`;
1219
+ const next = patchTaxonomy(tax, a);
1220
+ const changed = TAXONOMY_FIELDS.filter(
1221
+ (f) => canonicalJson(tax[f] ?? null) !== canonicalJson(next[f] ?? null),
1222
+ ).map((f) => (f === "labels" ? "labels" : `${f}: ${show(tax, f)} → ${show(next, f)}`));
1223
+ const moves =
1224
+ tax.archive.enabled &&
1225
+ next.archive.enabled &&
1226
+ tax.archive.basePath !== next.archive.basePath
1227
+ ? [{ from: tax.archive.basePath, to: next.archive.basePath }]
1228
+ : [];
1229
+ const lines = moves.length ? await redirectLines(ctx, tax.siteId, { moves }) : [];
1230
+ return withLines(
1231
+ `Update taxonomy "${tax.key}" (${changed.join("; ") || "no change"})?`,
1232
+ lines,
1233
+ );
1234
+ },
1235
+ handler: async (a, ctx) => {
1236
+ const tax = await ctx.store.getTaxonomy(a.taxonomyId );
1237
+ if (!tax || tax.siteId !== a.siteId)
1238
+ throw new ToolError("not_found", `taxonomy ${a.taxonomyId} not found`);
1239
+ if (tax.rev !== a.rev)
1240
+ throw new ToolError(
1241
+ "conflict",
1242
+ `taxonomy ${tax.id} is at rev ${tax.rev}, you sent ${a.rev}; reload and retry`,
1243
+ );
1244
+ const taxonomy = await guarded(() => ctx.store.updateTaxonomy(patchTaxonomy(tax, a)));
1245
+ const snapshot = await autoSnapshot(ctx, tax.siteId, "update_taxonomy");
1246
+ return { taxonomy, snapshot };
1247
+ },
1248
+ },
1249
+ {
1250
+ name: "list_redirects",
1251
+ title: "List redirects",
1252
+ description:
1253
+ "The site's redirects (from → to, 301/302, exact/prefix, reason, source node). Slug changes of published articles and term renames, merges and retirements create them automatically.",
1254
+ inputSchema: obj({ siteId: id }, ["siteId"]),
1255
+ outputSchema: obj({ redirects: { type: "array", items: REDIRECT } }, ["redirects"]),
1256
+ annotations: READ,
1257
+ scopes: ["site:read"],
1258
+ handler: async (a, ctx) => {
1259
+ await requireSite(ctx, a.siteId );
1260
+ return { redirects: await ctx.store.listRedirects(a.siteId ) };
1261
+ },
1262
+ },
1263
+ {
1264
+ name: "create_redirect",
1265
+ title: "Create redirect",
1266
+ description:
1267
+ "Redirect a path the site no longer serves to another path of this site (agents cannot redirect to other hosts). Chains are collapsed and loops refused. Always waits for a person. Snapshots afterwards.",
1268
+ inputSchema: obj(
1269
+ {
1270
+ siteId: id,
1271
+ from: REDIRECT_PROPS.from ,
1272
+ to: REDIRECT_PROPS.to ,
1273
+ status: { type: "integer", enum: [301, 302], default: 301 },
1274
+ match: { type: "string", enum: ["exact", "prefix"], default: "exact" },
1275
+ note: str("Why (shown to people)", { maxLength: 500 }),
1276
+ },
1277
+ ["siteId", "from", "to"],
1278
+ ),
1279
+ outputSchema: obj({ redirect: REDIRECT, snapshot: SNAPSHOT }, ["redirect", "snapshot"]),
1280
+ annotations: WRITE,
1281
+ scopes: ["site:write"],
1282
+ requiresApproval: true,
1283
+ precheck: async (a, ctx) => {
1284
+ const problem = redirectTargetProblem(ctx.principal.actor, a.to );
1285
+ if (problem) throw new ToolError("denied", problem);
1286
+ },
1287
+ approvalPrompt: async (a) =>
1288
+ `Create a ${(a.status ) ?? 301} redirect ${quote(a.from , 120)} → ${quote(a.to , 120)} (${(a.match ) ?? "exact"})${a.note ? `: "${quote(a.note , 120)}"` : ""}?`,
1289
+ handler: async (a, ctx) => {
1290
+ const site = await requireSite(ctx, a.siteId );
1291
+ const problem = redirectTargetProblem(ctx.principal.actor, a.to );
1292
+ if (problem) throw new ToolError("denied", problem);
1293
+ const redirect = await guarded(() =>
1294
+ ctx.store.createRedirect({
1295
+ siteId: site.id,
1296
+ from: a.from ,
1297
+ to: a.to ,
1298
+ status: ((a.status ) ?? 301) ,
1299
+ match: ((a.match ) ?? "exact") ,
1300
+ reason: "manual",
1301
+ ...(a.note ? { note: a.note } : {}),
1302
+ }),
1303
+ );
1304
+ const snapshot = await autoSnapshot(ctx, site.id, "create_redirect");
1305
+ return { redirect, snapshot };
1306
+ },
1307
+ },
1308
+ {
1309
+ name: "delete_redirect",
1310
+ title: "Delete redirect",
1311
+ description:
1312
+ "Delete a redirect; its old path then answers 404 unless something serves it. Always waits for a person. Snapshots afterwards.",
1313
+ inputSchema: obj({ siteId: id, redirectId: id }, ["siteId", "redirectId"]),
1314
+ outputSchema: obj({ snapshot: SNAPSHOT }, ["snapshot"]),
1315
+ annotations: { ...WRITE, destructiveHint: true },
1316
+ scopes: ["site:write"],
1317
+ requiresApproval: true,
1318
+ approvalPrompt: async (a, ctx) => {
1319
+ const r = await ctx.store.getRedirect(a.redirectId );
1320
+ return r
1321
+ ? `Delete the redirect ${quote(r.from, 120)} → ${quote(r.to, 120)} (${r.reason})? ${quote(r.from, 120)} will answer 404 unless something serves it.`
1322
+ : `Delete redirect ${a.redirectId}?`;
1323
+ },
1324
+ handler: async (a, ctx) => {
1325
+ const r = await ctx.store.getRedirect(a.redirectId );
1326
+ if (!r || r.siteId !== a.siteId)
1327
+ throw new ToolError("not_found", `redirect ${a.redirectId} not found`);
1328
+ await guarded(() => ctx.store.deleteRedirect(r));
1329
+ return { snapshot: await autoSnapshot(ctx, r.siteId, "delete_redirect") };
1330
+ },
1331
+ },
1332
+ ];
1333
+ }
1334
+
1335
+ const TAXONOMY_FIELDS = [
1336
+ "labels",
1337
+ "hierarchical",
1338
+ "allowNewTerms",
1339
+ "required",
1340
+ "maxPerEntry",
1341
+ "collections",
1342
+ "archive",
1343
+ "feed",
1344
+ "minEntriesToIndex",
1345
+ ] ;
1346
+
1347
+ /** A taxonomy setting as a person reads it in a prompt. */
1348
+ function show(tax , f ) {
1349
+ const v = tax[f];
1350
+ if (v === undefined) return "unset";
1351
+ if (f === "archive") return tax.archive.enabled ? tax.archive.basePath : "off";
1352
+ if (Array.isArray(v)) return v.join(", ");
1353
+ return String(v);
1354
+ }
1355
+
1356
+ /** `update_taxonomy` arguments over the stored taxonomy (`null` clears an optional field). */
1357
+ function patchTaxonomy(tax , a ) {
1358
+ const next = { ...tax };
1359
+ for (const f of TAXONOMY_FIELDS) {
1360
+ if (a[f] === undefined) continue;
1361
+ if (a[f] === null) delete next[f];
1362
+ else next[f] = a[f];
1363
+ }
1364
+ return next ;
1365
+ }
1366
+
1367
+ async function mergePrompt(
1368
+ ctx ,
1369
+ sources ,
1370
+ target ,
1371
+ prefix = "",
1372
+ ) {
1373
+ const tax = await taxonomyOf(ctx, target);
1374
+ const locale = await defaultLocale(ctx, target.siteId);
1375
+ const n = await entriesUsing(
1376
+ ctx,
1377
+ target.siteId,
1378
+ tax.key,
1379
+ sources.map((s) => s.id),
1380
+ );
1381
+ const lines = tax.archive.enabled
1382
+ ? await redirectLines(ctx, target.siteId, {
1383
+ serve: [archivePath(tax, target.slug)],
1384
+ moves: sources
1385
+ .filter(isPublic)
1386
+ .map((s) => ({ from: archivePath(tax, s.slug), to: archivePath(tax, target.slug) })),
1387
+ repoints: sources
1388
+ .filter((s) => !isPublic(s))
1389
+ .map((s) => ({ from: archivePath(tax, s.slug), to: archivePath(tax, target.slug) })),
1390
+ })
1391
+ : [];
1392
+ const names = sources.map((s) => `"${quote(termName(s, locale), 40)}" (${s.slug})`).join(", ");
1393
+ return withLines(
1394
+ `${prefix}Merge ${tax.key} ${names} into ${named(tax, target, locale)}? ${articles(n)} re-filed under it; the merged terms retire and their names and slugs keep resolving to it.${lines.length ? " Redirects:" : ""}`,
1395
+ lines,
1396
+ );
1397
+ }
1398
+
1399
+ async function retirePrompt(
1400
+ ctx ,
1401
+ term ,
1402
+ target ,
1403
+ prefix = "",
1404
+ ) {
1405
+ const tax = await taxonomyOf(ctx, term);
1406
+ const locale = await defaultLocale(ctx, term.siteId);
1407
+ const n = await entriesUsing(ctx, term.siteId, tax.key, [term.id]);
1408
+ let to = target;
1409
+ if (!to && tax.hierarchical && term.parentId) {
1410
+ const parent = await ctx.store.getTerm(term.parentId);
1411
+ if (parent && isPublic(parent)) to = parent;
1412
+ }
1413
+ const children = (await ctx.store.listTerms(term.siteId, { taxonomy: tax.key })).filter(
1414
+ (t) => t.parentId === term.id && t.status !== "retired",
1415
+ ).length;
1416
+ const from = archivePath(tax, term.slug);
1417
+ const lines =
1418
+ tax.archive.enabled && to
1419
+ ? await redirectLines(ctx, term.siteId, {
1420
+ ...(isPublic(term)
1421
+ ? { moves: [{ from, to: archivePath(tax, to.slug) }] }
1422
+ : { repoints: [{ from, to: archivePath(tax, to.slug) }] }),
1423
+ })
1424
+ : [];
1425
+ const gone =
1426
+ tax.archive.enabled && isPublic(term) && !to
1427
+ ? ` Its archive ${from} will answer 410 Gone.`
1428
+ : "";
1429
+ return withLines(
1430
+ `${prefix}Retire ${named(tax, term, locale)}? It is removed from ${articles(n)}${children ? `; its ${children} sub-term${children === 1 ? "" : "s"} move up` : ""}.${gone}${lines.length ? " Redirects:" : ""}`,
1431
+ lines,
1432
+ );
1433
+ }