@coywolf/emdash 0.0.0-stage → 0.35.13

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 (261) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -2
  3. package/package.json +70 -5
  4. package/src/admin/admin-css.ts +57 -0
  5. package/src/admin/ai.tsx +915 -0
  6. package/src/admin/backups.tsx +613 -0
  7. package/src/admin/breadcrumbs.tsx +542 -0
  8. package/src/admin/codeBlocks.tsx +204 -0
  9. package/src/admin/customBlocks.tsx +321 -0
  10. package/src/admin/dirty.ts +20 -0
  11. package/src/admin/discovery.tsx +463 -0
  12. package/src/admin/download.ts +28 -0
  13. package/src/admin/feature-switches.tsx +124 -0
  14. package/src/admin/features.tsx +142 -0
  15. package/src/admin/files.tsx +791 -0
  16. package/src/admin/formUploads.tsx +445 -0
  17. package/src/admin/guides.tsx +18 -0
  18. package/src/admin/headings.tsx +262 -0
  19. package/src/admin/images.tsx +825 -0
  20. package/src/admin/index.tsx +62 -0
  21. package/src/admin/links.tsx +980 -0
  22. package/src/admin/performance.tsx +330 -0
  23. package/src/admin/preview.tsx +63 -0
  24. package/src/admin/redirects-removed.tsx +266 -0
  25. package/src/admin/redirects.tsx +470 -0
  26. package/src/admin/reviews.tsx +232 -0
  27. package/src/admin/robots-bots.tsx +647 -0
  28. package/src/admin/robots-shared.tsx +109 -0
  29. package/src/admin/robots-wizard.tsx +748 -0
  30. package/src/admin/robots.tsx +911 -0
  31. package/src/admin/save-bar.tsx +134 -0
  32. package/src/admin/schema.tsx +1187 -0
  33. package/src/admin/search.tsx +355 -0
  34. package/src/admin/settings-ui.tsx +87 -0
  35. package/src/admin/trash-prompt-core.ts +135 -0
  36. package/src/admin/trash-prompt.tsx +156 -0
  37. package/src/admin/usage.tsx +301 -0
  38. package/src/admin/videos-display.tsx +248 -0
  39. package/src/admin/videos.tsx +1202 -0
  40. package/src/admin/wpImport.tsx +1516 -0
  41. package/src/ai/entities.ts +59 -0
  42. package/src/ai/export.ts +61 -0
  43. package/src/ai/logic.test.ts +187 -0
  44. package/src/ai/logic.ts +606 -0
  45. package/src/ai/pack.ts +121 -0
  46. package/src/ai/prompts.ts +87 -0
  47. package/src/ai/providers.ts +276 -0
  48. package/src/ai/queue.test.ts +97 -0
  49. package/src/ai/routes.ts +359 -0
  50. package/src/ai/store.ts +301 -0
  51. package/src/ai/wikidata.test.ts +184 -0
  52. package/src/ai/wikidata.ts +87 -0
  53. package/src/ai/worker.ts +530 -0
  54. package/src/astro/astro-modules.d.ts +6 -0
  55. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  56. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  57. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  58. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  59. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  60. package/src/astro/customBlocks/Details.astro +8 -0
  61. package/src/astro/customBlocks/Disclosure.astro +8 -0
  62. package/src/astro/customBlocks/Note.astro +8 -0
  63. package/src/astro/customBlocks/Podcast.astro +8 -0
  64. package/src/astro/customBlocks/Quote.astro +8 -0
  65. package/src/astro/customBlocks/Testimonial.astro +8 -0
  66. package/src/astro/files/FileCard.astro +103 -0
  67. package/src/astro/headings/HeadingBlock.astro +129 -0
  68. package/src/astro/headings/TableOfContents.astro +45 -0
  69. package/src/astro/headings/request.ts +19 -0
  70. package/src/astro/images/ResponsiveImage.astro +60 -0
  71. package/src/astro/index.ts +84 -0
  72. package/src/astro/reviews/CoywolfReview.astro +27 -0
  73. package/src/astro/reviews/Review.astro +49 -0
  74. package/src/astro/search/SearchBox.astro +61 -0
  75. package/src/astro/search/SearchBoxForm.astro +509 -0
  76. package/src/astro/tables/CaptionedTable.astro +72 -0
  77. package/src/astro/videos/CoywolfVideo.astro +448 -0
  78. package/src/backups/download.ts +112 -0
  79. package/src/backups/dump.test.mjs +287 -0
  80. package/src/backups/dump.ts +288 -0
  81. package/src/backups/module.ts +276 -0
  82. package/src/backups/pack.ts +33 -0
  83. package/src/backups/restore.test.mjs +103 -0
  84. package/src/backups/restore.ts +209 -0
  85. package/src/backups/store.ts +409 -0
  86. package/src/breadcrumbs/pack.ts +113 -0
  87. package/src/breadcrumbs/settings.ts +102 -0
  88. package/src/breadcrumbs/stamp.ts +40 -0
  89. package/src/breadcrumbs/trail.ts +123 -0
  90. package/src/codeBlocks/contrast.ts +179 -0
  91. package/src/codeBlocks/hljs.d.ts +5 -0
  92. package/src/codeBlocks/pack.ts +110 -0
  93. package/src/codeBlocks/render.test.ts +152 -0
  94. package/src/codeBlocks/render.ts +304 -0
  95. package/src/codeBlocks/settings.ts +18 -0
  96. package/src/codeBlocks/themes.generated.ts +304 -0
  97. package/src/codeBlocks/themes.ts +114 -0
  98. package/src/core/compose.ts +244 -0
  99. package/src/core/content-url.ts +888 -0
  100. package/src/core/csv.ts +119 -0
  101. package/src/core/d1-batch.ts +65 -0
  102. package/src/core/entry-docs.ts +175 -0
  103. package/src/core/features-module.ts +48 -0
  104. package/src/core/features.ts +392 -0
  105. package/src/core/generation.ts +88 -0
  106. package/src/core/guide-content.ts +166 -0
  107. package/src/core/module.ts +104 -0
  108. package/src/core/read-failures.ts +57 -0
  109. package/src/core/request-memo.ts +71 -0
  110. package/src/core/secrets.ts +83 -0
  111. package/src/core/settings-enhance.ts +74 -0
  112. package/src/core/site.ts +36 -0
  113. package/src/core/storage.ts +27 -0
  114. package/src/customBlocks/pack.ts +260 -0
  115. package/src/customBlocks/render.ts +424 -0
  116. package/src/customBlocks/rich.ts +313 -0
  117. package/src/customBlocks/settings.ts +46 -0
  118. package/src/discovery/build.ts +252 -0
  119. package/src/discovery/feed-build.ts +217 -0
  120. package/src/discovery/feed.ts +172 -0
  121. package/src/discovery/indexnow.ts +114 -0
  122. package/src/discovery/llms.ts +79 -0
  123. package/src/discovery/markdown.ts +294 -0
  124. package/src/discovery/memory-cache.ts +97 -0
  125. package/src/discovery/middleware.ts +186 -0
  126. package/src/discovery/module.ts +353 -0
  127. package/src/discovery/news.ts +99 -0
  128. package/src/discovery/pack.ts +58 -0
  129. package/src/discovery/settings.ts +91 -0
  130. package/src/discovery/urls.ts +35 -0
  131. package/src/env.d.ts +10 -0
  132. package/src/files/card.ts +231 -0
  133. package/src/files/counts.test.ts +114 -0
  134. package/src/files/counts.ts +60 -0
  135. package/src/files/export.ts +44 -0
  136. package/src/files/format.test.ts +93 -0
  137. package/src/files/format.ts +163 -0
  138. package/src/files/module.ts +596 -0
  139. package/src/files/pack.ts +92 -0
  140. package/src/files/r2-rules.test.ts +30 -0
  141. package/src/files/r2-rules.ts +41 -0
  142. package/src/files/range.ts +50 -0
  143. package/src/files/s3.ts +114 -0
  144. package/src/files/serve.ts +141 -0
  145. package/src/files/sigv4.test.ts +64 -0
  146. package/src/files/sigv4.ts +160 -0
  147. package/src/files/site.ts +137 -0
  148. package/src/files/walker.ts +58 -0
  149. package/src/formUploads/lib.ts +266 -0
  150. package/src/formUploads/pack.ts +55 -0
  151. package/src/formUploads/wrap.ts +592 -0
  152. package/src/forms.ts +16 -0
  153. package/src/headings/headings.test.ts +159 -0
  154. package/src/headings/pack.ts +154 -0
  155. package/src/headings/render.ts +139 -0
  156. package/src/headings/settings.ts +117 -0
  157. package/src/headings/slug.ts +68 -0
  158. package/src/headings/stamp.ts +142 -0
  159. package/src/headings/test-hooks.mjs +14 -0
  160. package/src/headings/toc.ts +48 -0
  161. package/src/images/cloudflare.ts +329 -0
  162. package/src/images/lib.ts +221 -0
  163. package/src/images/module.ts +372 -0
  164. package/src/images/pack.ts +804 -0
  165. package/src/images/pending.ts +37 -0
  166. package/src/images/settings.ts +48 -0
  167. package/src/images/setup.ts +192 -0
  168. package/src/images/variants-estimate.ts +162 -0
  169. package/src/images/variants-store.ts +475 -0
  170. package/src/images/variants.ts +311 -0
  171. package/src/index.ts +143 -0
  172. package/src/links/check.ts +101 -0
  173. package/src/links/classify.ts +270 -0
  174. package/src/links/export.ts +59 -0
  175. package/src/links/module.ts +652 -0
  176. package/src/links/pack.ts +43 -0
  177. package/src/links/pt.ts +325 -0
  178. package/src/links/store.ts +704 -0
  179. package/src/middleware.ts +287 -0
  180. package/src/modules.ts +53 -0
  181. package/src/options.ts +66 -0
  182. package/src/pageCache/lib.test.mjs +224 -0
  183. package/src/pageCache/lib.ts +274 -0
  184. package/src/pageCache/pack.ts +240 -0
  185. package/src/pageCache/usage-core.ts +152 -0
  186. package/src/pageCache/usage.ts +262 -0
  187. package/src/pageCache/warm.test.mjs +670 -0
  188. package/src/pageCache/warm.ts +722 -0
  189. package/src/redirects/middleware.ts +169 -0
  190. package/src/redirects/module.ts +124 -0
  191. package/src/redirects/pack.ts +89 -0
  192. package/src/redirects/removed-core.ts +151 -0
  193. package/src/redirects/removed.ts +293 -0
  194. package/src/redirects/rules.ts +355 -0
  195. package/src/redirects/slug-changes.ts +207 -0
  196. package/src/redirects/slug-core.test.ts +50 -0
  197. package/src/redirects/slug-core.ts +49 -0
  198. package/src/redirects/transfer.ts +92 -0
  199. package/src/reviews/lib.ts +624 -0
  200. package/src/reviews/pack.ts +135 -0
  201. package/src/reviews/schema.ts +128 -0
  202. package/src/reviews/settings.ts +26 -0
  203. package/src/robots/bots.ts +321 -0
  204. package/src/robots/data/bots.json +696 -0
  205. package/src/robots/data/verified.json +582 -0
  206. package/src/robots/directory.ts +26 -0
  207. package/src/robots/explain.ts +191 -0
  208. package/src/robots/guided.ts +300 -0
  209. package/src/robots/importer.ts +263 -0
  210. package/src/robots/middleware.ts +151 -0
  211. package/src/robots/module.ts +446 -0
  212. package/src/robots/pack.ts +69 -0
  213. package/src/robots/radar.ts +129 -0
  214. package/src/robots/rep.test.ts +335 -0
  215. package/src/robots/rep.ts +362 -0
  216. package/src/robots/rules.test.ts +251 -0
  217. package/src/robots/rules.ts +883 -0
  218. package/src/robots/transfer.ts +45 -0
  219. package/src/robots/validate.ts +524 -0
  220. package/src/schema/catalog.ts +146 -0
  221. package/src/schema/graph.test.mjs +522 -0
  222. package/src/schema/graph.ts +791 -0
  223. package/src/schema/module.ts +1138 -0
  224. package/src/schema/og-image.ts +100 -0
  225. package/src/schema/pack.ts +58 -0
  226. package/src/search/engine.ts +578 -0
  227. package/src/search/fallback.ts +80 -0
  228. package/src/search/live-cache.ts +116 -0
  229. package/src/search/live-client.ts +770 -0
  230. package/src/search/live-serve.ts +282 -0
  231. package/src/search/module.ts +261 -0
  232. package/src/search/pack.ts +57 -0
  233. package/src/search/query.ts +132 -0
  234. package/src/search/ratelimit-core.ts +118 -0
  235. package/src/search/ratelimit.ts +79 -0
  236. package/src/search/snippet.ts +135 -0
  237. package/src/search/title-index.ts +139 -0
  238. package/src/search/title-match.ts +192 -0
  239. package/src/shared.ts +71 -0
  240. package/src/version.ts +2 -0
  241. package/src/videos/date.ts +40 -0
  242. package/src/videos/engagement.ts +75 -0
  243. package/src/videos/lib.ts +679 -0
  244. package/src/videos/module.ts +1160 -0
  245. package/src/videos/pack.ts +187 -0
  246. package/src/videos/pause.ts +42 -0
  247. package/src/videos/poster.ts +324 -0
  248. package/src/videos/render.ts +336 -0
  249. package/src/videos/store.ts +551 -0
  250. package/src/videos/stream.ts +134 -0
  251. package/src/wpImport/convert.ts +702 -0
  252. package/src/wpImport/guests.ts +329 -0
  253. package/src/wpImport/gutenberg.ts +157 -0
  254. package/src/wpImport/markers.ts +66 -0
  255. package/src/wpImport/pack.ts +451 -0
  256. package/src/wpImport/parents.ts +202 -0
  257. package/src/wpImport/prepare.ts +592 -0
  258. package/src/wpImport/redirects.ts +358 -0
  259. package/src/wpImport/sources.ts +67 -0
  260. package/src/wpImport/stream.ts +160 -0
  261. package/src/wpImport/urls.ts +354 -0
