@caelo-cms/shared 0.11.0 → 0.11.2

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 (137) hide show
  1. package/dist/ai-tools.d.ts +32 -144
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +46 -139
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/bug-intake.d.ts +46 -3
  6. package/dist/bug-intake.d.ts.map +1 -1
  7. package/dist/bug-intake.js +52 -3
  8. package/dist/bug-intake.js.map +1 -1
  9. package/dist/build-page.d.ts +5 -5
  10. package/dist/build-page.d.ts.map +1 -1
  11. package/dist/build-page.js +14 -9
  12. package/dist/build-page.js.map +1 -1
  13. package/dist/content.d.ts +14 -2
  14. package/dist/content.d.ts.map +1 -1
  15. package/dist/content.js +73 -15
  16. package/dist/content.js.map +1 -1
  17. package/dist/css-scope-scan.js +1 -1
  18. package/dist/css-var-scan.js +3 -3
  19. package/dist/css-var-scan.js.map +1 -1
  20. package/dist/escaped-line-breaks.d.ts +44 -0
  21. package/dist/escaped-line-breaks.d.ts.map +1 -0
  22. package/dist/escaped-line-breaks.js +203 -0
  23. package/dist/escaped-line-breaks.js.map +1 -0
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/interactions.js +5 -5
  29. package/dist/interactions.js.map +1 -1
  30. package/dist/lists/default-types.d.ts +12 -0
  31. package/dist/lists/default-types.d.ts.map +1 -1
  32. package/dist/lists/default-types.js +36 -0
  33. package/dist/lists/default-types.js.map +1 -1
  34. package/dist/lists/fields.d.ts.map +1 -1
  35. package/dist/lists/fields.js +1 -0
  36. package/dist/lists/fields.js.map +1 -1
  37. package/dist/lists/render-data.d.ts +9 -0
  38. package/dist/lists/render-data.d.ts.map +1 -1
  39. package/dist/lists/render-deps.d.ts +4 -3
  40. package/dist/lists/render-deps.d.ts.map +1 -1
  41. package/dist/lists/render-deps.js +5 -3
  42. package/dist/lists/render-deps.js.map +1 -1
  43. package/dist/lists/resolve.d.ts +13 -0
  44. package/dist/lists/resolve.d.ts.map +1 -1
  45. package/dist/lists/resolve.js +95 -3
  46. package/dist/lists/resolve.js.map +1 -1
  47. package/dist/live-events.d.ts +44 -0
  48. package/dist/live-events.d.ts.map +1 -0
  49. package/dist/live-events.js +128 -0
  50. package/dist/live-events.js.map +1 -0
  51. package/dist/markup-structure.d.ts +20 -2
  52. package/dist/markup-structure.d.ts.map +1 -1
  53. package/dist/markup-structure.js +69 -3
  54. package/dist/markup-structure.js.map +1 -1
  55. package/dist/media.d.ts +15 -0
  56. package/dist/media.d.ts.map +1 -1
  57. package/dist/media.js.map +1 -1
  58. package/dist/module-id-tag.d.ts +4 -1
  59. package/dist/module-id-tag.d.ts.map +1 -1
  60. package/dist/module-id-tag.js +11 -2
  61. package/dist/module-id-tag.js.map +1 -1
  62. package/dist/nested-module-render.d.ts +13 -0
  63. package/dist/nested-module-render.d.ts.map +1 -1
  64. package/dist/nested-module-render.js +34 -0
  65. package/dist/nested-module-render.js.map +1 -1
  66. package/dist/preview-compose.d.ts +18 -1
  67. package/dist/preview-compose.d.ts.map +1 -1
  68. package/dist/preview-compose.js +26 -7
  69. package/dist/preview-compose.js.map +1 -1
  70. package/dist/redirect-plan.d.ts +13 -0
  71. package/dist/redirect-plan.d.ts.map +1 -1
  72. package/dist/redirect-plan.js +22 -0
  73. package/dist/redirect-plan.js.map +1 -1
  74. package/dist/seo.d.ts +21 -0
  75. package/dist/seo.d.ts.map +1 -1
  76. package/dist/seo.js +32 -0
  77. package/dist/seo.js.map +1 -1
  78. package/dist/source-text-transport.d.ts +28 -2
  79. package/dist/source-text-transport.d.ts.map +1 -1
  80. package/dist/source-text-transport.js +12 -4
  81. package/dist/source-text-transport.js.map +1 -1
  82. package/dist/template-sections.d.ts.map +1 -1
  83. package/dist/template-sections.js +17 -4
  84. package/dist/template-sections.js.map +1 -1
  85. package/dist/theme-render.js +11 -26
  86. package/dist/theme-render.js.map +1 -1
  87. package/dist/themes.d.ts.map +1 -1
  88. package/dist/themes.js +12 -9
  89. package/dist/themes.js.map +1 -1
  90. package/dist/version.d.ts +2 -2
  91. package/dist/version.js +1 -1
  92. package/package.json +1 -1
  93. package/src/__tests__/redos-hardening.test.ts +1 -0
  94. package/src/ai-tools-add-module-modes.test.ts +1 -1
  95. package/src/ai-tools.test.ts +7 -20
  96. package/src/ai-tools.ts +57 -151
  97. package/src/bug-intake.test.ts +48 -1
  98. package/src/bug-intake.ts +58 -3
  99. package/src/build-page.ts +14 -9
  100. package/src/content.test.ts +26 -1
  101. package/src/content.ts +84 -16
  102. package/src/css-scope-scan.ts +1 -1
  103. package/src/css-var-scan.ts +3 -3
  104. package/src/escaped-line-breaks.ts +221 -0
  105. package/src/index.ts +2 -0
  106. package/src/interactions.ts +5 -5
  107. package/src/link-refs.test.ts +1 -0
  108. package/src/lists/default-types.ts +39 -0
  109. package/src/lists/fields.ts +1 -0
  110. package/src/lists/list-fixtures.test.ts +9 -3
  111. package/src/lists/render-data.ts +9 -0
  112. package/src/lists/render-deps.ts +6 -3
  113. package/src/lists/resolve.test.ts +182 -5
  114. package/src/lists/resolve.ts +101 -3
  115. package/src/live-events.ts +138 -0
  116. package/src/markup-structure.test.ts +47 -0
  117. package/src/markup-structure.ts +74 -3
  118. package/src/media.ts +12 -0
  119. package/src/module-id-tag.test.ts +38 -0
  120. package/src/module-id-tag.ts +11 -2
  121. package/src/nested-module-render.test.ts +39 -0
  122. package/src/nested-module-render.ts +35 -0
  123. package/src/preview-compose-deferrals.test.ts +44 -0
  124. package/src/preview-compose.ts +47 -10
  125. package/src/redirect-plan.test.ts +25 -0
  126. package/src/redirect-plan.ts +24 -0
  127. package/src/seo.test.ts +58 -0
  128. package/src/seo.ts +49 -0
  129. package/src/source-text-transport.test.ts +98 -3
  130. package/src/source-text-transport.ts +15 -4
  131. package/src/template-engine-lists.test.ts +55 -0
  132. package/src/template-sections.ts +18 -4
  133. package/src/theme-importers/__tests__/proto-pollution.test.ts +13 -3
  134. package/src/theme-render-sanitize.test.ts +8 -0
  135. package/src/theme-render.ts +11 -26
  136. package/src/themes.ts +12 -7
  137. package/src/version.ts +1 -1
