@caelo-cms/shared 0.10.22 → 0.10.24

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 (249) hide show
  1. package/dist/ai-tools.d.ts +289 -273
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +342 -323
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/auth-forms.d.ts.map +1 -1
  6. package/dist/auth-forms.js +4 -1
  7. package/dist/auth-forms.js.map +1 -1
  8. package/dist/base-css.d.ts +35 -0
  9. package/dist/base-css.d.ts.map +1 -0
  10. package/dist/base-css.js +40 -0
  11. package/dist/base-css.js.map +1 -0
  12. package/dist/build-page.d.ts +330 -0
  13. package/dist/build-page.d.ts.map +1 -0
  14. package/dist/build-page.js +282 -0
  15. package/dist/build-page.js.map +1 -0
  16. package/dist/content.d.ts +322 -9
  17. package/dist/content.d.ts.map +1 -1
  18. package/dist/content.js +354 -11
  19. package/dist/content.js.map +1 -1
  20. package/dist/css-gradient-scan.d.ts +14 -0
  21. package/dist/css-gradient-scan.d.ts.map +1 -0
  22. package/dist/css-gradient-scan.js +81 -0
  23. package/dist/css-gradient-scan.js.map +1 -0
  24. package/dist/css-var-scan.d.ts +56 -0
  25. package/dist/css-var-scan.d.ts.map +1 -0
  26. package/dist/css-var-scan.js +97 -0
  27. package/dist/css-var-scan.js.map +1 -0
  28. package/dist/design-draft-shell.d.ts +21 -0
  29. package/dist/design-draft-shell.d.ts.map +1 -0
  30. package/dist/design-draft-shell.js +81 -0
  31. package/dist/design-draft-shell.js.map +1 -0
  32. package/dist/design-manifest.d.ts +36 -0
  33. package/dist/design-manifest.d.ts.map +1 -0
  34. package/dist/design-manifest.js +90 -0
  35. package/dist/design-manifest.js.map +1 -0
  36. package/dist/fonts.d.ts +89 -0
  37. package/dist/fonts.d.ts.map +1 -0
  38. package/dist/fonts.js +241 -0
  39. package/dist/fonts.js.map +1 -0
  40. package/dist/genesis-inventory.d.ts +32 -0
  41. package/dist/genesis-inventory.d.ts.map +1 -0
  42. package/dist/genesis-inventory.js +186 -0
  43. package/dist/genesis-inventory.js.map +1 -0
  44. package/dist/genesis.d.ts +102 -0
  45. package/dist/genesis.d.ts.map +1 -0
  46. package/dist/genesis.js +145 -0
  47. package/dist/genesis.js.map +1 -0
  48. package/dist/i18n.d.ts +29 -36
  49. package/dist/i18n.d.ts.map +1 -1
  50. package/dist/i18n.js +53 -128
  51. package/dist/i18n.js.map +1 -1
  52. package/dist/index.d.ts +28 -1
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +28 -1
  55. package/dist/index.js.map +1 -1
  56. package/dist/interactions.d.ts +23 -0
  57. package/dist/interactions.d.ts.map +1 -0
  58. package/dist/interactions.js +44 -0
  59. package/dist/interactions.js.map +1 -0
  60. package/dist/media.d.ts +101 -16
  61. package/dist/media.d.ts.map +1 -1
  62. package/dist/media.js +126 -15
  63. package/dist/media.js.map +1 -1
  64. package/dist/page-log.d.ts +94 -0
  65. package/dist/page-log.d.ts.map +1 -0
  66. package/dist/page-log.js +111 -0
  67. package/dist/page-log.js.map +1 -0
  68. package/dist/preview-compose.d.ts +85 -12
  69. package/dist/preview-compose.d.ts.map +1 -1
  70. package/dist/preview-compose.js +157 -67
  71. package/dist/preview-compose.js.map +1 -1
  72. package/dist/proposal-status.d.ts +40 -0
  73. package/dist/proposal-status.d.ts.map +1 -0
  74. package/dist/proposal-status.js +34 -0
  75. package/dist/proposal-status.js.map +1 -0
  76. package/dist/responsive-images.d.ts +64 -0
  77. package/dist/responsive-images.d.ts.map +1 -0
  78. package/dist/responsive-images.js +98 -0
  79. package/dist/responsive-images.js.map +1 -0
  80. package/dist/safe-keys.d.ts +9 -0
  81. package/dist/safe-keys.d.ts.map +1 -0
  82. package/dist/safe-keys.js +20 -0
  83. package/dist/safe-keys.js.map +1 -0
  84. package/dist/seo.d.ts +11 -16
  85. package/dist/seo.d.ts.map +1 -1
  86. package/dist/seo.js +7 -23
  87. package/dist/seo.js.map +1 -1
  88. package/dist/skills.d.ts +14 -68
  89. package/dist/skills.d.ts.map +1 -1
  90. package/dist/skills.js +19 -113
  91. package/dist/skills.js.map +1 -1
  92. package/dist/strip-cdata.d.ts +7 -0
  93. package/dist/strip-cdata.d.ts.map +1 -0
  94. package/dist/strip-cdata.js +48 -0
  95. package/dist/strip-cdata.js.map +1 -0
  96. package/dist/structured-sets.d.ts +6 -52
  97. package/dist/structured-sets.d.ts.map +1 -1
  98. package/dist/structured-sets.js +6 -68
  99. package/dist/structured-sets.js.map +1 -1
  100. package/dist/subagents.d.ts +105 -3
  101. package/dist/subagents.d.ts.map +1 -1
  102. package/dist/subagents.js +224 -41
  103. package/dist/subagents.js.map +1 -1
  104. package/dist/template-engine.d.ts +85 -0
  105. package/dist/template-engine.d.ts.map +1 -0
  106. package/dist/template-engine.js +403 -0
  107. package/dist/template-engine.js.map +1 -0
  108. package/dist/theme-importers/auto-detect.d.ts +26 -0
  109. package/dist/theme-importers/auto-detect.d.ts.map +1 -0
  110. package/dist/theme-importers/auto-detect.js +42 -0
  111. package/dist/theme-importers/auto-detect.js.map +1 -0
  112. package/dist/theme-importers/css-comments.d.ts +12 -0
  113. package/dist/theme-importers/css-comments.d.ts.map +1 -0
  114. package/dist/theme-importers/css-comments.js +15 -0
  115. package/dist/theme-importers/css-comments.js.map +1 -0
  116. package/dist/theme-importers/dtcg.d.ts +46 -0
  117. package/dist/theme-importers/dtcg.d.ts.map +1 -0
  118. package/dist/theme-importers/dtcg.js +111 -0
  119. package/dist/theme-importers/dtcg.js.map +1 -0
  120. package/dist/theme-importers/loose.d.ts +3 -0
  121. package/dist/theme-importers/loose.d.ts.map +1 -0
  122. package/dist/theme-importers/loose.js +76 -0
  123. package/dist/theme-importers/loose.js.map +1 -0
  124. package/dist/theme-importers/shadcn.d.ts +24 -0
  125. package/dist/theme-importers/shadcn.d.ts.map +1 -0
  126. package/dist/theme-importers/shadcn.js +135 -0
  127. package/dist/theme-importers/shadcn.js.map +1 -0
  128. package/dist/theme-importers/style-dictionary.d.ts +17 -0
  129. package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
  130. package/dist/theme-importers/style-dictionary.js +125 -0
  131. package/dist/theme-importers/style-dictionary.js.map +1 -0
  132. package/dist/theme-importers/tailwind.d.ts +3 -0
  133. package/dist/theme-importers/tailwind.d.ts.map +1 -0
  134. package/dist/theme-importers/tailwind.js +218 -0
  135. package/dist/theme-importers/tailwind.js.map +1 -0
  136. package/dist/theme-literal-binding.d.ts +37 -0
  137. package/dist/theme-literal-binding.d.ts.map +1 -0
  138. package/dist/theme-literal-binding.js +138 -0
  139. package/dist/theme-literal-binding.js.map +1 -0
  140. package/dist/theme-normalize.d.ts +31 -0
  141. package/dist/theme-normalize.d.ts.map +1 -0
  142. package/dist/theme-normalize.js +587 -0
  143. package/dist/theme-normalize.js.map +1 -0
  144. package/dist/theme-ramp.d.ts +55 -0
  145. package/dist/theme-ramp.d.ts.map +1 -0
  146. package/dist/theme-ramp.js +149 -0
  147. package/dist/theme-ramp.js.map +1 -0
  148. package/dist/theme-render.d.ts +105 -0
  149. package/dist/theme-render.d.ts.map +1 -0
  150. package/dist/theme-render.js +441 -0
  151. package/dist/theme-render.js.map +1 -0
  152. package/dist/themes-errors.d.ts +109 -0
  153. package/dist/themes-errors.d.ts.map +1 -0
  154. package/dist/themes-errors.js +170 -0
  155. package/dist/themes-errors.js.map +1 -0
  156. package/dist/themes.d.ts +343 -0
  157. package/dist/themes.d.ts.map +1 -0
  158. package/dist/themes.js +697 -0
  159. package/dist/themes.js.map +1 -0
  160. package/dist/version.d.ts +7 -4
  161. package/dist/version.d.ts.map +1 -1
  162. package/dist/version.js +6 -3
  163. package/dist/version.js.map +1 -1
  164. package/package.json +10 -2
  165. package/src/__tests__/redos-hardening.test.ts +160 -0
  166. package/src/ai-tools-add-module-modes.test.ts +106 -0
  167. package/src/ai-tools-position.test.ts +134 -0
  168. package/src/ai-tools.test.ts +81 -0
  169. package/src/ai-tools.ts +1105 -0
  170. package/src/auth-forms.ts +36 -0
  171. package/src/base-css.ts +42 -0
  172. package/src/build-page.test.ts +228 -0
  173. package/src/build-page.ts +319 -0
  174. package/src/cap-failures.ts +67 -0
  175. package/src/content.test.ts +170 -0
  176. package/src/content.ts +620 -0
  177. package/src/context.ts +43 -0
  178. package/src/css-gradient-scan.ts +88 -0
  179. package/src/css-var-scan.test.ts +96 -0
  180. package/src/css-var-scan.ts +144 -0
  181. package/src/derive-module-type.test.ts +80 -0
  182. package/src/design-draft-shell.test.ts +85 -0
  183. package/src/design-draft-shell.ts +109 -0
  184. package/src/design-manifest.ts +93 -0
  185. package/src/fonts.test.ts +157 -0
  186. package/src/fonts.ts +296 -0
  187. package/src/genesis-inventory.test.ts +86 -0
  188. package/src/genesis-inventory.ts +215 -0
  189. package/src/genesis-sanitize.test.ts +35 -0
  190. package/src/genesis.ts +158 -0
  191. package/src/i18n.test.ts +58 -0
  192. package/src/i18n.ts +91 -0
  193. package/src/index.test.ts +10 -0
  194. package/src/index.ts +59 -0
  195. package/src/interactions.ts +48 -0
  196. package/src/logger.ts +147 -0
  197. package/src/media.test.ts +160 -0
  198. package/src/media.ts +355 -0
  199. package/src/page-log.test.ts +163 -0
  200. package/src/page-log.ts +124 -0
  201. package/src/preview-compose.test.ts +637 -0
  202. package/src/preview-compose.ts +656 -0
  203. package/src/preview-scanner.test.ts +96 -0
  204. package/src/preview-scanner.ts +214 -0
  205. package/src/proposal-status.test.ts +69 -0
  206. package/src/proposal-status.ts +40 -0
  207. package/src/responsive-images.test.ts +104 -0
  208. package/src/responsive-images.ts +151 -0
  209. package/src/result.ts +29 -0
  210. package/src/safe-keys.ts +21 -0
  211. package/src/seo.test.ts +194 -0
  212. package/src/seo.ts +233 -0
  213. package/src/skills.ts +48 -0
  214. package/src/snapshots.test.ts +80 -0
  215. package/src/snapshots.ts +81 -0
  216. package/src/strip-cdata.test.ts +41 -0
  217. package/src/strip-cdata.ts +50 -0
  218. package/src/structured-sets.ts +114 -0
  219. package/src/subagents.test.ts +262 -0
  220. package/src/subagents.ts +432 -0
  221. package/src/template-engine.test.ts +379 -0
  222. package/src/template-engine.ts +520 -0
  223. package/src/theme-gradient.test.ts +92 -0
  224. package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
  225. package/src/theme-importers/auto-detect.ts +84 -0
  226. package/src/theme-importers/css-comments.ts +15 -0
  227. package/src/theme-importers/dtcg.ts +106 -0
  228. package/src/theme-importers/loose.ts +76 -0
  229. package/src/theme-importers/shadcn.ts +133 -0
  230. package/src/theme-importers/style-dictionary.ts +125 -0
  231. package/src/theme-importers/tailwind.ts +217 -0
  232. package/src/theme-literal-binding.test.ts +71 -0
  233. package/src/theme-literal-binding.ts +159 -0
  234. package/src/theme-motion.test.ts +115 -0
  235. package/src/theme-normalize-envelope.test.ts +43 -0
  236. package/src/theme-normalize-gradient.test.ts +135 -0
  237. package/src/theme-normalize.ts +661 -0
  238. package/src/theme-ramp.ts +187 -0
  239. package/src/theme-render-sanitize.test.ts +45 -0
  240. package/src/theme-render.test.ts +119 -0
  241. package/src/theme-render.ts +487 -0
  242. package/src/theme-shadow.test.ts +56 -0
  243. package/src/themes-errors.ts +199 -0
  244. package/src/themes.ts +842 -0
  245. package/src/version.ts +66 -0
  246. package/dist/translation.d.ts +0 -127
  247. package/dist/translation.d.ts.map +0 -1
  248. package/dist/translation.js +0 -208
  249. package/dist/translation.js.map +0 -1