package/src/ai/pack.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * AI Enrichment module: Wikidata-grounded entities for Schema.org
3
+ * (about/mentions/sameAs), AI meta descriptions, and AI image text (alt text
4
+ * and captions). Saves and uploads only enqueue work; the cron tick does the
5
+ * model calls, within a daily call limit.
6
+ */
7
+ import type { PluginContext } from "emdash";
8
+
9
+ import { cachedCtxFeatures, ctxFeatures, isOn, registerFeatures } from "../core/features.js";
10
+ import type { PackModule } from "../core/module.js";
11
+ import { getEntryEntities } from "./entities.js";
12
+ import { PROVIDER_HOSTS } from "./providers.js";
13
+ import { aiRoutes } from "./routes.js";
14
+ import { type AiOptions, STORAGE, col, enqueue, entryKey, readSettings, type EntryRecord } from "./store.js";
15
+ import { WIKIDATA_HOST, WIKIDATA_QUERY_HOST } from "./wikidata.js";
16
+ import { queueId } from "./logic.js";
17
+ import { entryText, targetCollections, tick } from "./worker.js";
18
+
19
+ export type { AiOptions } from "./store.js";
20
+ export { getEntryEntities, type EntryEntities } from "./entities.js";
21
+
22
+ const FEATURES = [
23
+ {
24
+ id: "ai",
25
+ label: "AI Enrichment",
26
+ description: "AI features powered by Workers AI or your own Anthropic, OpenAI, or Gemini key. Work runs in the background with a daily call limit.",
27
+ default: false,
28
+ },
29
+ {
30
+ id: "ai.entities",
31
+ label: "Entities for schema",
32
+ description: "Find the people, organizations, places, and things each page is about, verified against Wikidata, for Schema.org about/mentions.",
33
+ default: false,
34
+ },
35
+ {
36
+ id: "ai.entitiesStandalone",
37
+ label: "Output entities on their own",
38
+ description: "Add the entities to each page's JSON-LD by themselves. Only needed when Coywolf Schema isn't on.",
39
+ default: false,
40
+ },
41
+ {
42
+ id: "ai.descriptions",
43
+ label: "Meta descriptions",
44
+ description: "Write meta descriptions for published entries that don't have one, as suggestions or filled in automatically.",
45
+ default: false,
46
+ },
47
+ {
48
+ id: "ai.imageText",
49
+ label: "Image text",
50
+ description: "Write alt text (and optionally captions) for images on upload and in bulk. Text people wrote is kept.",
51
+ default: false,
52
+ },
53
+ ];
54
+ registerFeatures(FEATURES);
55
+
56
+ export const AI_TASK = "ai-queue";
57
+
58
+ export function aiPack(options: AiOptions = {}): PackModule {
59
+ /** Queue an entry when its text changed since the last analysis (debounced). Never makes a model call. */
60
+ async function onContent(event: { content: Record<string, unknown>; collection: string }, ctx: PluginContext) {
61
+ const content = event.content;
62
+ const id = typeof content.id === "string" ? content.id : null;
63
+ // Only live content: skip unpublished entries and draft saves (autosaves) of published ones,
64
+ // whose pending draft differs from the live revision. Publishing the draft fires afterPublish.
65
+ if (!id || content.status !== "published") return;
66
+ if (typeof content.draftRevisionId === "string" && content.draftRevisionId && content.draftRevisionId !== content.liveRevisionId) return;
67
+ const features = await ctxFeatures(ctx);
68
+ if (!isOn(features, "ai.entities") && !isOn(features, "ai.descriptions")) return;
69
+ const s = await readSettings(ctx, options);
70
+ if (!(await targetCollections(ctx, s)).includes(event.collection)) return;
71
+ const data = (content.data && typeof content.data === "object" ? content.data : content) as Record<string, unknown>;
72
+ const t = await entryText(ctx, event.collection, data, s, features);
73
+ if (!t) return;
74
+ const prev = await col<EntryRecord>(ctx, "aiEntries").get(entryKey(event.collection, id));
75
+ if (prev?.status === "ok" && prev.hash === t.hash) return;
76
+ await enqueue(ctx, { kind: "entry", collection: event.collection, entryId: id, due: Date.now() + s.debounceMinutes * 60_000 });
77
+ }
78
+
79
+ return {
80
+ id: "ai",
81
+ label: "AI Enrichment",
82
+ features: FEATURES,
83
+ routes: aiRoutes(options),
84
+ hooks: {
85
+ "content:afterSave": onContent,
86
+ "content:afterPublish": onContent,
87
+ "content:afterRestore": onContent,
88
+ /** Forget a deleted (or trashed) entry: its analysis and any queued job. A restore queues it again. */
89
+ "content:afterDelete": async (event: { id: string; collection: string }, ctx: PluginContext) => {
90
+ await col<EntryRecord>(ctx, "aiEntries").delete(entryKey(event.collection, event.id));
91
+ await col(ctx, "aiQueue").delete(queueId({ kind: "entry", collection: event.collection, entryId: event.id }));
92
+ },
93
+ "media:afterUpload": async (event: { media: { id: string; mimeType: string } }, ctx: PluginContext) => {
94
+ const { id, mimeType } = event.media;
95
+ if (!mimeType?.startsWith("image/") || mimeType === "image/svg+xml") return;
96
+ await enqueue(ctx, { kind: "media", mediaId: id, due: Date.now() });
97
+ },
98
+ "page:metadata": async (event: { page: { content?: { collection: string; id: string }; canonical: string | null; url: string } }, ctx: PluginContext) => {
99
+ const ref = event.page.content;
100
+ if (!ref) return null;
101
+ // The Schema & Social graph already carries these on its Article/WebPage node.
102
+ if (isOn(await cachedCtxFeatures(ctx), "schema.graph")) return null;
103
+ const { about, mentions } = await getEntryEntities(ctx, ref.collection, ref.id);
104
+ if (!about.length && !mentions.length) return null;
105
+ const graph: Record<string, unknown> = { "@context": "https://schema.org", "@type": "WebPage", "@id": event.page.canonical ?? event.page.url };
106
+ if (about.length) graph.about = about;
107
+ if (mentions.length) graph.mentions = mentions;
108
+ return { kind: "jsonld", id: "coywolf-ai-entities", graph };
109
+ },
110
+ },
111
+ hookFeature: {
112
+ "media:afterUpload": "ai.imageText",
113
+ "page:metadata": "ai.entitiesStandalone",
114
+ },
115
+ tasks: [{ name: AI_TASK, schedule: "*/2 * * * *", handler: (ctx: PluginContext) => tick(ctx, options, { budgetMs: 8 * 60_000 }).then(() => undefined) }],
116
+ adminPages: [{ path: "/ai", label: "AI Enrichment", icon: "sparkle" }],
117
+ storage: STORAGE,
118
+ capabilities: ["network:request", "content:read", "content:write", "schema:read", "media:read", "media:bytes:read", "media:metadata:write"],
119
+ allowedHosts: [...PROVIDER_HOSTS, WIKIDATA_HOST, WIKIDATA_QUERY_HOST],
120
+ };
121
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * AI Enrichment prompts, ported from Coywolf SEO for WordPress
3
+ * (class-coywolf-seo-ai.php and class-coywolf-seo-image-ai.php).
4
+ */
5
+ import type { Candidate, GroundedMention } from "./logic.js";
6
+ import { DESCRIPTION_MAX } from "./logic.js";
7
+
8
+ export const EXTRACT_SYSTEM =
9
+ "You extract named entities from an article for SEO schema markup. " +
10
+ "Respond with ONLY a JSON array — no prose, no code fences. Each element is an object with exactly these keys: " +
11
+ '"surface" (the entity exactly as written in the article), ' +
12
+ '"name" (the canonical, normalized name), ' +
13
+ '"type" (one of "Person", "Organization", "Place", "Thing"), ' +
14
+ '"description" (one short line stating what the entity is), ' +
15
+ '"primary" (true when the entity is a main subject of the article, false when it is mentioned in passing). ' +
16
+ "Rules: never output identifiers, QIDs, database IDs, or URLs of any kind. " +
17
+ "Only include real-world entities a reader could look up — people, organizations, places, products, published works, well-defined concepts. " +
18
+ "Skip the article author and the publishing website itself. At most 12 entities. If there are none, return [].";
19
+
20
+ export const DISAMBIGUATE_SYSTEM =
21
+ "You match entities from an article to Wikidata items. " +
22
+ "Respond with ONLY a JSON object — no prose, no code fences — mapping each entity name to the QID string of the best-matching candidate, or null when no candidate clearly matches. " +
23
+ "Choose strictly among the provided candidates. Never produce a QID that is not listed. " +
24
+ "Use the article context, the entity type, and each candidate's Wikidata description to decide. If the choice is unclear, use null.";
25
+
26
+ export const DESCRIBE_SYSTEM =
27
+ "You write the meta description for a web article. " +
28
+ "Respond with ONLY the meta description itself — plain text, a single line, no quotation marks around it, no markdown, no preamble, no explanation. " +
29
+ "It must be a faithful summary of the article content in one or two sentences, written for a search result snippet. " +
30
+ "No clickbait: no questions to the reader, no hype, no exclamation marks, no \"you won't believe\" teasers, and no promises the article doesn't keep. " +
31
+ `It must be STRICTLY UNDER ${DESCRIPTION_MAX} characters.`;
32
+
33
+ export const IMAGE_SYSTEM =
34
+ "You write accessibility-first metadata for images in a website's media library, " +
35
+ "following WCAG-aligned guidance for alternative text, titles, and captions. " +
36
+ "You write for the people who depend on this text — screen reader and braille users, and anyone on a " +
37
+ "text-only or broken-image fallback — and never for search engines. " +
38
+ "You respond with raw JSON only: a single JSON object, no markdown fences, no commentary.";
39
+
40
+ export function articlePrompt(title: string, content: string): string {
41
+ return `Title: ${title}\n\n${content}`;
42
+ }
43
+
44
+ export function disambiguatePrompt(ambiguous: GroundedMention[], title: string, content: string): string {
45
+ const lines = [`Article: ${title} — ${[...content].slice(0, 600).join("")}`, "", "Entities and their candidates:"];
46
+ for (const m of ambiguous) {
47
+ lines.push(`${m.name} (${m.type}${m.description ? ` — ${m.description}` : ""}):`);
48
+ for (const c of m.candidates as Candidate[]) lines.push(` ${c.id}: ${c.label}${c.description ? ` — ${c.description}` : ""}`);
49
+ }
50
+ return lines.join("\n");
51
+ }
52
+
53
+ export function imagePrompt(opts: { filename: string; site: string; locale: string; extra?: string }): string {
54
+ let prompt =
55
+ "Analyze the image and write accessibility-first media library metadata for it, following the rules below. " +
56
+ "This metadata becomes the default for every future use of the file, and you are given no specific post context, so it must stand on its own.\n\n" +
57
+ "Respond with a JSON object:\n" +
58
+ '{"alt_text": string, "title": string, "caption": string}\n\n' +
59
+ "Write alt_text only when the image warrants it (an empty string for a purely decorative image); always provide a title and a caption.\n\n" +
60
+ "First, silently decide what kind of image this is, then write accordingly:\n" +
61
+ '- Decorative (a divider, texture, background flourish, or an icon that only repeats nearby text): return an empty alt_text. Do not invent text and do not write "decorative image". Still give it a useful title.\n' +
62
+ '- Functional (the image acts as a button or link): alt_text states the action or destination, not the artwork — a magnifying-glass search icon is "Search", not "Magnifying glass".\n' +
63
+ "- Text-bearing (a sign, quote card, screenshot, or graphic whose text is the point): transcribe the text verbatim in alt_text. If the visual design of the text is the point, describe that design as well.\n" +
64
+ "- Complex (a chart, infographic, diagram, or data-dense screenshot): alt_text is one sentence giving the takeaway.\n" +
65
+ "- Informative (any other image that carries meaning): alt_text describes what matters.\n\n" +
66
+ "alt_text — becomes the alt attribute:\n" +
67
+ "- Front-load the core subject and action, then add secondary details in descending importance, so a reader who stops after the first clause still has the essential picture.\n" +
68
+ "- Write full sentences in sentence case, ending with a period.\n" +
69
+ "- Default to one concise sentence (around 125 characters).\n" +
70
+ '- Never begin with "Image of", "Photo of", or "Graphic of". Name the medium only when it matters, e.g. "Watercolor painting of…" or "Screenshot of…".\n' +
71
+ '- Expand anything normally abbreviated: spell out units, measurements, and addresses ("5GB" becomes "5 gigabytes"; "123 Main St." becomes "123 Main Street"). Spell out acronyms unless this audience knows the acronym better.\n' +
72
+ "- Use proper typographic curly quotes and apostrophes (not straight ones) when quoting text that appears in the image.\n" +
73
+ "- Do not repeat what the caption says; alt_text and caption are read together, so write them as complements.\n\n" +
74
+ "title — an internal media library label:\n" +
75
+ '- A short, human-readable identifier of three to eight words in title case, e.g. "Barista Pouring Latte Art". Never a filename, never keywords, and never a copy of alt_text.\n\n' +
76
+ "caption — visible to everyone, shown as the figure caption:\n" +
77
+ "- One concise, informative sentence suitable to display beneath the image, phrased differently from alt_text.\n\n" +
78
+ `- Write every field in the language of locale "${opts.locale}".\n` +
79
+ "- Plain text only: no HTML and no markdown. Write for human readers; never write for SEO.\n";
80
+ const context: string[] = [];
81
+ if (opts.filename) context.push(`filename "${opts.filename}"`);
82
+ if (opts.site) context.push(`site "${opts.site}"`);
83
+ if (context.length) prompt += `\nContext that may help (ignore it if misleading): ${context.join(", ")}.`;
84
+ const extra = opts.extra?.trim();
85
+ if (extra) prompt += `\n\nAdditional site-specific instructions:\n${extra}`;
86
+ return prompt;
87
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Model providers for AI Enrichment: Workers AI (through the AI binding) or a
3
+ * bring-your-own key for Anthropic, OpenAI, or Google Gemini. One call shape
4
+ * for text and vision; usage is reported where the provider gives it.
5
+ * API keys are never logged or included in errors.
6
+ */
7
+
8
+ export type ProviderId = "workers-ai" | "anthropic" | "openai" | "gemini";
9
+
10
+ export const PROVIDERS: Array<{ id: ProviderId; label: string; textModel: string; visionModel: string }> = [
11
+ { id: "workers-ai", label: "Cloudflare Workers AI", textModel: "@cf/meta/llama-4-scout-17b-16e-instruct", visionModel: "@cf/meta/llama-4-scout-17b-16e-instruct" },
12
+ // Checked 2026-10-03 against each provider's model docs: Claude Haiku 4.5 is only committed through
13
+ // Oct 15, 2026, so Sonnet 5.5 (through at least Sep 28, 2027) is the smallest Claude with a long runway;
14
+ // Gemini 2.5 is closed to new keys, Google recommends 3.8 Flash; GPT-6 Luna is OpenAI's efficient model.
15
+ { id: "anthropic", label: "Anthropic (Claude)", textModel: "claude-sonnet-5-5", visionModel: "claude-sonnet-5-5" },
16
+ { id: "openai", label: "OpenAI", textModel: "gpt-6-luna", visionModel: "gpt-6-luna" },
17
+ { id: "gemini", label: "Google Gemini", textModel: "gemini-3.8-flash", visionModel: "gemini-3.8-flash" },
18
+ ];
19
+
20
+ export const PROVIDER_HOSTS = ["api.anthropic.com", "api.openai.com", "generativelanguage.googleapis.com"];
21
+
22
+ export interface ChatRequest {
23
+ system: string;
24
+ user: string;
25
+ image?: { mimeType: string; base64: string };
26
+ maxTokens: number;
27
+ }
28
+
29
+ export interface ChatResult {
30
+ /** Raw text, or an already-parsed object (Workers AI JSON mode). */
31
+ text: string;
32
+ inputTokens: number;
33
+ outputTokens: number;
34
+ }
35
+
36
+ export interface ProviderConfig {
37
+ provider: ProviderId;
38
+ model: string;
39
+ apiKey: string;
40
+ /** Workers AI binding (env.AI). */
41
+ binding?: { run(model: string, input: unknown, options?: unknown): Promise<unknown> };
42
+ /** fetch to use (the plugin's HTTP access when available). */
43
+ fetch: (url: string, init?: RequestInit) => Promise<Response>;
44
+ }
45
+
46
+ const TIMEOUT_MS = 90_000;
47
+
48
+ export class ProviderError extends Error {
49
+ constructor(
50
+ message: string,
51
+ readonly status?: number,
52
+ ) {
53
+ super(message);
54
+ this.name = "ProviderError";
55
+ }
56
+ }
57
+
58
+ function redact(text: string, key: string): string {
59
+ let out = text;
60
+ if (key) out = out.split(key).join("[redacted]");
61
+ return out.replace(/\s+/g, " ").slice(0, 300);
62
+ }
63
+
64
+ async function httpError(response: Response, key: string, label: string): Promise<ProviderError> {
65
+ let detail = "";
66
+ try {
67
+ const body = (await response.json()) as { error?: { message?: string } | string; message?: string };
68
+ detail = typeof body.error === "string" ? body.error : (body.error?.message ?? body.message ?? "");
69
+ } catch {
70
+ detail = "";
71
+ }
72
+ return new ProviderError(`${label} returned HTTP ${response.status}${detail ? `: ${redact(detail, key)}` : ""}`, response.status);
73
+ }
74
+
75
+ async function postJson(cfg: ProviderConfig, url: string, headers: Record<string, string>, body: unknown, label: string): Promise<unknown> {
76
+ let response: Response;
77
+ try {
78
+ response = await cfg.fetch(url, {
79
+ method: "POST",
80
+ headers: { "content-type": "application/json", ...headers },
81
+ body: JSON.stringify(body),
82
+ signal: AbortSignal.timeout(TIMEOUT_MS),
83
+ });
84
+ } catch (error) {
85
+ throw new ProviderError(`${label} request failed: ${redact(String((error as Error)?.message ?? error), cfg.apiKey)}`);
86
+ }
87
+ if (!response.ok) throw await httpError(response, cfg.apiKey, label);
88
+ return response.json();
89
+ }
90
+
91
+ async function workersAi(cfg: ProviderConfig, req: ChatRequest): Promise<ChatResult> {
92
+ if (!cfg.binding) throw new ProviderError("The Workers AI binding isn't configured. Add an \"ai\" binding to wrangler.jsonc (see docs/ai.md in the Coywolf Pack repository).");
93
+ const content = req.image
94
+ ? [
95
+ { type: "text", text: req.user },
96
+ { type: "image_url", image_url: { url: `data:${req.image.mimeType};base64,${req.image.base64}` } },
97
+ ]
98
+ : req.user;
99
+ let result: unknown;
100
+ try {
101
+ result = await cfg.binding.run(cfg.model, {
102
+ messages: [
103
+ { role: "system", content: req.system },
104
+ { role: "user", content },
105
+ ],
106
+ max_tokens: req.maxTokens,
107
+ });
108
+ } catch (error) {
109
+ throw new ProviderError(`Workers AI: ${redact(String((error as Error)?.message ?? error), "")}`);
110
+ }
111
+ const r = result as { response?: unknown; usage?: { prompt_tokens?: number; completion_tokens?: number }; choices?: Array<{ message?: { content?: string } }> };
112
+ const raw = r.response ?? r.choices?.[0]?.message?.content ?? "";
113
+ return {
114
+ text: typeof raw === "string" ? raw : JSON.stringify(raw),
115
+ inputTokens: r.usage?.prompt_tokens ?? 0,
116
+ outputTokens: r.usage?.completion_tokens ?? 0,
117
+ };
118
+ }
119
+
120
+ async function anthropic(cfg: ProviderConfig, req: ChatRequest): Promise<ChatResult> {
121
+ const content: unknown[] = [];
122
+ if (req.image) content.push({ type: "image", source: { type: "base64", media_type: req.image.mimeType, data: req.image.base64 } });
123
+ content.push({ type: "text", text: req.user });
124
+ const data = (await postJson(
125
+ cfg,
126
+ "https://api.anthropic.com/v1/messages",
127
+ { "x-api-key": cfg.apiKey, "anthropic-version": "2023-06-01" },
128
+ { model: cfg.model, max_tokens: req.maxTokens, system: req.system, messages: [{ role: "user", content }] },
129
+ "Anthropic",
130
+ )) as { content?: Array<{ type?: string; text?: string }>; usage?: { input_tokens?: number; output_tokens?: number } };
131
+ return {
132
+ text: (data.content ?? []).filter((b) => b.type === "text").map((b) => b.text ?? "").join(""),
133
+ inputTokens: data.usage?.input_tokens ?? 0,
134
+ outputTokens: data.usage?.output_tokens ?? 0,
135
+ };
136
+ }
137
+
138
+ async function openai(cfg: ProviderConfig, req: ChatRequest): Promise<ChatResult> {
139
+ const user = req.image
140
+ ? [
141
+ { type: "text", text: req.user },
142
+ { type: "image_url", image_url: { url: `data:${req.image.mimeType};base64,${req.image.base64}` } },
143
+ ]
144
+ : req.user;
145
+ const body: Record<string, unknown> = {
146
+ model: cfg.model,
147
+ max_completion_tokens: req.maxTokens,
148
+ messages: [
149
+ { role: "system", content: req.system },
150
+ { role: "user", content: user },
151
+ ],
152
+ };
153
+ const headers = { authorization: `Bearer ${cfg.apiKey}` };
154
+ let data: unknown;
155
+ try {
156
+ data = await postJson(cfg, "https://api.openai.com/v1/chat/completions", headers, body, "OpenAI");
157
+ } catch (error) {
158
+ // Some models reject max_completion_tokens; retry once with the legacy field.
159
+ if (!(error instanceof ProviderError) || error.status !== 400 || !error.message.includes("max_completion_tokens")) throw error;
160
+ const { max_completion_tokens, ...rest } = body;
161
+ data = await postJson(cfg, "https://api.openai.com/v1/chat/completions", headers, { ...rest, max_tokens: max_completion_tokens }, "OpenAI");
162
+ }
163
+ const d = data as { choices?: Array<{ message?: { content?: string } }>; usage?: { prompt_tokens?: number; completion_tokens?: number } };
164
+ return { text: d.choices?.[0]?.message?.content ?? "", inputTokens: d.usage?.prompt_tokens ?? 0, outputTokens: d.usage?.completion_tokens ?? 0 };
165
+ }
166
+
167
+ async function gemini(cfg: ProviderConfig, req: ChatRequest): Promise<ChatResult> {
168
+ const parts: unknown[] = [];
169
+ if (req.image) parts.push({ inline_data: { mime_type: req.image.mimeType, data: req.image.base64 } });
170
+ parts.push({ text: req.user });
171
+ const data = (await postJson(
172
+ cfg,
173
+ `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(cfg.model)}:generateContent`,
174
+ { "x-goog-api-key": cfg.apiKey },
175
+ {
176
+ systemInstruction: { parts: [{ text: req.system }] },
177
+ contents: [{ role: "user", parts }],
178
+ // Thinking models spend output tokens before answering; leave room.
179
+ generationConfig: { maxOutputTokens: Math.max(4096, req.maxTokens) },
180
+ },
181
+ "Gemini",
182
+ )) as {
183
+ candidates?: Array<{ content?: { parts?: Array<{ text?: string; thought?: boolean }> } }>;
184
+ usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number };
185
+ };
186
+ const text = (data.candidates?.[0]?.content?.parts ?? [])
187
+ .filter((p) => typeof p.text === "string" && !p.thought)
188
+ .map((p) => p.text)
189
+ .join("");
190
+ return { text, inputTokens: data.usageMetadata?.promptTokenCount ?? 0, outputTokens: data.usageMetadata?.candidatesTokenCount ?? 0 };
191
+ }
192
+
193
+ /** Current hosted models may reason before answering, and that counts against the output limit; leave room. Billing is per token used. */
194
+ const MIN_OUTPUT_TOKENS = 4096;
195
+
196
+ export async function chat(cfg: ProviderConfig, input: ChatRequest): Promise<ChatResult> {
197
+ if (cfg.provider !== "workers-ai" && !cfg.apiKey) throw new ProviderError("Add an API key for the selected AI provider on the AI page.");
198
+ const req = cfg.provider === "workers-ai" ? input : { ...input, maxTokens: Math.max(MIN_OUTPUT_TOKENS, input.maxTokens) };
199
+ switch (cfg.provider) {
200
+ case "workers-ai":
201
+ return workersAi(cfg, req);
202
+ case "anthropic":
203
+ return anthropic(cfg, req);
204
+ case "openai":
205
+ return openai(cfg, req);
206
+ case "gemini":
207
+ return gemini(cfg, req);
208
+ }
209
+ }
210
+
211
+ // ── Model lists (for the settings page) ─────────────────────────
212
+
213
+ export interface ModelOption {
214
+ id: string;
215
+ label: string;
216
+ }
217
+
218
+ /** Workers AI text-generation models that accept images (curated; the binding has no list call). */
219
+ const WORKERS_AI_MODELS: ModelOption[] = [
220
+ { id: "@cf/meta/llama-4-scout-17b-16e-instruct", label: "Llama 4 Scout 17B (text + images)" },
221
+ { id: "@cf/mistralai/mistral-small-3.1-24b-instruct", label: "Mistral Small 3.1 24B (text + images)" },
222
+ { id: "@cf/google/gemma-3-12b-it", label: "Gemma 3 12B (text + images)" },
223
+ { id: "@cf/meta/llama-3.3-70b-instruct-fp8-fast", label: "Llama 3.3 70B fast (text only)" },
224
+ ];
225
+
226
+ /** OpenAI ids that aren't chat models (audio, images, embeddings, moderation, …). */
227
+ const OPENAI_SKIP = /(audio|realtime|tts|transcribe|whisper|dall-e|image|embedding|moderation|search|computer-use|babbage|davinci|instruct)/i;
228
+
229
+ /**
230
+ * Models the saved key can use, newest first where the provider says.
231
+ * The key is sent only to the provider and never returned or logged.
232
+ */
233
+ export async function listModels(cfg: Pick<ProviderConfig, "provider" | "apiKey" | "fetch">): Promise<ModelOption[]> {
234
+ const get = async (url: string, headers: Record<string, string>) => {
235
+ const response = await cfg.fetch(url, { headers, signal: AbortSignal.timeout(15_000) });
236
+ if (!response.ok) throw new ProviderError(`The provider answered ${response.status} when listing models.`, response.status);
237
+ return (await response.json()) as Record<string, unknown>;
238
+ };
239
+ switch (cfg.provider) {
240
+ case "workers-ai":
241
+ return WORKERS_AI_MODELS;
242
+ case "anthropic": {
243
+ const out: ModelOption[] = [];
244
+ let after: string | undefined;
245
+ for (let page = 0; page < 5; page++) {
246
+ const body = await get(`https://api.anthropic.com/v1/models?limit=100${after ? `&after_id=${encodeURIComponent(after)}` : ""}`, {
247
+ "x-api-key": cfg.apiKey,
248
+ "anthropic-version": "2023-06-01",
249
+ });
250
+ const data = (body.data ?? []) as Array<{ id: string; display_name?: string }>;
251
+ for (const m of data) out.push({ id: m.id, label: m.display_name ? `${m.display_name} (${m.id})` : m.id });
252
+ if (!body.has_more || !data.length) break;
253
+ after = data[data.length - 1].id;
254
+ }
255
+ return out;
256
+ }
257
+ case "openai": {
258
+ const body = await get("https://api.openai.com/v1/models", { Authorization: `Bearer ${cfg.apiKey}` });
259
+ const data = (body.data ?? []) as Array<{ id: string; created?: number }>;
260
+ return data
261
+ .filter((m) => /^(gpt|o\d|chatgpt)/i.test(m.id) && !OPENAI_SKIP.test(m.id))
262
+ .sort((a, b) => (b.created ?? 0) - (a.created ?? 0))
263
+ .map((m) => ({ id: m.id, label: m.id }));
264
+ }
265
+ case "gemini": {
266
+ const body = await get("https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000", { "x-goog-api-key": cfg.apiKey });
267
+ const models = (body.models ?? []) as Array<{ name: string; displayName?: string; supportedGenerationMethods?: string[] }>;
268
+ return models
269
+ .filter((m) => m.supportedGenerationMethods?.includes("generateContent") && /gemini/i.test(m.name))
270
+ .map((m) => {
271
+ const id = m.name.replace(/^models\//, "");
272
+ return { id, label: m.displayName ? `${m.displayName} (${id})` : id };
273
+ });
274
+ }
275
+ }
276
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Unit tests for AI Enrichment queue batching.
3
+ * Run: node --test test/*.test.mjs (or node --test src/ai/*.test.ts)
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import { test } from "node:test";
7
+
8
+ // @ts-ignore -- Node runs the .ts source directly; tsc doesn't need to resolve it.
9
+ import * as L from "./logic.ts";
10
+
11
+ const logic = L as typeof import("./logic.js");
12
+ type Job = import("./logic.js").QueueJob;
13
+
14
+ const job = (id: string, due: number, kind: Job["kind"] = "entry"): { id: string; data: Job } => ({
15
+ id,
16
+ data: { kind, due, attempts: 0, enqueuedAt: 0, ...(kind === "media" ? { mediaId: id } : { collection: "posts", entryId: id }) },
17
+ });
18
+
19
+ test("planBatch takes due jobs in due order up to the per-tick limit", () => {
20
+ const jobs = [job("c", 300), job("a", 100), job("future", 10_000), job("b", 200)];
21
+ const out = logic.planBatch(jobs, { now: 1000, perTick: 2, remainingCalls: 100, callsPerJob: () => 3 });
22
+ assert.deepEqual(
23
+ out.map((j) => j.id),
24
+ ["a", "b"],
25
+ );
26
+ });
27
+
28
+ test("planBatch respects the daily call budget and keeps each kind in order", () => {
29
+ const jobs = [job("entry1", 1), job("img", 2, "media"), job("entry2", 3)];
30
+ const cost = (j: Job) => (j.kind === "media" ? 1 : 3);
31
+ assert.deepEqual(
32
+ logic.planBatch(jobs, { now: 10, perTick: 10, remainingCalls: 4, callsPerJob: cost }).map((j) => j.id),
33
+ ["entry1", "img"],
34
+ );
35
+ // Not enough budget for the first entry: no entry runs (entry2 doesn't jump the line), but the image can.
36
+ assert.deepEqual(
37
+ logic.planBatch(jobs, { now: 10, perTick: 10, remainingCalls: 2, callsPerJob: cost }).map((j) => j.id),
38
+ ["img"],
39
+ );
40
+ assert.deepEqual(logic.planBatch(jobs, { now: 10, perTick: 10, remainingCalls: -5, callsPerJob: cost }), []);
41
+ });
42
+
43
+ test("deferJob pushes the job to Retry-After without an attempt, and gives up deferring after MAX_DEFERRALS in a row", () => {
44
+ const j = job("a", 0).data;
45
+ const once = logic.deferJob(j, 1_000, 30_000);
46
+ assert.deepEqual(once, { ...j, deferrals: 1, due: 31_000 });
47
+ assert.equal(once!.attempts, 0);
48
+ const tenth = logic.deferJob({ ...j, deferrals: logic.MAX_DEFERRALS - 1 }, 1_000, 30_000);
49
+ assert.equal(tenth?.deferrals, logic.MAX_DEFERRALS);
50
+ assert.equal(logic.deferJob({ ...j, deferrals: logic.MAX_DEFERRALS }, 1_000, 30_000), null, "the next one counts as a failed attempt");
51
+ assert.equal(logic.MAX_DEFERRALS, 10);
52
+ });
53
+
54
+ test("retryAt backs off and gives up after MAX_ATTEMPTS", () => {
55
+ const j = job("a", 0).data;
56
+ assert.equal(logic.retryAt({ ...j, attempts: 0 }, 0), 5 * 60_000);
57
+ assert.equal(logic.retryAt({ ...j, attempts: 1 }, 0), 20 * 60_000);
58
+ assert.equal(logic.retryAt({ ...j, attempts: logic.MAX_ATTEMPTS - 1 }, 0), null);
59
+ });
60
+
61
+ test("mergeJob debounces: later due wins, force sticks, attempts reset", () => {
62
+ const first: Job = { kind: "entry", collection: "posts", entryId: "1", due: 100, attempts: 2, enqueuedAt: 50, force: true };
63
+ const next: Job = { kind: "entry", collection: "posts", entryId: "1", due: 500, attempts: 0, enqueuedAt: 400 };
64
+ assert.deepEqual(logic.mergeJob(first, next), { ...next, force: true, enqueuedAt: 50, attempts: 0 });
65
+ assert.deepEqual(logic.mergeJob(null, next), next);
66
+ });
67
+
68
+ test("queueId and dayKey", () => {
69
+ assert.equal(logic.queueId({ kind: "entry", collection: "posts", entryId: "x" }), "entry:posts:x");
70
+ assert.equal(logic.queueId({ kind: "media", mediaId: "m" }), "media:m");
71
+ assert.equal(logic.dayKey(Date.UTC(2026, 9, 3, 23, 59)), "2026-10-03");
72
+ });
73
+
74
+ test("planBatch alternates kinds so a backlog of entries can't starve images", () => {
75
+ const jobs = [job("e1", 1), job("e2", 2), job("e3", 3), job("e4", 4), job("m1", 50, "media"), job("m2", 60, "media")];
76
+ const out = logic.planBatch(jobs, { now: 100, perTick: 4, remainingCalls: 100, callsPerJob: () => 1 });
77
+ assert.deepEqual(
78
+ out.map((j) => j.id),
79
+ ["e1", "m1", "e2", "m2"],
80
+ );
81
+ });
82
+
83
+ test("planBatch stays within the subrequest budget; an expensive kind stops without blocking a cheap one", () => {
84
+ const jobs = [job("e1", 1), job("e2", 2), job("m1", 3, "media"), job("m2", 4, "media")];
85
+ const out = logic.planBatch(jobs, {
86
+ now: 100,
87
+ perTick: 10,
88
+ remainingCalls: 100,
89
+ callsPerJob: () => 1,
90
+ subrequests: 20,
91
+ subrequestsPerJob: (j: Job) => (j.kind === "entry" ? 16 : 1),
92
+ });
93
+ assert.deepEqual(
94
+ out.map((j) => j.id),
95
+ ["e1", "m1", "m2"],
96
+ );
97
+ });