package/dist/version.d.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * Format: SemVer 2.0.0. Pre-1.0 minor bumps are breaking; post-1.0
20
20
  * follow standard SemVer.
21
21
  */
22
- export declare const CAELO_VERSION = "0.11.0";
22
+ export declare const CAELO_VERSION = "0.11.2";
23
23
  /**
24
24
  * Deprecated alias for back-compat — early P17 work spelled this
25
25
  * `CALEO_VERSION` (typo of "Caelo"). New code should import
@@ -31,7 +31,7 @@ export declare const CAELO_VERSION = "0.11.0";
31
31
  *
32
32
  * @deprecated use CAELO_VERSION
33
33
  */
34
- export declare const CALEO_VERSION = "0.11.0";
34
+ export declare const CALEO_VERSION = "0.11.2";
35
35
  /**
36
36
  * Parsed shape — major/minor/patch + optional pre-release tag.
37
37
  * Stable interface for callers that need to feature-gate (rare —
package/dist/version.js CHANGED
@@ -20,7 +20,7 @@
20
20
  * Format: SemVer 2.0.0. Pre-1.0 minor bumps are breaking; post-1.0
21
21
  * follow standard SemVer.
22
22
  */
23
- export const CAELO_VERSION = "0.11.0";
23
+ export const CAELO_VERSION = "0.11.2";
24
24
  /**
25
25
  * Deprecated alias for back-compat — early P17 work spelled this
26
26
  * `CALEO_VERSION` (typo of "Caelo"). New code should import
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caelo-cms/shared",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "private": false,
5
5
  "license": "MPL-2.0",
6
6
  "description": "Shared Zod schemas + types + the two-pass HTML composer used by every Caelo CMS workspace package. Stable enough for plugin authors and embedders to depend on directly.",
@@ -69,6 +69,7 @@ describe("renderTemplate sections (S4, epic #1107 tokenizer)", () => {
69
69
  pages: new Map(),
70
70
  media: new Map(),
71
71
  siteLanguage: "en",
72
+ homePageId: null,
72
73
  }).forPage(null);
73
74
  const navField = { name: "nav_items", kind: "list" as const, listType: "links" };
74
75
  const entry = (n: number, label: string, href: string) => ({
@@ -71,7 +71,7 @@ describe("addModuleToolInput modes (issue #159)", () => {
71
71
  it("TOLERATES a moduleId call carrying authoring keys (incl. structural html) — placement-only, handler surfaces the ignored-authoring info", () => {
72
72
  // §1A/§11 — a valid placement must never fail over an extra authoring
73
73
  // field. moduleId + html passes; the tool handler (not the schema)
74
- // reports that html was not applied and points at edit_module.
74
+ // reports that html was not applied and points at update_modules_many.
75
75
  const r = addModuleToolInput.safeParse({
76
76
  ...BASE,
77
77
  moduleId: "11111111-1111-4111-8111-111111111102",
@@ -1,24 +1,21 @@
1
1
  // SPDX-License-Identifier: MPL-2.0
2
2
 
3
3
  import { describe, expect, it } from "bun:test";
4
- import {
5
- chatPublishInput,
6
- chatSendMessageInput,
7
- editModuleToolInput,
8
- siteMemoryProposeToolInput,
9
- } from "./ai-tools.js";
4
+ import { chatPublishInput, chatSendMessageInput, siteMemoryProposeToolInput } from "./ai-tools.js";
5
+ import { moduleUpdateSchema } from "./content.js";
10
6
 
11
7
  const UUID = "11111111-1111-4111-8111-111111111111";
12
8
  const UUID_2 = "22222222-2222-4222-8222-222222222222";
13
9
 
14
- describe("editModuleToolInput", () => {
10
+ // The per-item shape of update_modules_many (the module-edit tool extends it).
11
+ describe("moduleUpdateSchema (update_modules_many item)", () => {
15
12
  it("accepts a minimal valid edit", () => {
16
- const r = editModuleToolInput.safeParse({ moduleId: UUID, html: "<p>x</p>" });
13
+ const r = moduleUpdateSchema.safeParse({ moduleId: UUID, html: "<p>x</p>" });
17
14
  expect(r.success).toBe(true);
18
15
  });
19
16
 
20
17
  it("rejects unknown keys (.strict)", () => {
21
- const r = editModuleToolInput.safeParse({
18
+ const r = moduleUpdateSchema.safeParse({
22
19
  moduleId: UUID,
23
20
  html: "<p>x</p>",
24
21
  pageId: UUID_2,
@@ -27,22 +24,12 @@ describe("editModuleToolInput", () => {
27
24
  });
28
25
 
29
26
  it("rejects oversized html", () => {
30
- const r = editModuleToolInput.safeParse({
27
+ const r = moduleUpdateSchema.safeParse({
31
28
  moduleId: UUID,
32
29
  html: "x".repeat(300_000),
33
30
  });
34
31
  expect(r.success).toBe(false);
35
32
  });
36
-
37
- // e2e-livedit run 40351690739 — `{moduleId}` alone reached the op.
38
- it("refuses an edit that changes nothing, naming what to pass", () => {
39
- for (const input of [{ moduleId: UUID }, { moduleId: UUID, bindThemeLiterals: false }]) {
40
- const r = editModuleToolInput.safeParse(input);
41
- expect(r.success).toBe(false);
42
- if (!r.success) expect(r.error.issues[0]?.message).toContain("edit_module changes nothing");
43
- }
44
- expect(editModuleToolInput.safeParse({ moduleId: UUID, kind: "hero" }).success).toBe(true);
45
- });
46
33
  });
47
34
 
48
35
  describe("siteMemoryProposeToolInput", () => {
package/src/ai-tools.ts CHANGED
@@ -17,15 +17,14 @@ import {
17
17
  contentInstanceDeleteSchema,
18
18
  contentInstanceUpdateSchema,
19
19
  forkPlacementContentSchema,
20
- MODULE_CSS_MAX,
21
- MODULE_HTML_MAX,
22
- MODULE_JS_MAX,
23
20
  type ModuleField,
24
21
  moduleFieldSchema,
22
+ pageShortTitleSchema,
25
23
  pageTypeSlugSchema,
26
24
  setPlacementContentSchema,
27
25
  slugSchema,
28
26
  } from "./content.js";
27
+ import type { SourceLanguage } from "./escaped-line-breaks.js";
29
28
  import { LIST_ITEM_ATTR_RE } from "./lists/item-attr.js";
30
29
  import { pluginPreviewSelectionSchema } from "./plugin-preview.js";
31
30
  import { redirectTargetPathSchema } from "./redirect-target.js";
@@ -71,9 +70,10 @@ function stripSurroundingQuotes(v: unknown): unknown {
71
70
  * is handled in one place instead of refused per tool.
72
71
  *
73
72
  * @param schema - the string schema (length caps, min) the decoded value must satisfy.
73
+ * @param language - the grammar of the value (`html`, `css`, `js`).
74
74
  */