@@ -0,0 +1,163 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #264 — unit coverage for the per-page log's pure logic: the shared
5
+ * append-input schema (the same one the op + tool validate against) and the
6
+ * `formatPageLogBlock` context-block renderer (non-empty gate, entry cap,
7
+ * <2 KB budget).
8
+ */
9
+
10
+ import { describe, expect, it } from "bun:test";
11
+ import {
12
+ formatPageLogBlock,
13
+ type PageLogEntry,
14
+ pageLogAppendInputSchema,
15
+ pageLogEntrySchema,
16
+ } from "./page-log.js";
17
+
18
+ const PAGE = "11111111-1111-4111-8111-aaaaaaaaaaaa";
19
+
20
+ function entry(over: Partial<PageLogEntry>): PageLogEntry {
21
+ return {
22
+ id: "22222222-2222-4222-8222-bbbbbbbbbbbb",
23
+ pageId: PAGE,
24
+ chatSessionId: null,
25
+ actorKind: "ai",
26
+ entryKind: "decision",
27
+ summary: "Chose a two-column hero to match the source layout.",
28
+ detail: null,
29
+ createdAt: "2026-07-13T10:00:00.000Z",
30
+ ...over,
31
+ };
32
+ }
33
+
34
+ describe("pageLogAppendInputSchema", () => {
35
+ it("accepts a minimal valid payload (no detail)", () => {
36
+ const r = pageLogAppendInputSchema.safeParse({
37
+ pageId: PAGE,
38
+ entryKind: "operator_answer",
39
+ summary: "Operator said keep the original blue, not the refreshed teal.",
40
+ });
41
+ expect(r.success).toBe(true);
42
+ });
43
+
44
+ it("accepts an object detail and every entry kind", () => {
45
+ for (const entryKind of [
46
+ "edited",
47
+ "decision",
48
+ "operator_answer",
49
+ "open_question",
50
+ "rebuilt",
51
+ "note",
52
+ ] as const) {
53
+ const r = pageLogAppendInputSchema.safeParse({
54
+ pageId: PAGE,
55
+ entryKind,
56
+ summary: "x",
57
+ detail: { chosen: "blue", operatorWords: "keep the blue" },
58
+ });
59
+ expect(r.success).toBe(true);
60
+ }
61
+ });
62
+
63
+ it("rejects an unknown entry kind, an empty summary, and a bad page id", () => {
64
+ expect(
65
+ pageLogAppendInputSchema.safeParse({ pageId: PAGE, entryKind: "deleted", summary: "x" })
66
+ .success,
67
+ ).toBe(false);
68
+ expect(
69
+ pageLogAppendInputSchema.safeParse({ pageId: PAGE, entryKind: "note", summary: "" }).success,
70
+ ).toBe(false);
71
+ expect(
72
+ pageLogAppendInputSchema.safeParse({ pageId: "not-a-uuid", entryKind: "note", summary: "x" })
73
+ .success,
74
+ ).toBe(false);
75
+ });
76
+
77
+ it("rejects unknown keys (strict) and a non-object detail", () => {
78
+ expect(
79
+ pageLogAppendInputSchema.safeParse({
80
+ pageId: PAGE,
81
+ entryKind: "note",
82
+ summary: "x",
83
+ extra: 1,
84
+ }).success,
85
+ ).toBe(false);
86
+ expect(
87
+ pageLogAppendInputSchema.safeParse({
88
+ pageId: PAGE,
89
+ entryKind: "note",
90
+ summary: "x",
91
+ detail: "a bare string",
92
+ }).success,
93
+ ).toBe(false);
94
+ });
95
+ });
96
+
97
+ describe("formatPageLogBlock", () => {
98
+ it("returns null for an empty log so the header is omitted", () => {
99
+ expect(formatPageLogBlock([])).toBeNull();
100
+ });
101
+
102
+ it("renders the header, primer, and one line per entry newest-first", () => {
103
+ const block = formatPageLogBlock([
104
+ entry({ entryKind: "rebuilt", summary: "Rebuilt from the imported source." }),
105
+ entry({ entryKind: "operator_answer", summary: "Keep the blue.", actorKind: "human" }),
106
+ ]);
107
+ expect(block).not.toBeNull();
108
+ const b = block as string;
109
+ expect(b).toContain("## Page log");
110
+ expect(b).toContain("log_page_edit");
111
+ expect(b).toContain("- [rebuilt] Rebuilt from the imported source. (AI, 2026-07-13)");
112
+ expect(b).toContain("- [operator_answer] Keep the blue. (operator, 2026-07-13)");
113
+ });
114
+
115
+ it("caps the entry list and stays under 2 KB even with long summaries", () => {
116
+ const many: PageLogEntry[] = Array.from({ length: 40 }, (_, i) =>
117
+ entry({ id: `id-${i}`, summary: "Q".repeat(500) }),
118
+ );
119
+ const block = formatPageLogBlock(many) as string;
120
+ // 8 shown + a truncation notice line.
121
+ expect(block).toContain("32 older entries omitted.");
122
+ expect((block.match(/^- \[/gm) ?? []).length).toBe(8);
123
+ expect(Buffer.byteLength(block, "utf8")).toBeLessThan(2048);
124
+ });
125
+
126
+ it("uses the singular form when exactly one older entry is omitted", () => {
127
+ const nine: PageLogEntry[] = Array.from({ length: 9 }, (_, i) => entry({ id: `id-${i}` }));
128
+ const block = formatPageLogBlock(nine) as string;
129
+ expect(block).toContain("1 older entry omitted.");
130
+ expect(block).not.toContain("entries omitted");
131
+ });
132
+ });
133
+
134
+ describe("pageLogEntrySchema", () => {
135
+ // This is the contract `page_log.list` re-validates every DB row against,
136
+ // so schema-drift shapes (non-ISO timestamp, unknown enum value) must fail.
137
+ const valid = entry({});
138
+
139
+ it("accepts a well-formed row", () => {
140
+ expect(pageLogEntrySchema.safeParse(valid).success).toBe(true);
141
+ });
142
+
143
+ it("rejects a createdAt that is not an ISO datetime", () => {
144
+ for (const createdAt of ["2026-07-13", "yesterday", "1720900000000"]) {
145
+ expect(pageLogEntrySchema.safeParse({ ...valid, createdAt }).success).toBe(false);
146
+ }
147
+ });
148
+
149
+ it("rejects an unknown actorKind or entryKind (enum drift)", () => {
150
+ expect(pageLogEntrySchema.safeParse({ ...valid, actorKind: "robot" }).success).toBe(false);
151
+ expect(pageLogEntrySchema.safeParse({ ...valid, entryKind: "deleted" }).success).toBe(false);
152
+ });
153
+
154
+ it("rejects a non-uuid id and unknown keys (strict)", () => {
155
+ expect(pageLogEntrySchema.safeParse({ ...valid, id: "row-1" }).success).toBe(false);
156
+ expect(pageLogEntrySchema.safeParse({ ...valid, extra: 1 }).success).toBe(false);
157
+ });
158
+
159
+ it("rejects a non-object detail but accepts null", () => {
160
+ expect(pageLogEntrySchema.safeParse({ ...valid, detail: "corrupt" }).success).toBe(false);
161
+ expect(pageLogEntrySchema.safeParse({ ...valid, detail: null }).success).toBe(true);
162
+ });
163
+ });
@@ -0,0 +1,124 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #264 — the per-page edit LOG: durable, append-only work history so a
5
+ * later chat or a fresh subagent that touches a page knows WHY it was edited,
6
+ * what decisions were taken, and which operator answers shaped it — without
7
+ * dragging the originating chat's full transcript through its context.
8
+ *
9
+ * This is FACT (what happened), not learned BEHAVIOUR — so, unlike
10
+ * `site_ai_memory`, it is ungated: any actor appends directly. The shared
11
+ * Zod schemas here are the single source of truth for the `page_log.append`
12
+ * op input, the `log_page_edit` tool schema, and the `page_log.list` output.
13
+ * `formatPageLogBlock` renders the `## Page log` context block the AI reads
14
+ * before touching a page.
15
+ */
16
+
17
+ import { z } from "zod";
18
+ import type { ActorKind } from "./context.js";
19
+
20
+ /**
21
+ * The kinds of log entry, in the order the AI should reach for them:
22
+ * - `edited` — a substantive content/module change was made.
23
+ * - `decision` — a design/structure call the AI made and its rationale.
24
+ * - `operator_answer` — an answer the operator gave that shaped the page.
25
+ * - `open_question` — something still unresolved a future turn must settle.
26
+ * - `rebuilt` — the page was rebuilt from scratch (e.g. migration rebuild).
27
+ * - `note` — anything else worth preserving for a future editor.
28
+ */
29
+ export const pageLogEntryKinds = [
30
+ "edited",
31
+ "decision",
32
+ "operator_answer",
33
+ "open_question",
34
+ "rebuilt",
35
+ "note",
36
+ ] as const;
37
+
38
+ export const pageLogEntryKindSchema = z.enum(pageLogEntryKinds);
39
+ export type PageLogEntryKind = (typeof pageLogEntryKinds)[number];
40
+
41
+ /**
42
+ * Optional structured context beyond the one-line summary — an OBJECT, never
43
+ * a bare scalar or array, so the jsonb column stores queryable keys (chosen
44
+ * option, operator's exact words, affected module ids). Writes go through
45
+ * `jsonbParam()` in the op handler to avoid the double-encode trap (issue
46
+ * #68).
47
+ */
48
+ export const pageLogDetailSchema = z.record(z.string(), z.unknown());
49
+ export type PageLogDetail = z.infer<typeof pageLogDetailSchema>;
50
+
51
+ /** Input shape shared by `page_log.append` and the `log_page_edit` tool. */
52
+ export const pageLogAppendInputSchema = z
53
+ .object({
54
+ pageId: z.string().uuid(),
55
+ entryKind: pageLogEntryKindSchema,
56
+ summary: z.string().min(1).max(2000),
57
+ detail: pageLogDetailSchema.optional(),
58
+ })
59
+ .strict();
60
+ export type PageLogAppendInput = z.infer<typeof pageLogAppendInputSchema>;
61
+
62
+ /** One row as returned by `page_log.list`. */
63
+ export const pageLogEntrySchema = z
64
+ .object({
65
+ id: z.string().uuid(),
66
+ pageId: z.string().uuid(),
67
+ chatSessionId: z.string().uuid().nullable(),
68
+ actorKind: z.enum(["human", "ai", "plugin", "system"]),
69
+ entryKind: pageLogEntryKindSchema,
70
+ summary: z.string(),
71
+ detail: pageLogDetailSchema.nullable(),
72
+ // ISO datetime, not just any string: the value is `created_at` sliced to a
73
+ // date and rendered into the AI context block — a real contract catches
74
+ // drift (a raw pg timestamp, a number) at the op boundary instead of
75
+ // producing a garbled `## Page log` line.
76
+ createdAt: z.string().datetime(),
77
+ })
78
+ .strict();
79
+ export type PageLogEntry = z.infer<typeof pageLogEntrySchema>;
80
+
81
+ const ACTOR_LABEL: Record<ActorKind, string> = {
82
+ human: "operator",
83
+ ai: "AI",
84
+ plugin: "plugin",
85
+ system: "system",
86
+ };
87
+
88
+ /**
89
+ * Render the `## Page log` context block for a single page's recent entries
90
+ * (newest first). Returns `null` when there is nothing to show, so the
91
+ * caller omits the header entirely (CLAUDE.md §11 — render only when
92
+ * non-empty). Kept well under 2 KB: entries are capped and each summary is
93
+ * clamped, because the block rides in the system prompt on every turn that
94
+ * touches the page.
95
+ *
96
+ * @param entries page log rows, expected newest-first (as `page_log.list`
97
+ * returns them). The renderer does not re-sort.
98
+ */
99
+ export function formatPageLogBlock(entries: readonly PageLogEntry[]): string | null {
100
+ if (entries.length === 0) return null;
101
+ // Capped + clamped to stay comfortably under 2 KB: the block rides in the
102
+ // system prompt on every turn that touches the page.
103
+ const MAX_ENTRIES = 8;
104
+ const MAX_SUMMARY = 160;
105
+ const shown = entries.slice(0, MAX_ENTRIES);
106
+ const lines: string[] = [
107
+ "## Page log",
108
+ "",
109
+ "Prior work on THIS page — read it before you change the page so you build on settled decisions instead of re-litigating them. After a meaningful change, append your own entry with `log_page_edit`.",
110
+ "",
111
+ ];
112
+ for (const e of shown) {
113
+ const who = ACTOR_LABEL[e.actorKind] ?? e.actorKind;
114
+ const when = e.createdAt.slice(0, 10);
115
+ const summary =
116
+ e.summary.length > MAX_SUMMARY ? `${e.summary.slice(0, MAX_SUMMARY - 3)}...` : e.summary;
117
+ lines.push(`- [${e.entryKind}] ${summary} (${who}, ${when})`);
118
+ }
119
+ if (entries.length > MAX_ENTRIES) {
120
+ const omitted = entries.length - MAX_ENTRIES;
121
+ lines.push(`- ${omitted} older ${omitted === 1 ? "entry" : "entries"} omitted.`);
122
+ }
123
+ return lines.join("\n");
124
+ }