75
- export function sourceTextArg<T extends z.ZodType<string>>(schema: T) {
76
- return z.preprocess(decodeSourceTextTransport, schema);
75
+ export function sourceTextArg<T extends z.ZodType<string>>(schema: T, language: SourceLanguage) {
76
+ return z.preprocess((value) => decodeSourceTextTransport(value, language), schema);
77
77
  }
78
78
 
79
79
  export const positionInputSchema = z.preprocess(
@@ -87,63 +87,6 @@ export const positionInputSchema = z.preprocess(
87
87
  ]),
88
88
  );
89
89
 
90
- export const editModuleToolInput = z
91
- .object({
92
- moduleId: z.string().uuid(),
93
- displayName: z.string().min(1).max(128).optional(),
94
- /**
95
- * v0.12.0 — rewrite the module's purpose. Optional; passing it
96
- * updates the `list_modules` catalog your future self will read.
97
- */
98
- description: z.string().max(1000).optional(),
99
- /** v0.12.0 — re-classify the module's role tag. */
100
- kind: z.enum(["chrome", "hero", "content", "cta", "utility"]).optional(),
101
- /**
102
- * v0.12.3 (issue #106) — re-classify the module's stable `type` (the
103
- * class a parent's `allowedModuleTypes` whitelist matches against,
104
- * e.g. `button`). Rarely needed; usually derived from displayName at
105
- * create time. Set it to make this module satisfy a parent's allowlist.
106
- */
107
- type: slugSchema.optional(),
108
- html: sourceTextArg(z.string().max(MODULE_HTML_MAX)).optional(),
109
- css: sourceTextArg(z.string().max(MODULE_CSS_MAX)).optional(),
110
- js: sourceTextArg(z.string().max(MODULE_JS_MAX)).optional(),
111
- /**
112
- * v0.4.0 — module field schema. Each field declares one substitution
113
- * slot referenced in the module HTML as `{{name}}`. Page placements
114
- * fill these via `set_page_module_content`. Optional on edits — pass
115
- * to replace the declared schema.
116
- */
117
- fields: z.array(moduleFieldSchema).max(64).optional(),
118
- /** issue #164 slice 2 — see addModuleToolInput.bindThemeLiterals. */
119
- bindThemeLiterals: z.boolean().optional(),
120
- })
121
- .strict()
122
- // e2e-livedit run 40351690739: a call with only a (placeholder)
123
- // moduleId reached the op and came back as a bare "module not found".
124
- // An edit that changes nothing is refused before any lookup, with the
125
- // fields that make it one.
126
- .refine((v) => Object.keys(v).some((k) => k !== "moduleId" && k !== "bindThemeLiterals"), {
127
- message:
128
- "edit_module changes nothing: pass what to change (html, css, js, fields, displayName, description, kind or type). list_modules shows the modules; add_module creates a new one.",
129
- });
130
-
131
- /**
132
- * v0.4.0 — `set_page_module_content` AI tool. Fills the content values for
133
- * a module placement on a specific page. Page-bound + branch-isolated per
134
- * chat until publish (unlike `edit_module` which is global + immediate).
135
- */
136
- export const setPageModuleContentToolInput = z
137
- .object({
138
- pageId: z.string().uuid(),
139
- blockName: z.string().min(1).max(64),
140
- position: z.number().int().nonnegative(),
141
- /** Map of module field name → value. Fully replaces existing values. */
142
- contentValues: z.record(z.string(), z.unknown()),
143
- })
144
- .strict();
145
- export type SetPageModuleContentToolInput = z.infer<typeof setPageModuleContentToolInput>;
146
-
147
90
  export const siteMemoryProposeToolInput = z
148
91
  .object({
149
92
  slot: z.enum(["purpose", "brand-voice", "tone", "banned-phrases", "instructions", "glossary"]),
@@ -218,9 +161,9 @@ export const addModuleToolInput = z
218
161
  description: z.string().max(1000).optional(),
219
162
  kind: z.enum(["chrome", "hero", "content", "cta", "utility"]).optional(),
220
163
  type: slugSchema.optional(),
221
- html: sourceTextArg(z.string().min(1).max(50_000)).optional(),
222
- css: sourceTextArg(z.string().max(50_000)).optional(),
223
- js: sourceTextArg(z.string().max(50_000)).optional(),
164
+ html: sourceTextArg(z.string().min(1).max(50_000), "html").optional(),
165
+ css: sourceTextArg(z.string().max(50_000), "css").optional(),
166
+ js: sourceTextArg(z.string().max(50_000), "js").optional(),
224
167
  fields: z.array(moduleFieldSchema).max(64).optional(),
225
168
  bindThemeLiterals: z.boolean().optional(),
226
169
  /**
@@ -233,8 +176,8 @@ export const addModuleToolInput = z
233
176
  * stores the values as the minted module's `fields[].default` —
234
177
  * which requires explicit `fields` (moduleize field names are
235
178
  * unknowable up front) and is rejected for `moduleId` reuse (a
236
- * shared module renders its stored defaults; edit_module changes
237
- * them).
179
+ * shared module renders its stored defaults; update_modules_many
180
+ * changes them).
238
181
  */
239
182
  values: z.record(z.string(), z.unknown()).optional(),
240
183
  })
@@ -245,7 +188,7 @@ export const addModuleToolInput = z
245
188
  ctx.addIssue({
246
189
  code: z.ZodIssueCode.custom,
247
190
  message:
248
- "Reusing a module on a layout renders the module's STORED field defaults — `values` has nothing to bind to. Drop `values` (the chrome shows the module as-is), or change the shared copy via edit_module.",
191
+ "Reusing a module on a layout renders the module's STORED field defaults — `values` has nothing to bind to. Drop `values` (the chrome shows the module as-is), or change the shared copy via update_modules_many.",
249
192
  });
250
193
  } else if (input.fields === undefined || input.fields.length === 0) {
251
194
  ctx.addIssue({
@@ -269,7 +212,7 @@ export const addModuleToolInput = z
269
212
  // (§1A/§11), so we do NOT reject a moduleId call that also carries authoring
270
213
  // fields. Those fields are not applied here; the tool HANDLER surfaces an
271
214
  // INFO for any ignored field whose value DIFFERS from the module's stored
272
- // one, pointing at edit_module — so a dropped change is never silent (§2).
215
+ // one, pointing at update_modules_many — so a dropped change is never silent (§2).
273
216
  if (input.moduleId !== undefined) return;
274
217
  if (input.displayName === undefined || input.html === undefined) {
275
218
  ctx.addIssue({
@@ -361,44 +304,6 @@ export const deleteContentInstanceToolInput = contentInstanceDeleteSchema;
361
304
  export const setPlacementContentToolInput = setPlacementContentSchema;
362
305
  export const forkPlacementContentToolInput = forkPlacementContentSchema;
363
306
 
364
- export const AI_TOOLS = [
365
- "edit_module",
366
- "site_memory_propose",
367
- "add_module",
368
- "create_page",
369
- // audit #3 — page metadata (name/title/slug/template/status) is one tool for
370
- // 1..200 pages: `update_pages_many`. The former rename_page / set_page_title
371
- // / change_page_slug were single-field wrappers over the same pages.update.
372
- "update_pages_many",
373
- // audit #4 — one delete tool for 1..200 pages, disposition per page.
374
- // The former singular delete_page folded in (n=1 is a one-item array).
375
- "delete_pages_many",
376
- // one remove-module tool routed by target (page|layout).
377
- "remove_module_from",
378
- "update_theme",
379
- "set_template_layout",
380
- "create_layout",
381
- "set_site_defaults",
382
- "duplicate_page",
383
- "repoint_page_template",
384
- "move_module",
385
- "reorder_module",
386
- "set_nav_menu",
387
- "add_plugin_to_page",
388
- // v0.12.0 — content_instances + placement binding
389
- "list_content_instances",
390
- "get_content_instance",
391
- "create_content_instance",
392
- "set_content_instance_values",
393
- "delete_content_instance",
394
- "set_placement_content",
395
- "fork_placement_content",
396
- // issue #299 — bulk-first build path (§11)
397
- "build_page",
398
- "create_content_instances",
399
- "set_page_module_content_many",
400
- ] as const;
401
- export type AiToolName = (typeof AI_TOOLS)[number];
402
307
  export type AddPluginToPageToolInput = z.infer<typeof addPluginToPageToolInput>;
403
308
  export type ListContentInstancesToolInput = z.infer<typeof listContentInstancesToolInput>;
404
309
  export type GetContentInstanceToolInput = z.infer<typeof getContentInstanceToolInput>;
@@ -473,7 +378,7 @@ export const CHAT_MAX_ATTACHMENTS = 4;
473
378
  * `position` / `field` (#917) — plus `item` / `key` for an entry of a
474
379
  * local list field (epic #1107) — name the placement field the text
475
380
  * comes from when the preview marked it, so the AI can address it in
476
- * `set_page_module_content` without inspecting the page first.
381
+ * `set_page_module_content_many` without inspecting the page first.
477
382
  * `listItem` is the `data-caelo-list-item` of the list entry the element
478
383
  * sits in (epic #1107, `docs/lists.md` §13): the chip then names that
479
384
  * entry — of a shared list, or of a module's own list — not just the
@@ -676,8 +581,8 @@ export const createTemplateToolInput = z
676
581
  .object({
677
582
  slug: slugInputSchema,
678
583
  displayName: z.string().min(1).max(256),
679
- html: sourceTextArg(z.string().min(1).max(2_000_000)),
680
- css: sourceTextArg(z.string().max(2_000_000)).default(""),
584
+ html: sourceTextArg(z.string().min(1).max(2_000_000), "html"),
585
+ css: sourceTextArg(z.string().max(2_000_000), "css").default(""),
681
586
  layoutId: z.string().uuid().optional(),
682
587
  /** Epic #1107 — the page type (slug) this template serves; omit for the site default. */
683
588
  pageType: pageTypeSlugSchema.optional(),
@@ -728,8 +633,8 @@ export const createLayoutToolInput = z
728
633
  .object({
729
634
  slug: slugInputSchema,
730
635
  displayName: z.string().min(1).max(200),
731
- html: sourceTextArg(z.string().min(1).max(50_000)),
732
- css: sourceTextArg(z.string().max(50_000)).optional(),
636
+ html: sourceTextArg(z.string().min(1).max(50_000), "html"),
637
+ css: sourceTextArg(z.string().max(50_000), "css").optional(),
733
638
  blocks: z
734
639
  .array(
735
640
  z
@@ -765,8 +670,9 @@ export const duplicatePageToolInput = z
765
670
  .object({
766
671
  sourcePageId: z.string().uuid(),
767
672
  newSlug: slugInputSchema,
768
- newName: z.string().min(1).max(256).optional(),
769
673
  newTitle: z.string().min(1).max(256).optional(),
674
+ /** #1142 — the copy's breadcrumb label; omitted = newTitle when given, else the source's. */
675
+ newShortTitle: pageShortTitleSchema.optional(),
770
676
  targetTemplateId: z.string().uuid().optional(),
771
677
  /** Where the copy sits: a parent page id, or null for the top level.
772
678
  * Omitted = next to the source (same parent). */
@@ -794,32 +700,50 @@ export const repointPageTemplateToolInput = z
794
700
  })
795
701
  .strict();
796
702
 
797
- export const moveModuleToolInput = z
798
- .object({
799
- pageId: z.string().uuid(),
800
- moduleId: z.string().uuid(),
801
- toBlockName: z.string().min(1).max(80),
802
- /** "top" | "bottom" | a 0-based index inside the destination block. */
803
- position: positionInputSchema,
804
- })
805
- .strict();
703
+ /**
704
+ * `move_module` position: `top` | `bottom` | a 0-based index in the target
705
+ * block, plus `up` / `down` (one slot within the module's CURRENT block).
706
+ * Same quote-stripping + numeric-string coercion as `positionInputSchema`.
707
+ */
708
+ export const movePositionInputSchema = z.preprocess(
709
+ stripSurroundingQuotes,
710
+ z.union([
711
+ z.enum(["top", "bottom", "up", "down"]),
712
+ z.preprocess(
713
+ (v) => (typeof v === "string" && /^\d+$/.test(v) ? Number.parseInt(v, 10) : v),
714
+ z.number().int().min(0).max(1000),
715
+ ),
716
+ ]),
717
+ );
806
718
 
807
- export const reorderModuleToolInput = z
719
+ /**
720
+ * `move_module` — moves a placement to `toBlockName` (omitted = the block it
721
+ * is in now, i.e. a reorder) at `position`. `up` / `down` only make sense
722
+ * inside the current block; the handler refuses them with another block.
723
+ */
724
+ export const moveModuleToolInput = z
808
725
  .object({
809
726
  pageId: z.string().uuid(),
810
727
  moduleId: z.string().uuid(),
811
728
  /**
812
- * `up` / `down` shift one slot. An integer is an absolute 0-based
813
- * target position within the same block.
729
+ * #1195 — which placement of `moduleId` to move when the page holds it
730
+ * more than once: its block and 0-based position there. Needed only
731
+ * then; the error names the placements.
814
732
  */
815
- direction: z.union([z.enum(["up", "down"]), z.number().int().min(0).max(1000)]),
733
+ fromBlockName: z.string().min(1).max(80).optional(),
734
+ fromPosition: z.preprocess(
735
+ (v) => (typeof v === "string" && /^\d+$/.test(v) ? Number.parseInt(v, 10) : v),
736
+ z.number().int().min(0).max(1000).optional(),
737
+ ),
738
+ toBlockName: z.string().min(1).max(80).optional(),
739
+ position: movePositionInputSchema,
816
740
  })
817
741
  .strict();
818
742
 
819
743
  /**
820
744
  * P7 — `find_media`. Searches the media library by alt-text /
821
745
  * filename / mime. Each row carries the media UUID (what
822
- * `set_theme_asset` / `set_media_alt` require) plus a pre-resolved
746
+ * `set_theme_asset` / `set_media_alt_many` require) plus a pre-resolved
823
747
  * variant URL. The AI's only media-inventory surface — the `## Media`
824
748
  * system-prompt block was removed by the context diet (#300), so every
825
749
  * asset lookup ("an image of a sunlit office") goes through this tool.
@@ -864,7 +788,7 @@ export const regenerateMediaVariantsToolInput = z
864
788
  export type RegenerateMediaVariantsToolInput = z.infer<typeof regenerateMediaVariantsToolInput>;
865
789
 
866
790
  /**
867
- * P7 — `set_media_alt`. AI may improve a11y on an existing asset
791
+ * P7 — one item of `set_media_alt_many`. AI may improve a11y on an existing asset
868
792
  * without a human round-trip (e.g. when the editor uploaded an image
869
793
  * with the default alt and the AI reads its content). Updates only
870
794
  * the alt field on `media_assets`.
@@ -894,7 +818,7 @@ export const setNotFoundPageToolInput = z.object({ pageId: z.string().uuid().nul
894
818
  export type SetNotFoundPageToolInput = z.infer<typeof setNotFoundPageToolInput>;
895
819
 
896
820
  /**
897
- * 0181 — `set_media_source`. AI records an asset's provenance (source
821
+ * 0181 — one item of `set_media_source_many`. AI records an asset's provenance (source
898
822
  * kind + origin detail) and licence when known. All fields but the
899
823
  * assetId are optional; the op COALESCEs so omitted fields stay
900
824
  * unchanged.
@@ -910,7 +834,7 @@ export const setMediaSourceToolInput = z
910
834
  export type SetMediaSourceToolInput = z.infer<typeof setMediaSourceToolInput>;
911
835
 
912
836
  /**
913
- * P8 — `set_page_seo`. Manual / panel writes to the per-page SEO
837
+ * P8 — one item of `set_page_seo_many`. Manual / panel writes to the per-page SEO
914
838
  * sidecar. AI calls this only on explicit user intent
915
839
  * ("set the home meta description to ..."). Doesn't bump fingerprints
916
840
  * (autofilled_at / optimized_at).
@@ -931,7 +855,7 @@ export const setPageSeoToolInput = z
931
855
  export type SetPageSeoToolInput = z.infer<typeof setPageSeoToolInput>;
932
856
 
933
857
  /**
934
- * P8 — `autofill_page_seo` (and, per item, `autofill_page_seo_many`).
858
+ * P8 — one item of `autofill_page_seo_many`.
935
859
  * Fill-once. Refuses when the page's SEO was already autofilled. The AI
936
860
  * calls it before a page's first Stage; staging and production builds
937
861
  * refuse pages without a meta description (deploy/missing-seo.ts).
@@ -945,22 +869,6 @@ export const autofillPageSeoToolInput = z
945
869
  .strict();
946
870
  export type AutofillPageSeoToolInput = z.infer<typeof autofillPageSeoToolInput>;
947
871
 
948
- /**
949
- * P8 — `optimize_page_seo`. Explicit re-optimization. Always allowed.
950
- * Takes optional context (keyword analysis / intent shifts / branding
951
- * changes). The AI can call this across N pages in one chat turn —
952
- * the resulting changes batch into one Publish-pill confirm.
953
- */
954
- export const optimizePageSeoToolInput = z
955
- .object({
956
- pageId: z.string().uuid(),
957
- metaDescription: z.string().min(1).max(320),
958
- ogImageAssetId: z.string().uuid().nullable().optional(),
959
- context: z.string().max(4000).optional(),
960
- })
961
- .strict();
962
- export type OptimizePageSeoToolInput = z.infer<typeof optimizePageSeoToolInput>;
963
-
964
872
  /**
965
873
  * P8 AI-first review pass — bulk variants. Per CLAUDE.md §11, bulk
966
874
  * tools save round-trips when the AI knows it has N changes to make
@@ -980,7 +888,7 @@ export type FindRedirectsToolInput = z.infer<typeof findRedirectsToolInput>;
980
888
 
981
889
  export const bulkCreateRedirectsToolInput = z
982
890
  .object({
983
- redirects: z
891
+ items: z
984
892
  .array(
985
893
  z
986
894
  .object({
@@ -1032,7 +940,7 @@ export type BulkDeleteRedirectsToolInput = z.infer<typeof bulkDeleteRedirectsToo
1032
940
 
1033
941
  export const bulkOptimizeSeoToolInput = z
1034
942
  .object({
1035
- updates: z
943
+ items: z
1036
944
  .array(
1037
945
  z
1038
946
  .object({
@@ -1049,7 +957,6 @@ export const bulkOptimizeSeoToolInput = z
1049
957
  .strict();
1050
958
  export type BulkOptimizeSeoToolInput = z.infer<typeof bulkOptimizeSeoToolInput>;
1051
959
 
1052
- export type EditModuleToolInput = z.infer<typeof editModuleToolInput>;
1053
960
  export type SiteMemoryProposeToolInput = z.infer<typeof siteMemoryProposeToolInput>;
1054
961
  export type CreateTemplateToolInput = z.infer<typeof createTemplateToolInput>;
1055
962
  // v0.10.22 — `UpdateThemeToolInput` removed alongside `update_theme` tool.
@@ -1067,7 +974,6 @@ export type SetSiteDefaultsToolInput = z.infer<typeof setSiteDefaultsToolInput>;
1067
974
  export type DuplicatePageToolInput = z.infer<typeof duplicatePageToolInput>;
1068
975
  export type RepointPageTemplateToolInput = z.infer<typeof repointPageTemplateToolInput>;
1069
976
  export type MoveModuleToolInput = z.infer<typeof moveModuleToolInput>;
1070
- export type ReorderModuleToolInput = z.infer<typeof reorderModuleToolInput>;
1071
977
  // v0.10.22 — `SetNavMenuToolInput` removed alongside `set_nav_menu` tool.
1072
978
 
1073
979
  /**
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { describe, expect, it } from "bun:test";
11
11
  import {
12
+ BUG_REPORT_KIND_BEFORE_1192,
12
13
  bugIntakeReport,
13
14
  bugIntakeSigningString,
14
15
  computeBugFingerprint,
@@ -32,7 +33,7 @@ describe("computeBugFingerprint", () => {
32
33
  });
33
34
 
34
35
  it("keeps different tools and different quoted field names apart", async () => {
35
- expect(await auto("boom", "build_page")).not.toBe(await auto("boom", "edit_module"));
36
+ expect(await auto("boom", "build_page")).not.toBe(await auto("boom", "update_modules_many"));
36
37
  expect(await auto('Invalid input at "page.slug"')).not.toBe(
37
38
  await auto('Invalid input at "page.title"'),
38
39
  );
@@ -152,3 +153,49 @@ describe("bugIntakeReport", () => {
152
153
  expect(parsed.success).toBe(false);
153
154
  });
154
155
  });
156
+
157
+ describe("bugIntakeReport kind (#1192)", () => {
158
+ const base = {
159
+ protocolVersion: 1,
160
+ reportId: "7f3c2b9e-1d4a-4e8b-9c0f-2a1b3c4d5e6f",
161
+ caeloVersion: "0.10.55",
162
+ fingerprint: "a".repeat(64),
163
+ source: "ai",
164
+ severity: "degraded",
165
+ blockedTask: false,
166
+ suspectedTool: null,
167
+ title: "t",
168
+ whatHappened: "w",
169
+ expected: "e",
170
+ evidence: null,
171
+ } as const;
172
+
173
+ it("parses a report without kind, as an install from before #1192 sends it, as a defect", () => {
174
+ expect(BUG_REPORT_KIND_BEFORE_1192).toBe("defect");
175
+ const parsed = bugIntakeReport.parse(base);
176
+ expect(parsed.kind).toBe("defect");
177
+ expect(parsed.severity).toBe("degraded");
178
+ });
179
+
180
+ it("round-trips a suggestion, which carries no severity", () => {
181
+ const sent = { ...base, kind: "suggestion", severity: null };
182
+ const parsed = bugIntakeReport.parse(sent);
183
+ expect(parsed).toEqual(sent);
184
+ expect(bugIntakeReport.parse(JSON.parse(JSON.stringify(parsed)))).toEqual(sent);
185
+ });
186
+
187
+ it("refuses a defect without severity, a graded suggestion and an auto-captured suggestion", () => {
188
+ const issues = (r: unknown) =>
189
+ bugIntakeReport.safeParse(r).error?.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
190
+ expect(issues({ ...base, kind: "defect", severity: null })).toEqual([
191
+ "severity: a defect needs a severity",
192
+ ]);
193
+ expect(issues({ ...base, kind: "suggestion" })).toEqual([
194
+ "severity: a suggestion has no severity; send null",
195
+ ]);
196
+ expect(issues({ ...base, source: "auto", kind: "suggestion", severity: null })).toEqual([
197
+ "kind: an auto-captured report is always a defect",
198
+ ]);
199
+ expect(issues({ ...base, kind: "feature" })?.[0]).toStartWith("kind:");
200
+ });
201
+ });
package/src/bug-intake.ts CHANGED
@@ -60,7 +60,41 @@ export const bugIntakeRegisterRequest = z
60
60
  .strict();
61
61
  export type BugIntakeRegisterRequest = z.infer<typeof bugIntakeRegisterRequest>;
62
62
 
63
- /** `POST /v1/reports` — one redacted bug report (signed). */
63
+ /**
64
+ * What a report is about (#1192).
65
+ * defect — something in Caelo misbehaved: a tool contradicted its
66
+ * description, a render contradicted stored state, a call
67
+ * failed. Auto captures and provider errors are always this.
68
+ * suggestion — nothing misbehaved, but Caelo lacks something the AI needed:
69
+ * a capability, a tool, a docs page. Filed by the AI only.
70
+ * The intake keeps the two apart end to end: own title prefix and label, own
71
+ * daily issue cap, and matching never puts one kind into the other's cluster.
72
+ */
73
+ export const bugReportKind = z.enum(["defect", "suggestion"]);
74
+ export type BugReportKind = z.infer<typeof bugReportKind>;
75
+
76
+ /**
77
+ * The kind of a report that names none. Contract rule (#1192): installs
78
+ * released before `kind` existed send no kind, and everything they could
79
+ * file was a defect, so a missing kind IS `defect`. This is a protocol rule,
80
+ * not a guess: it holds for every protocol-1 report, and the intake applies
81
+ * the same rule to the records it stored before #1192.
82
+ */
83
+ export const BUG_REPORT_KIND_BEFORE_1192: BugReportKind = "defect";
84
+
85
+ /** How badly a defect hurt the task. Defects only: a suggestion has no severity. */
86
+ export const bugReportSeverity = z.enum(["blocking", "degraded", "cosmetic"]);
87
+ export type BugReportSeverity = z.infer<typeof bugReportSeverity>;
88
+
89
+ /**
90
+ * `POST /v1/reports` — one redacted bug report (signed).
91
+ *
92
+ * `kind` is optional on the wire only for the contract rule above; parsing
93
+ * always yields one. `severity` is set for a defect and `null` for a
94
+ * suggestion (nothing broke, so there is nothing to grade), and an
95
+ * auto-captured report is always a defect. A report that breaks either is
96
+ * refused, not repaired: it came from a broken client.
97
+ */
64
98
  export const bugIntakeReport = z
65
99
  .object({
66
100
  protocolVersion: z.literal(BUG_INTAKE_PROTOCOL_VERSION),
@@ -69,7 +103,8 @@ export const bugIntakeReport = z
69
103
  caeloVersion: z.string().min(1).max(64),
70
104
  fingerprint: fingerprintSchema,
71
105
  source: z.enum(["ai", "auto"]),
72
- severity: z.enum(["blocking", "degraded", "cosmetic"]),
106
+ kind: bugReportKind.default(BUG_REPORT_KIND_BEFORE_1192),
107
+ severity: bugReportSeverity.nullable(),
73
108
  blockedTask: z.boolean(),
74
109
  suspectedTool: z.string().max(200).nullable(),
75
110
  title: z.string().min(1).max(BUG_INTAKE_LIMITS.title),
@@ -77,7 +112,27 @@ export const bugIntakeReport = z
77
112
  expected: z.string().min(1).max(BUG_INTAKE_LIMITS.expected),
78
113
  evidence: z.string().max(BUG_INTAKE_LIMITS.evidence).nullable(),
79
114
  })
80
- .strict();
115
+ .strict()
116
+ .superRefine((r, ctx) => {
117
+ if (r.kind === "defect" && r.severity === null) {
118
+ ctx.addIssue({ code: "custom", path: ["severity"], message: "a defect needs a severity" });
119
+ }
120
+ if (r.kind === "suggestion" && r.severity !== null) {
121
+ ctx.addIssue({
122
+ code: "custom",
123
+ path: ["severity"],
124
+ message: "a suggestion has no severity; send null",
125
+ });
126
+ }
127
+ if (r.kind === "suggestion" && r.source === "auto") {
128
+ ctx.addIssue({
129
+ code: "custom",
130
+ path: ["kind"],
131
+ message: "an auto-captured report is always a defect",
132
+ });
133
+ }
134
+ });
135
+ /** A parsed report: `kind` is always present. */
81
136
  export type BugIntakeReport = z.infer<typeof bugIntakeReport>;
82
137
 
83
138
  /**