@hollware/server 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,8 +1,8 @@
1
- import { A as effectiveScopes, C as revisionTags, D as tokens, E as sites, O as users, S as reviews, T as schedules, _ as entries, b as outbox, g as deliveries, j as isRole, k as ROLE_SCOPES, l as membershipsOf, n as createInvitation, s as findSession, v as invitations, w as revisions, x as publications, y as members } from "./store-CsuER6J7.mjs";
2
- import { EVENT_ID_HEADER, HollwareError, OPERATIONS, SCOPES, SIGNATURE_HEADER, TIMESTAMP_HEADER, describeBuiltInContracts, describeResolvedSite, describeSite, generatePersonalToken, generateReadToken, getEntry, getTranslations, hashPersonalToken, isPersonalToken, isReadToken, isScope, listEntries, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, signWebhook, sizeLimitViolation, uniqueSlugViolation, uniqueTranslationViolation } from "@hollware/core";
1
+ import { A as users, C as reviewComments, D as schedules, E as revisions, M as effectiveScopes, N as isRole, O as sites, S as publications, T as revisionTags, _ as entries, b as members, g as deliveries, j as ROLE_SCOPES, k as tokens, l as membershipsOf, n as createInvitation, s as findSession, v as entryEmbeddings, w as reviews, x as outbox, y as invitations } from "./store-CYMjP0iN.mjs";
2
+ import { CHUNKER_VERSION, EVENT_ID_HEADER, HollwareError, OPERATIONS, SCOPES, SIGNATURE_HEADER, TIMESTAMP_HEADER, chunksOf, describeBuiltInContracts, describeResolvedSite, describeSite, generatePersonalToken, generateReadToken, getEntry, getTranslations, hashPersonalToken, isPersonalToken, isReadToken, isScope, isSiteKey, listEntries, newId, processMarkdown, publish, renderHtml, resolveConfig, runContracts, search, signWebhook, sizeLimitViolation, uniqueSlugViolation, uniqueTranslationViolation } from "@hollware/core";
3
3
  import { Hono } from "hono";
4
4
  import { drizzle } from "drizzle-orm/d1";
5
- import { and, desc, eq, getTableColumns, gt, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
5
+ import { and, asc, desc, eq, getTableColumns, gt, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm";
6
6
  import { alias } from "drizzle-orm/sqlite-core";
7
7
  import { AuthorizationError, CimdFetchError, OAuthAuthorizationServer, OAuthResourceServer, authorizationErrorRedirect } from "@cloudflare/workers-oauth-provider";
8
8
  import { getCookie } from "hono/cookie";
@@ -10,7 +10,7 @@ import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, McpServer, accepted
10
10
  import * as z from "zod";
11
11
  import { WorkerEntrypoint } from "cloudflare:workers";
12
12
  //#region package.json
13
- var version = "0.4.0";
13
+ var version = "0.6.0";
14
14
  //#endregion
15
15
  //#region src/auth/responses.ts
16
16
  /** The challenge of every 401 and 403 (RFC 6750). */
@@ -632,6 +632,61 @@ function listEntriesQuery(db, site, query, cursor) {
632
632
  cursor === null ? void 0 : sql`(${sortKey}, ${entries.id}) < (${cursor.key}, ${cursor.id})`
633
633
  ]).orderBy(desc(sortKey), desc(entries.id)).limit(query.limit + 1);
634
634
  }
635
+ /**
636
+ * The FTS5 query of what someone typed: each of its words as a quoted string, so that FTS5's own
637
+ * syntax (`AND`, `NEAR`, `*`, quotes) is never read from it, and every word must match. Empty when
638
+ * it holds no word.
639
+ */
640
+ function ftsQuery(text) {
641
+ return (text.match(/[\p{L}\p{N}_]+/gu) ?? []).map((word) => `"${word}"`).join(" ");
642
+ }
643
+ /** The weights of the columns of `entries_fts` in bm25: title, tags, then the text. */
644
+ const BM25 = sql.raw("bm25(entries_fts, 10.0, 4.0, 1.0)");
645
+ async function searchEntries(db, site, query) {
646
+ const match = ftsQuery(query.text);
647
+ if (match === "") return [];
648
+ const hits = await db.all(sql`
649
+ select entry_id, snippet(entries_fts, 2, '[', ']', '…', 16) as snippet, ${BM25} as rank
650
+ from entries_fts
651
+ where entries_fts match ${match} and site_id = ${site}
652
+ ${query.collection === void 0 ? sql`` : sql`and collection = ${query.collection}`}
653
+ ${query.locale === void 0 ? sql`` : sql`and locale = ${query.locale}`}
654
+ order by rank
655
+ limit ${query.limit}
656
+ `);
657
+ if (hits.length === 0) return [];
658
+ const rows = await summaryQuery(db, "published", [eq(entries.siteId, site), inArray(entries.id, hits.map((hit) => hit.entry_id))]);
659
+ const byId = new Map(rows.map((row) => [row.entry.id, toEntrySummaryView(row)]));
660
+ return hits.flatMap((hit) => {
661
+ const entry = byId.get(hit.entry_id);
662
+ return entry === void 0 ? [] : [{
663
+ entry,
664
+ snippet: hit.snippet,
665
+ score: -hit.rank
666
+ }];
667
+ });
668
+ }
669
+ /** The entries of those ids that the view has, in the order of the ids; the others are left out. */
670
+ async function findSummaries(db, site, ids, view) {
671
+ if (ids.length === 0) return [];
672
+ const rows = await summaryQuery(db, view, [eq(entries.siteId, site), inArray(entries.id, [...ids])]);
673
+ const byId = new Map(rows.map((row) => [row.entry.id, toEntrySummaryView(row)]));
674
+ return ids.flatMap((id) => {
675
+ const entry = byId.get(id);
676
+ return entry === void 0 ? [] : [entry];
677
+ });
678
+ }
679
+ /** The entries closest to one, closest first, as its vectors found them (ADR-030); `[]` without. */
680
+ async function relatedOf(db, site, entryId) {
681
+ const [row] = await db.select({ related: entryEmbeddings.related }).from(entryEmbeddings).where(and(eq(entryEmbeddings.siteId, site), eq(entryEmbeddings.entryId, entryId))).limit(1);
682
+ if (row === void 0) return [];
683
+ const parsed = JSON.parse(row.related);
684
+ if (!Array.isArray(parsed)) return [];
685
+ return parsed.flatMap((item) => typeof item === "object" && item !== null && "id" in item && "score" in item && typeof item.id === "string" && typeof item.score === "number" ? [{
686
+ id: item.id,
687
+ score: item.score
688
+ }] : []);
689
+ }
635
690
  async function listEntries$2(db, site, query) {
636
691
  if (!Number.isInteger(query.limit) || query.limit < 1) throw new HollwareError("invalid_input", `The limit ${query.limit} is not a whole number of at least 1.`, "Ask for at least one entry per page.");
637
692
  const cursor = query.cursor === void 0 ? null : decodeCursor(query.cursor, query.view);
@@ -986,7 +1041,7 @@ async function pendingOf(db, site, entryId) {
986
1041
  const [row] = await db.select(COLUMNS).from(reviews).innerJoin(users, eq(users.id, reviews.requestedBy)).where(and(eq(reviews.siteId, site), eq(reviews.entryId, entryId), eq(reviews.status, "pending"))).limit(1);
987
1042
  return row === void 0 ? null : toReview(row);
988
1043
  }
989
- async function requestReview(db, clock, site, write, whyNothingChanged) {
1044
+ async function requestReview$1(db, clock, site, write, whyNothingChanged) {
990
1045
  const now = clock.now().toISOString();
991
1046
  const row = {
992
1047
  id: newId("rvw"),
@@ -1048,6 +1103,59 @@ async function requestChanges$1(db, clock, site, write) {
1048
1103
  review
1049
1104
  };
1050
1105
  }
1106
+ function metaOf(text) {
1107
+ if (text === null) return null;
1108
+ const parsed = JSON.parse(text);
1109
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? Object.fromEntries(Object.entries(parsed)) : null;
1110
+ }
1111
+ const COMMENT_COLUMNS = {
1112
+ ...getTableColumns(reviewComments),
1113
+ authorName: users.name
1114
+ };
1115
+ function toComment(row) {
1116
+ return {
1117
+ id: row.id,
1118
+ reviewId: row.reviewId,
1119
+ authorId: row.authorId,
1120
+ authorName: row.authorName,
1121
+ authorMeta: metaOf(row.authorMeta),
1122
+ body: row.body,
1123
+ createdAt: row.createdAt
1124
+ };
1125
+ }
1126
+ /**
1127
+ * Adds a comment to a review in one statement that inserts only while the review is pending, so a
1128
+ * decision that lands first leaves no comment behind it.
1129
+ */
1130
+ async function commentReview$1(db, clock, site, write) {
1131
+ const id = newId("rvc");
1132
+ const now = clock.now().toISOString();
1133
+ const meta = write.actor.meta === null ? null : JSON.stringify(write.actor.meta);
1134
+ if ((await db.all(sql`
1135
+ insert into ${reviewComments}
1136
+ (id, site_id, review_id, author_id, author_meta, body, created_at, updated_at)
1137
+ select ${id}, ${site}, ${write.reviewId}, ${write.actor.userId}, ${meta}, ${write.body}, ${now}, ${now}
1138
+ where exists (select 1 from ${reviews} where ${and(eq(reviews.id, write.reviewId), eq(reviews.siteId, site), eq(reviews.status, "pending"))})
1139
+ returning id
1140
+ `)).length === 0) return { outcome: await findReview(db, site, write.reviewId) === null ? "not_found" : "not_pending" };
1141
+ const [row] = await db.select(COMMENT_COLUMNS).from(reviewComments).innerJoin(users, eq(users.id, reviewComments.authorId)).where(and(eq(reviewComments.siteId, site), eq(reviewComments.id, id))).limit(1);
1142
+ if (row === void 0) throw new Error(`The comment ${id} that was just written is not there`);
1143
+ return {
1144
+ outcome: "commented",
1145
+ comment: toComment(row)
1146
+ };
1147
+ }
1148
+ async function reviewCommentsOf(db, site, reviewIds) {
1149
+ const byReview = /* @__PURE__ */ new Map();
1150
+ if (reviewIds.length === 0) return byReview;
1151
+ const rows = await db.select(COMMENT_COLUMNS).from(reviewComments).innerJoin(users, eq(users.id, reviewComments.authorId)).where(and(eq(reviewComments.siteId, site), inArray(reviewComments.reviewId, [...reviewIds]))).orderBy(asc(reviewComments.createdAt), asc(reviewComments.id));
1152
+ for (const row of rows) {
1153
+ const list = byReview.get(row.reviewId) ?? [];
1154
+ list.push(toComment(row));
1155
+ byReview.set(row.reviewId, list);
1156
+ }
1157
+ return byReview;
1158
+ }
1051
1159
  //#endregion
1052
1160
  //#region src/db/size.ts
1053
1161
  /**
@@ -1442,6 +1550,9 @@ function createD1Store(db, options = {}) {
1442
1550
  findEntry: (site, ref, view) => findEntry(database, site, ref, view),
1443
1551
  findEntryById: (site, id, view) => findEntryById(database, site, id, view),
1444
1552
  listEntries: (site, query) => listEntries$2(database, site, query),
1553
+ searchEntries: (site, query) => searchEntries(database, site, query),
1554
+ findSummaries: (site, ids, view) => findSummaries(database, site, ids, view),
1555
+ relatedOf: (site, entryId) => relatedOf(database, site, entryId),
1445
1556
  listTranslations: (site, collection, translationGroup, view) => listTranslations(database, site, collection, translationGroup, view),
1446
1557
  lookup: (site) => lookup(database, site),
1447
1558
  lastPublication: (site, entryId) => lastPublication(database, site, entryId),
@@ -1453,10 +1564,12 @@ function createD1Store(db, options = {}) {
1453
1564
  dueSchedules: (site, now) => dueSchedules(database, site, now),
1454
1565
  cancelSchedule: (site, entryId) => cancelSchedule(database, clock, site, entryId),
1455
1566
  unpublish: (site, write) => unpublish$1(database, clock, site, write),
1456
- requestReview: (site, write) => requestReview(database, clock, site, write, () => whyNothingChanged(database, site, write.entryId)),
1567
+ requestReview: (site, write) => requestReview$1(database, clock, site, write, () => whyNothingChanged(database, site, write.entryId)),
1457
1568
  findReview: (site, id) => findReview(database, site, id),
1458
1569
  listReviews: (site, query) => listReviews$1(database, site, query),
1459
- requestChanges: (site, write) => requestChanges$1(database, clock, site, write)
1570
+ requestChanges: (site, write) => requestChanges$1(database, clock, site, write),
1571
+ commentReview: (site, write) => commentReview$1(database, clock, site, write),
1572
+ reviewComments: (site, reviewIds) => reviewCommentsOf(database, site, reviewIds)
1460
1573
  };
1461
1574
  }
1462
1575
  //#endregion
@@ -1515,6 +1628,191 @@ function sqlIncrement() {
1515
1628
  return sql`${deliveries.attempts} + 1`;
1516
1629
  }
1517
1630
  //#endregion
1631
+ //#region src/semantic/index-entry.ts
1632
+ /** The id of a piece's vector. */
1633
+ function vectorId(entryId, piece) {
1634
+ return `${entryId}:${piece}`;
1635
+ }
1636
+ function excerptOf(text) {
1637
+ if (text.length <= 200) return text;
1638
+ const cut = text.slice(0, 200);
1639
+ const lastSpace = cut.lastIndexOf(" ");
1640
+ return `${(lastSpace > 0 ? cut.slice(0, lastSpace) : cut).trim()}…`;
1641
+ }
1642
+ function stringOf(value) {
1643
+ return typeof value === "string" && value.trim() !== "" ? value : void 0;
1644
+ }
1645
+ /** The ids of the vectors of `pieces` pieces of an entry, from `from` on. */
1646
+ function idsOf(entryId, from, pieces) {
1647
+ return Array.from({ length: Math.max(0, pieces - from) }, (_, offset) => vectorId(entryId, from + offset));
1648
+ }
1649
+ /** The entries nearest to an entry's summary, in its collection and locale; itself left out. */
1650
+ async function nearestTo(semantic, input) {
1651
+ return (await semantic.index.query(input.summary, {
1652
+ topK: 6,
1653
+ site: input.site,
1654
+ collection: input.collection,
1655
+ locale: input.locale,
1656
+ summariesOnly: true
1657
+ })).filter((match) => match.metadata.entry !== input.entryId).slice(0, 5).map((match) => ({
1658
+ id: match.metadata.entry,
1659
+ score: match.score
1660
+ }));
1661
+ }
1662
+ /**
1663
+ * Brings the vectors of one entry in line with what it has published: the pieces of its published
1664
+ * revision, embedded with the model of `semantic`. It reads the entry as it is now, so it is right
1665
+ * whichever event called it and however late: an entry whose vectors already are of its published
1666
+ * revision, model and chunker is `current` and costs no embedding; an entry with nothing published
1667
+ * (unpublished, deleted, gone) loses its vectors and its row.
1668
+ */
1669
+ async function syncEntryVectors(db, semantic, input) {
1670
+ const { site, entryId, now } = input;
1671
+ const [published] = await db.select({
1672
+ collection: entries.collection,
1673
+ locale: entries.locale,
1674
+ revisionId: revisions.id,
1675
+ frontmatter: revisions.frontmatter,
1676
+ document: revisions.document
1677
+ }).from(entries).innerJoin(revisions, and(eq(revisions.id, entries.publishedRev), eq(revisions.siteId, entries.siteId))).where(and(eq(entries.siteId, site), eq(entries.id, entryId))).limit(1);
1678
+ const [stored] = await db.select({
1679
+ revisionId: entryEmbeddings.revisionId,
1680
+ model: entryEmbeddings.model,
1681
+ chunkerVersion: entryEmbeddings.chunkerVersion,
1682
+ pieces: entryEmbeddings.pieces
1683
+ }).from(entryEmbeddings).where(and(eq(entryEmbeddings.siteId, site), eq(entryEmbeddings.entryId, entryId))).limit(1);
1684
+ if (published === void 0) {
1685
+ if (stored === void 0) return "absent";
1686
+ await semantic.index.remove(idsOf(entryId, 0, stored.pieces));
1687
+ await db.delete(entryEmbeddings).where(and(eq(entryEmbeddings.siteId, site), eq(entryEmbeddings.entryId, entryId)));
1688
+ return "removed";
1689
+ }
1690
+ if (stored !== void 0 && stored.revisionId === published.revisionId && stored.model === semantic.embedder.model && stored.chunkerVersion === CHUNKER_VERSION) return "current";
1691
+ const frontmatter = JSON.parse(published.frontmatter);
1692
+ const document = JSON.parse(published.document);
1693
+ const description = stringOf(frontmatter["description"]);
1694
+ const chunks = chunksOf({
1695
+ title: stringOf(frontmatter["title"]) ?? "",
1696
+ ...description === void 0 ? {} : { description },
1697
+ document
1698
+ });
1699
+ const vectors = await semantic.embedder.embed(chunks.map((chunk) => chunk.text));
1700
+ await semantic.index.upsert(chunks.map((chunk) => ({
1701
+ id: vectorId(entryId, chunk.index),
1702
+ values: vectors[chunk.index] ?? [],
1703
+ metadata: {
1704
+ site,
1705
+ entry: entryId,
1706
+ collection: published.collection,
1707
+ locale: published.locale,
1708
+ piece: chunk.index,
1709
+ excerpt: excerptOf(chunk.text)
1710
+ }
1711
+ })));
1712
+ if (stored !== void 0) await semantic.index.remove(idsOf(entryId, chunks.length, stored.pieces));
1713
+ const related = await nearestTo(semantic, {
1714
+ site,
1715
+ entryId,
1716
+ collection: published.collection,
1717
+ locale: published.locale,
1718
+ summary: vectors[0] ?? []
1719
+ });
1720
+ const row = {
1721
+ related: JSON.stringify(related),
1722
+ revisionId: published.revisionId,
1723
+ model: semantic.embedder.model,
1724
+ chunkerVersion: CHUNKER_VERSION,
1725
+ pieces: chunks.length,
1726
+ updatedAt: now
1727
+ };
1728
+ await db.insert(entryEmbeddings).values({
1729
+ id: newId("emb"),
1730
+ siteId: site,
1731
+ entryId,
1732
+ createdAt: now,
1733
+ ...row
1734
+ }).onConflictDoUpdate({
1735
+ target: [entryEmbeddings.siteId, entryEmbeddings.entryId],
1736
+ set: row
1737
+ });
1738
+ return "indexed";
1739
+ }
1740
+ /**
1741
+ * The entries whose vectors are not those of what they publish: published with no vectors (before
1742
+ * the Worker had semantic search, or an event that never reached the subscriber), of another
1743
+ * revision, model or chunker; and entries with vectors and nothing published. At most `limit`.
1744
+ */
1745
+ async function staleEntries(db, input) {
1746
+ const { site, model, limit } = input;
1747
+ const published = await db.select({ id: entries.id }).from(entries).leftJoin(entryEmbeddings, and(eq(entryEmbeddings.entryId, entries.id), eq(entryEmbeddings.siteId, entries.siteId))).where(and(eq(entries.siteId, site), isNotNull(entries.publishedRev), or(isNull(entryEmbeddings.id), ne(entryEmbeddings.revisionId, entries.publishedRev), ne(entryEmbeddings.model, model), ne(entryEmbeddings.chunkerVersion, CHUNKER_VERSION)))).limit(limit);
1748
+ const left = limit - published.length;
1749
+ const orphans = left <= 0 ? [] : await db.select({ id: entryEmbeddings.entryId }).from(entryEmbeddings).innerJoin(entries, and(eq(entries.id, entryEmbeddings.entryId), eq(entries.siteId, entryEmbeddings.siteId))).where(and(eq(entryEmbeddings.siteId, site), isNull(entries.publishedRev))).limit(left);
1750
+ return [...published, ...orphans].map((row) => row.id);
1751
+ }
1752
+ /**
1753
+ * What the cron trigger does for the vectors of a site: brings at most `limit` stale entries in
1754
+ * line, so that a site that turns semantic search on, or changes its model, is indexed a few
1755
+ * entries a minute without a command. An entry that fails is left for the next run.
1756
+ */
1757
+ async function healVectors(db, semantic, input) {
1758
+ const ids = await staleEntries(db, {
1759
+ site: input.site,
1760
+ model: semantic.embedder.model,
1761
+ limit: input.limit ?? 20
1762
+ });
1763
+ let failed = 0;
1764
+ for (const entryId of ids) await syncEntryVectors(db, semantic, {
1765
+ site: input.site,
1766
+ entryId,
1767
+ now: input.now
1768
+ }).catch(() => {
1769
+ failed += 1;
1770
+ });
1771
+ return {
1772
+ synced: ids.length - failed,
1773
+ failed
1774
+ };
1775
+ }
1776
+ /** How long an entry with no related entries waits before they are looked for again. */
1777
+ const RELATED_RETRY_MS = 6e5;
1778
+ /** How old the related entries of an entry may grow before they are looked for again. */
1779
+ const RELATED_REFRESH_MS = 864e5;
1780
+ /**
1781
+ * What the cron trigger does for related entries: it finds them again for at most `limit`
1782
+ * entries of the site, oldest first, whose related entries are a day old, or empty for ten
1783
+ * minutes. Vectorize shows a vector seconds after it was written, so entries indexed together
1784
+ * miss each other at first; and an entry published later belongs among the related entries of
1785
+ * those before it. Each keeps its vectors: only its summary is read back and compared.
1786
+ */
1787
+ async function refreshRelated(db, semantic, input) {
1788
+ const now = Date.parse(input.now);
1789
+ const retryBefore = (/* @__PURE__ */ new Date(now - RELATED_RETRY_MS)).toISOString();
1790
+ const refreshBefore = (/* @__PURE__ */ new Date(now - RELATED_REFRESH_MS)).toISOString();
1791
+ const rows = await db.select({
1792
+ entryId: entryEmbeddings.entryId,
1793
+ collection: entries.collection,
1794
+ locale: entries.locale
1795
+ }).from(entryEmbeddings).innerJoin(entries, and(eq(entries.id, entryEmbeddings.entryId), eq(entries.siteId, entryEmbeddings.siteId))).where(and(eq(entryEmbeddings.siteId, input.site), isNotNull(entries.publishedRev), or(and(eq(entryEmbeddings.related, "[]"), lte(entryEmbeddings.updatedAt, retryBefore)), lte(entryEmbeddings.updatedAt, refreshBefore)))).orderBy(entryEmbeddings.updatedAt).limit(input.limit ?? 10);
1796
+ if (rows.length === 0) return 0;
1797
+ const summaries = new Map((await semantic.index.get(rows.map((row) => vectorId(row.entryId, 0)))).map((vector) => [vector.id, vector.values]));
1798
+ let refreshed = 0;
1799
+ for (const row of rows) {
1800
+ const summary = summaries.get(vectorId(row.entryId, 0));
1801
+ if (summary === void 0) continue;
1802
+ const related = await nearestTo(semantic, {
1803
+ site: input.site,
1804
+ ...row,
1805
+ summary
1806
+ });
1807
+ await db.update(entryEmbeddings).set({
1808
+ related: JSON.stringify(related),
1809
+ updatedAt: input.now
1810
+ }).where(and(eq(entryEmbeddings.siteId, input.site), eq(entryEmbeddings.entryId, row.entryId)));
1811
+ refreshed += 1;
1812
+ }
1813
+ return refreshed;
1814
+ }
1815
+ //#endregion
1518
1816
  //#region src/events/deliver.ts
1519
1817
  /** Where IndexNow takes the URLs that changed. */
1520
1818
  const INDEXNOW_ENDPOINT = "https://api.indexnow.org/indexnow";
@@ -1566,6 +1864,17 @@ function subscribersOf(deps) {
1566
1864
  }
1567
1865
  });
1568
1866
  }
1867
+ const semantic = deps.semantic;
1868
+ if (semantic !== void 0 && semantic !== null) subscribers.push({
1869
+ name: "vectors",
1870
+ async send(payload) {
1871
+ await syncEntryVectors(deps.db, semantic, {
1872
+ site: site.key,
1873
+ entryId: payload.entry.id,
1874
+ now: deps.clock.now().toISOString()
1875
+ });
1876
+ }
1877
+ });
1569
1878
  return subscribers;
1570
1879
  }
1571
1880
  /**
@@ -2040,6 +2349,208 @@ function installAdminApi(app, options) {
2040
2349
  }));
2041
2350
  }
2042
2351
  //#endregion
2352
+ //#region src/semantic/cloudflare.ts
2353
+ /** The embedding model when the config names none: multilingual, so Spanish finds English. */
2354
+ const DEFAULT_EMBEDDING_MODEL = "@cf/baai/bge-m3";
2355
+ function isRecord$3(value) {
2356
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2357
+ }
2358
+ /** Workers AI as an embedder: `{ data: number[][] }` for a batch of texts. */
2359
+ function workersAiEmbedder(ai, model) {
2360
+ return {
2361
+ model,
2362
+ async embed(texts) {
2363
+ if (texts.length === 0) return [];
2364
+ const answer = await ai.run(model, { text: [...texts] });
2365
+ const data = isRecord$3(answer) ? answer["data"] : void 0;
2366
+ if (!Array.isArray(data) || data.length !== texts.length) throw new Error(`${model} answered no vector for each of ${texts.length} texts`);
2367
+ return data.map((vector) => {
2368
+ if (!Array.isArray(vector)) throw new Error(`${model} answered a vector that is no list`);
2369
+ return vector.map(Number);
2370
+ });
2371
+ }
2372
+ };
2373
+ }
2374
+ function metadataOf(raw) {
2375
+ if (raw === void 0) return null;
2376
+ const { site, entry, collection, locale, piece, excerpt } = raw;
2377
+ if (typeof excerpt !== "string" || typeof site !== "string" || typeof entry !== "string" || typeof collection !== "string" || typeof locale !== "string" || typeof piece !== "number") return null;
2378
+ return {
2379
+ site,
2380
+ entry,
2381
+ collection,
2382
+ locale,
2383
+ piece,
2384
+ excerpt
2385
+ };
2386
+ }
2387
+ /** Vectorize as the index. Its metadata indexes on site, collection, locale and piece filter. */
2388
+ function vectorizeIndex(binding) {
2389
+ return {
2390
+ async upsert(vectors) {
2391
+ if (vectors.length === 0) return;
2392
+ await binding.upsert(vectors.map((vector) => ({
2393
+ id: vector.id,
2394
+ values: [...vector.values],
2395
+ metadata: { ...vector.metadata }
2396
+ })));
2397
+ },
2398
+ async remove(ids) {
2399
+ if (ids.length > 0) await binding.deleteByIds([...ids]);
2400
+ },
2401
+ async get(ids) {
2402
+ if (ids.length === 0) return [];
2403
+ return (await binding.getByIds([...ids])).map((vector) => ({
2404
+ id: vector.id,
2405
+ values: Array.from(vector.values)
2406
+ }));
2407
+ },
2408
+ async query(values, options) {
2409
+ return (await binding.query([...values], {
2410
+ topK: options.topK,
2411
+ returnMetadata: "all",
2412
+ filter: {
2413
+ site: options.site,
2414
+ ...options.collection === void 0 ? {} : { collection: options.collection },
2415
+ ...options.locale === void 0 ? {} : { locale: options.locale },
2416
+ ...options.summariesOnly === true ? { piece: 0 } : {}
2417
+ }
2418
+ })).matches.flatMap((match) => {
2419
+ const metadata = metadataOf(match.metadata);
2420
+ return metadata === null ? [] : [{
2421
+ id: match.id,
2422
+ score: match.score,
2423
+ metadata
2424
+ }];
2425
+ });
2426
+ }
2427
+ };
2428
+ }
2429
+ /** Semantic search over the Worker's bindings, or `null` when it does not have both. */
2430
+ function semanticOf(env, model) {
2431
+ const ai = Reflect.get(env, "AI");
2432
+ const vectorize = Reflect.get(env, "VECTORIZE");
2433
+ if (!isRecord$3(ai) || typeof ai["run"] !== "function") return null;
2434
+ if (!isRecord$3(vectorize) || typeof vectorize["query"] !== "function") return null;
2435
+ return {
2436
+ embedder: workersAiEmbedder(ai, model),
2437
+ index: vectorizeIndex(vectorize)
2438
+ };
2439
+ }
2440
+ /**
2441
+ * Semantic search over the Worker's bindings, or `null` without them. The model is the variable
2442
+ * `HOLLWARE_EMBEDDING_MODEL`, or `DEFAULT_EMBEDDING_MODEL`; the Vectorize index must have been
2443
+ * made for its dimensions, so a new model needs a new index.
2444
+ */
2445
+ function semanticOfEnv(env) {
2446
+ const model = Reflect.get(env, "HOLLWARE_EMBEDDING_MODEL");
2447
+ return semanticOf(env, typeof model === "string" && model !== "" ? model : DEFAULT_EMBEDDING_MODEL);
2448
+ }
2449
+ //#endregion
2450
+ //#region src/analytics/cloudflare.ts
2451
+ /** The dataset of Analytics Engine when the Worker names none. */
2452
+ const DEFAULT_ANALYTICS_DATASET = "hollware_page_views";
2453
+ /** Where the SQL API of Analytics Engine answers. */
2454
+ function sqlApiUrl(accountId) {
2455
+ return `https://api.cloudflare.com/client/v4/accounts/${accountId}/analytics_engine/sql`;
2456
+ }
2457
+ function isRecord$2(value) {
2458
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2459
+ }
2460
+ /**
2461
+ * Analytics Engine as the writer: one data point per view, indexed by the site (its sampling key),
2462
+ * with the path and the country as blobs and a count of 1.
2463
+ */
2464
+ function analyticsEngineWriter(binding) {
2465
+ return { record(view) {
2466
+ binding.writeDataPoint({
2467
+ indexes: [view.site],
2468
+ blobs: [view.path, view.country],
2469
+ doubles: [1]
2470
+ });
2471
+ } };
2472
+ }
2473
+ /**
2474
+ * The query of the most read paths. The SQL API takes no parameters, so every value in it is
2475
+ * checked first: a site key (`[a-z0-9-]`), a dataset name and two whole numbers.
2476
+ */
2477
+ function topPagesSql(dataset, site, query) {
2478
+ if (!/^[a-z_][a-z0-9_]{0,63}$/i.test(dataset)) throw new Error(`No dataset is named ${dataset}`);
2479
+ if (!isSiteKey(site)) throw new Error(`No site key is ${String(site)}`);
2480
+ if (!Number.isInteger(query.days) || query.days < 1 || query.days > 90) throw new Error(`${query.days} days is not a whole number from 1 to 90`);
2481
+ if (!Number.isInteger(query.limit) || query.limit < 1 || query.limit > 50) throw new Error(`${query.limit} pages is not a whole number from 1 to 50`);
2482
+ return [
2483
+ "SELECT blob1 AS path, SUM(_sample_interval) AS views",
2484
+ `FROM ${dataset}`,
2485
+ `WHERE index1 = '${site}' AND timestamp > NOW() - INTERVAL '${query.days}' DAY`,
2486
+ "GROUP BY path",
2487
+ "ORDER BY views DESC",
2488
+ `LIMIT ${query.limit}`,
2489
+ "FORMAT JSON"
2490
+ ].join(" ");
2491
+ }
2492
+ /** The SQL API of Analytics Engine as the reader, its answers kept for ten minutes. */
2493
+ function sqlApiReader(options) {
2494
+ return { async topPages(site, query) {
2495
+ const sql = topPagesSql(options.dataset, site, query);
2496
+ const key = new Request(`https://hollware.invalid/content-stats/${options.dataset}/${site}?days=${query.days}&limit=${query.limit}`);
2497
+ const cached = await options.cache?.match(key);
2498
+ const answer = cached ?? await options.fetch(new Request(sqlApiUrl(options.accountId), {
2499
+ method: "POST",
2500
+ headers: { authorization: `Bearer ${options.token}` },
2501
+ body: sql
2502
+ }));
2503
+ if (!answer.ok) throw new Error(`The SQL API of Analytics Engine answered ${answer.status}`);
2504
+ const text = await answer.text();
2505
+ if (cached === void 0 && options.cache !== null) await options.cache.put(key, new Response(text, { headers: { "cache-control": `max-age=600` } }));
2506
+ const body = JSON.parse(text);
2507
+ return (isRecord$2(body) && Array.isArray(body["data"]) ? body["data"] : []).flatMap((row) => {
2508
+ if (!isRecord$2(row) || typeof row["path"] !== "string") return [];
2509
+ const views = Number(row["views"]);
2510
+ return Number.isFinite(views) ? [{
2511
+ path: row["path"],
2512
+ views
2513
+ }] : [];
2514
+ });
2515
+ } };
2516
+ }
2517
+ /** The writer over the Worker's `ANALYTICS` binding, or `null` without one. */
2518
+ function analyticsWriterOf(env) {
2519
+ const binding = Reflect.get(env, "ANALYTICS");
2520
+ if (!isRecord$2(binding) || typeof binding["writeDataPoint"] !== "function") return null;
2521
+ return analyticsEngineWriter(binding);
2522
+ }
2523
+ /**
2524
+ * The reader over the Worker's variables: `HOLLWARE_ANALYTICS_ACCOUNT_ID` and the secret
2525
+ * `HOLLWARE_ANALYTICS_TOKEN` (an API token with Account Analytics Read), and the dataset of
2526
+ * `HOLLWARE_ANALYTICS_DATASET` or the default one. `null` without the account or the token.
2527
+ */
2528
+ function analyticsReaderOf(env) {
2529
+ const accountId = Reflect.get(env, "HOLLWARE_ANALYTICS_ACCOUNT_ID");
2530
+ const token = Reflect.get(env, "HOLLWARE_ANALYTICS_TOKEN");
2531
+ const dataset = Reflect.get(env, "HOLLWARE_ANALYTICS_DATASET");
2532
+ if (typeof accountId !== "string" || accountId === "") return null;
2533
+ if (typeof token !== "string" || token === "") return null;
2534
+ return sqlApiReader({
2535
+ accountId,
2536
+ token,
2537
+ dataset: typeof dataset === "string" && dataset !== "" ? dataset : DEFAULT_ANALYTICS_DATASET,
2538
+ fetch: (request) => fetch(request),
2539
+ cache: typeof caches === "undefined" ? null : caches.default
2540
+ });
2541
+ }
2542
+ //#endregion
2543
+ //#region src/ops/invoke.ts
2544
+ /**
2545
+ * Calls the handler of an operation. This is the one place where the typed pairing of specs and
2546
+ * handlers meets a dynamic caller (MCP, the ops API): the caller parsed `input` with the input
2547
+ * schema of `spec`, which is the schema that types the handler of the same name.
2548
+ */
2549
+ function invoke(handlers, spec, input, context) {
2550
+ const handler = handlers[spec.name];
2551
+ return handler(input, context);
2552
+ }
2553
+ //#endregion
2043
2554
  //#region src/http/ops-api.ts
2044
2555
  const STATUS$1 = {
2045
2556
  invalid_input: 400,
@@ -2054,10 +2565,6 @@ function json(body, status = 200) {
2054
2565
  });
2055
2566
  }
2056
2567
  /** Calls the handler of an operation with input its spec has parsed. */
2057
- function invoke$1(handlers, spec, input, context) {
2058
- const handler = handlers[spec.name];
2059
- return handler(input, context);
2060
- }
2061
2568
  function installOpsApi(app, options) {
2062
2569
  app.post("/v1/ops/:name", async (c) => options.protect(c, async (principal) => {
2063
2570
  const requestId = c.get("requestId");
@@ -2089,10 +2596,12 @@ function installOpsApi(app, options) {
2089
2596
  clock: options.clock,
2090
2597
  client: { name: "hollware-admin" },
2091
2598
  requestId,
2092
- notify: options.notify(c, requestId, site.key)
2599
+ notify: options.notify(c, requestId, site.key),
2600
+ semantic: semanticOfEnv(c.env),
2601
+ analytics: analyticsReaderOf(c.env)
2093
2602
  };
2094
2603
  try {
2095
- return json(spec.output.parse(await invoke$1(options.handlers, spec, parsed.data, context)));
2604
+ return json(spec.output.parse(await invoke(options.handlers, spec, parsed.data, context)));
2096
2605
  } catch (error) {
2097
2606
  if (error instanceof HollwareError) return json({
2098
2607
  code: error.code,
@@ -2129,6 +2638,31 @@ const STATUS = {
2129
2638
  unknown_collection: 404,
2130
2639
  unknown_locale: 404
2131
2640
  };
2641
+ /**
2642
+ * A route of the read API whose `HollwareError` (an unknown collection, a limit out of range) is
2643
+ * the caller's to read, with its status. It is caught here, in the route: Hono 5 hands an error
2644
+ * that a route throws to the app's error handler before any middleware around it sees it, and
2645
+ * that handler answers every error with the same 500. The answer says how long the read took on
2646
+ * the server, after the token was checked (`Server-Timing: read;dur=<ms>`), so that a client can
2647
+ * tell the server's time from the network's.
2648
+ */
2649
+ function readRoute(handler) {
2650
+ return async (c) => {
2651
+ const started = Date.now();
2652
+ try {
2653
+ const response = await handler(c);
2654
+ response.headers.set("server-timing", `read;dur=${Date.now() - started}`);
2655
+ return response;
2656
+ } catch (error) {
2657
+ if (!(error instanceof HollwareError)) throw error;
2658
+ return c.json({
2659
+ code: error.code,
2660
+ message: error.message,
2661
+ fix: error.fix
2662
+ }, STATUS[error.code] ?? 500);
2663
+ }
2664
+ };
2665
+ }
2132
2666
  /** A weak ETag of a body: the first 128 bits of its SHA-256, in hex. */
2133
2667
  async function etagOf(body) {
2134
2668
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body)));
@@ -2194,19 +2728,8 @@ function installReadApi(app, options) {
2194
2728
  vary: "Origin",
2195
2729
  "access-control-allow-origin": new URL(c.env.HOLLWARE_PUBLIC_URL).origin
2196
2730
  });
2197
- const answered = await (async () => {
2198
- try {
2199
- await next();
2200
- return c.res;
2201
- } catch (error) {
2202
- if (!(error instanceof HollwareError)) throw error;
2203
- return c.json({
2204
- code: error.code,
2205
- message: error.message,
2206
- fix: error.fix
2207
- }, STATUS[error.code] ?? 500);
2208
- }
2209
- })();
2731
+ await next();
2732
+ const answered = c.res;
2210
2733
  if (crossOrigin === null) return answered;
2211
2734
  const shared = new Response(answered.body, answered);
2212
2735
  shared.headers.set("access-control-allow-origin", crossOrigin);
@@ -2217,8 +2740,18 @@ function installReadApi(app, options) {
2217
2740
  return c.res;
2218
2741
  });
2219
2742
  app.get("/v1/openapi.json", (c) => c.json(openApiDocument()));
2220
- app.get("/v1/site", async (c) => answer(c, await options.read(c.env).describeSite(c.get("site"))));
2221
- app.get("/v1/:collection", async (c) => {
2743
+ app.get("/v1/site", readRoute(async (c) => answer(c, await options.read(c.env).describeSite(c.get("site")))));
2744
+ app.get("/v1/search", readRoute(async (c) => {
2745
+ const query = c.req.query();
2746
+ const limit = query["limit"] === void 0 ? void 0 : Number(query["limit"]);
2747
+ return answer(c, await options.read(c.env).search(c.get("site"), query["q"] ?? "", {
2748
+ ...query["collection"] === void 0 ? {} : { collection: query["collection"] },
2749
+ ...query["locale"] === void 0 ? {} : { locale: query["locale"] },
2750
+ ...limit === void 0 ? {} : { limit },
2751
+ ...query["mode"] === void 0 ? {} : { mode: searchModeOf(query["mode"]) }
2752
+ }));
2753
+ }));
2754
+ app.get("/v1/:collection", readRoute(async (c) => {
2222
2755
  const query = c.req.query();
2223
2756
  const limit = query["limit"] === void 0 ? void 0 : Number(query["limit"]);
2224
2757
  return answer(c, await options.read(c.env).listEntries(c.get("site"), c.req.param("collection"), {
@@ -2227,12 +2760,27 @@ function installReadApi(app, options) {
2227
2760
  ...limit === void 0 ? {} : { limit },
2228
2761
  ...query["cursor"] === void 0 ? {} : { cursor: query["cursor"] }
2229
2762
  }));
2230
- });
2231
- app.get("/v1/:collection/translations/:group", async (c) => answer(c, await options.read(c.env).getTranslations(c.get("site"), c.req.param("collection"), c.req.param("group"))));
2232
- app.get("/v1/:collection/:locale/:slug", async (c) => {
2763
+ }));
2764
+ app.get("/v1/:collection/translations/:group", readRoute(async (c) => answer(c, await options.read(c.env).getTranslations(c.get("site"), c.req.param("collection"), c.req.param("group")))));
2765
+ app.get("/v1/:collection/:locale/:slug", readRoute(async (c) => {
2233
2766
  const formats = c.req.query("formats");
2234
- return answer(c, await options.read(c.env).getEntry(c.get("site"), c.req.param("collection"), c.req.param("slug"), c.req.param("locale"), formats === void 0 ? {} : { formats: formats.split(",").flatMap((format) => format === "markdown" || format === "document" || format === "html" ? [format] : []) }));
2235
- });
2767
+ const related = c.req.query("related") === "true" ? { related: true } : {};
2768
+ return answer(c, await options.read(c.env).getEntry(c.get("site"), c.req.param("collection"), c.req.param("slug"), c.req.param("locale"), formats === void 0 ? related : {
2769
+ ...related,
2770
+ formats: formats.split(",").flatMap((format) => format === "markdown" || format === "document" || format === "html" ? [format] : [])
2771
+ }));
2772
+ }));
2773
+ }
2774
+ const SEARCH_MODES = [
2775
+ "fulltext",
2776
+ "semantic",
2777
+ "hybrid"
2778
+ ];
2779
+ /** The `mode` of a search from the query string, or the error that names the ones there are. */
2780
+ function searchModeOf(value) {
2781
+ const mode = SEARCH_MODES.find((known) => known === value);
2782
+ if (mode === void 0) throw new HollwareError("invalid_input", `The search mode ${value} is not one Hollware knows.`, `Pass mode as one of ${SEARCH_MODES.join(", ")}, or leave it out for hybrid.`);
2783
+ return mode;
2236
2784
  }
2237
2785
  /** The OpenAPI 3.1 document of the read API. */
2238
2786
  function openApiDocument() {
@@ -2271,6 +2819,20 @@ function openApiDocument() {
2271
2819
  ...common
2272
2820
  }
2273
2821
  } },
2822
+ "/v1/search": { get: {
2823
+ operationId: "search",
2824
+ parameters: [
2825
+ param("q", "query", true),
2826
+ param("collection", "query", false),
2827
+ param("locale", "query", false),
2828
+ param("limit", "query", false),
2829
+ param("mode", "query", false)
2830
+ ],
2831
+ responses: {
2832
+ "200": ok("The published entries that match q, best first"),
2833
+ ...common
2834
+ }
2835
+ } },
2274
2836
  "/v1/{collection}": { get: {
2275
2837
  operationId: "list_entries",
2276
2838
  parameters: [
@@ -2300,7 +2862,8 @@ function openApiDocument() {
2300
2862
  param("collection", "path", true),
2301
2863
  param("locale", "path", true),
2302
2864
  param("slug", "path", true),
2303
- param("formats", "query", false)
2865
+ param("formats", "query", false),
2866
+ param("related", "query", false)
2304
2867
  ],
2305
2868
  responses: {
2306
2869
  "200": ok("A published entry"),
@@ -2495,7 +3058,7 @@ function plainPageHeaders() {
2495
3058
  * The ceremonies, with SimpleWebAuthn and its parsers, in a chunk of their own: only these four
2496
3059
  * routes use them, and a Worker that serves MCP never pays for them at startup (`budgets.json`).
2497
3060
  */
2498
- const ceremonies = () => import("./ceremonies-V_1BbmZQ.mjs");
3061
+ const ceremonies = () => import("./ceremonies-6mZ3u32c.mjs");
2499
3062
  /** The cookie of a signed-in person: only `/authorize` reads it (ADR-026). */
2500
3063
  const SESSION_COOKIE = "hollware_session";
2501
3064
  function sessionCookie(secret) {
@@ -2888,6 +3451,66 @@ function build(issuer, options) {
2888
3451
  };
2889
3452
  }
2890
3453
  //#endregion
3454
+ //#region src/http/events.ts
3455
+ /** The largest beacon the route reads: a site key and a path, with room to spare. */
3456
+ const MAX_BEACON_BYTES = 2048;
3457
+ /** The origins whose pages may count views of a site: its own URL and its `cors.origins`. */
3458
+ function pageOriginsOf(site) {
3459
+ return [...site.url === null ? [] : [new URL(site.url).origin], ...site.cors.origins];
3460
+ }
3461
+ function refusal(status, code, message, fix) {
3462
+ return Response.json({
3463
+ code,
3464
+ message,
3465
+ fix
3466
+ }, {
3467
+ status,
3468
+ headers: { "cache-control": "no-store" }
3469
+ });
3470
+ }
3471
+ /** The path of a view without its query and fragment, or `null` when it is no path. */
3472
+ function pathOf(value) {
3473
+ if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return null;
3474
+ const { pathname } = new URL(value, "https://hollware.invalid");
3475
+ return pathname.length > 512 ? null : pathname;
3476
+ }
3477
+ /**
3478
+ * `POST /events`, the beacon of the sites (ADR-031): `{ "site": "<key>", "path": "/<path>" }`, sent
3479
+ * by a page with `navigator.sendBeacon`, so with no token. It counts a view of the path when the
3480
+ * page is of one of the site's origins (its URL or its `cors.origins`), and keeps the site, the
3481
+ * path and the country Cloudflare gives the request: never a cookie, an IP or a user agent. It
3482
+ * answers 204 whether or not the Worker has an `ANALYTICS` binding, so that a page cannot tell.
3483
+ */
3484
+ function installEvents(app, options) {
3485
+ app.post("/events", async (c) => {
3486
+ const text = await c.req.text();
3487
+ if (new TextEncoder().encode(text).byteLength > 2048) return refusal(413, "too_large", `A beacon is at most ${MAX_BEACON_BYTES} bytes.`, "Send the site and the path of the page, and nothing else.");
3488
+ let body;
3489
+ try {
3490
+ body = JSON.parse(text);
3491
+ } catch {
3492
+ body = null;
3493
+ }
3494
+ const key = typeof body === "object" && body !== null && "site" in body ? body.site : null;
3495
+ const site = typeof key === "string" ? options.sites.get(key) : void 0;
3496
+ const path = typeof body === "object" && body !== null && "path" in body ? pathOf(body.path) : null;
3497
+ if (site === void 0 || path === null) return refusal(400, "invalid_input", "A beacon is JSON with the key of a site this Hollware serves and the path of a page.", "Send { \"site\": \"<site key>\", \"path\": \"/<path of the page>\" }.");
3498
+ const origin = c.req.header("origin") ?? null;
3499
+ if (origin === null || !pageOriginsOf(site).includes(origin)) return refusal(403, "forbidden_origin", `Only pages of the site "${site.key}" count its views.`, `Set url of the site in hollware.config.ts to the origin of its pages, or add the origin to cors.origins.`);
3500
+ const cf = Reflect.get(c.req.raw, "cf");
3501
+ const country = typeof cf === "object" && cf !== null && "country" in cf && typeof cf.country === "string" ? cf.country : "XX";
3502
+ analyticsWriterOf(c.env)?.record({
3503
+ site: site.key,
3504
+ path,
3505
+ country
3506
+ });
3507
+ return new Response(null, {
3508
+ status: 204,
3509
+ headers: { "cache-control": "no-store" }
3510
+ });
3511
+ });
3512
+ }
3513
+ //#endregion
2891
3514
  //#region src/http/gateway.ts
2892
3515
  /**
2893
3516
  * A 401 of a resource server, with Hollware's body: the same JSON as every other refusal, and the
@@ -3006,6 +3629,7 @@ function createGateway(options) {
3006
3629
  */
3007
3630
  const readsFromAnyCorsOrigin = (request, origin) => {
3008
3631
  const { pathname } = new URL(request.url);
3632
+ if (request.method === "POST" && pathname === "/events") return true;
3009
3633
  return corsOrigins.has(origin) && [
3010
3634
  "GET",
3011
3635
  "HEAD",
@@ -3047,7 +3671,9 @@ function createGateway(options) {
3047
3671
  site,
3048
3672
  store: createD1Store(db, { clock }),
3049
3673
  clock,
3050
- notify: notify(c, requestId, site.key)
3674
+ notify: notify(c, requestId, site.key),
3675
+ semantic: semanticOfEnv(c.env),
3676
+ analytics: analyticsReaderOf(c.env)
3051
3677
  }
3052
3678
  });
3053
3679
  }));
@@ -3090,6 +3716,7 @@ function createGateway(options) {
3090
3716
  protect: (c, proceed) => protect(c, "v1", proceed),
3091
3717
  metadataUrl: (c) => oauth(c.env.HOLLWARE_PUBLIC_URL).metadataUrls.v1
3092
3718
  });
3719
+ installEvents(app, { sites });
3093
3720
  installErrorHandling(app, log);
3094
3721
  return async (request, env, ctx) => {
3095
3722
  contexts.set(request, ctx);
@@ -3278,6 +3905,8 @@ const encoder = new TextEncoder();
3278
3905
  */
3279
3906
  const OUTPUT_TOO_LARGE_FIX = {
3280
3907
  approve_review: "The operation ran and its change may have been applied: read the review with list_reviews before you repeat it.",
3908
+ comment_review: "The operation ran and its comment may have been added: read the review with list_reviews before you repeat it.",
3909
+ content_stats: "Ask for less: pass a smaller limit.",
3281
3910
  create_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
3282
3911
  describe_site: "The description of the site does not fit in one answer. Document fewer collections, components and contracts, or shorten their descriptions.",
3283
3912
  get_entry: "Ask for one format at a time with formats: markdown, document or html, instead of several together.",
@@ -3286,6 +3915,8 @@ const OUTPUT_TOO_LARGE_FIX = {
3286
3915
  list_reviews: "Ask for less: pass a smaller limit.",
3287
3916
  publish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
3288
3917
  request_changes: "The operation ran and its change may have been applied: read the review with list_reviews before you repeat it.",
3918
+ request_review: "The operation ran and its review may have been asked: read it with list_reviews before you repeat it.",
3919
+ search: "Ask for less: pass a smaller limit.",
3289
3920
  unpublish: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
3290
3921
  update_draft: "The operation ran and its change may have been applied: read the entry with get_entry before you repeat it.",
3291
3922
  validate: "The verdict lists more problems than fit in one answer. Fix the first ones and validate again."
@@ -3426,6 +4057,33 @@ function createToolTable(operations = OPERATIONS) {
3426
4057
  });
3427
4058
  }
3428
4059
  //#endregion
4060
+ //#region src/mcp/prompts.ts
4061
+ /**
4062
+ * The prompts of the MCP endpoint: workflows that an agent's user picks by name, written once
4063
+ * here. A prompt only says what to do; every step is a tool, checked as any call is.
4064
+ */
4065
+ const REFRESH_CONTENT = {
4066
+ name: "refresh_content",
4067
+ title: "Refresh what readers read",
4068
+ description: "Pick a much-read entry whose content has aged, find what the site already says on its subject, and update it through the contracts, ready to publish.",
4069
+ args: z.object({ days: z.string().regex(/^[1-9][0-9]?$/).optional().describe("How many days of reading to look at, 1 to 90; 30 when not given.") })
4070
+ };
4071
+ /** The text of `refresh_content`: the steps, each with the tool that takes it. */
4072
+ function refreshContentText(days) {
4073
+ return [
4074
+ `Refresh the content that readers read most, from the last ${days} days.`,
4075
+ "",
4076
+ `1. Call content_stats with days: ${days}. Pick an entry from the pages with the most views: prefer one whose updatedAt is oldest, or whose subject has moved on. Skip pages with no entry.`,
4077
+ "2. Read it with get_entry, with related: true, and read describe_site for its collection: its schema, contracts and style guide.",
4078
+ "3. Call search with the subject of the entry in your own words (mode hybrid) to find what the site already says, so that the update links to it instead of repeating it.",
4079
+ "4. Write the update: correct what is out of date, add what is missing, link the related entries where they help. Keep the voice of the style guide.",
4080
+ "5. Save it with update_draft at the version you read. Fix every error of the verdict, and the warnings you can, saving again; validate checks a version without saving it.",
4081
+ "6. Call publish. Without content:publish it asks for a review: say so, with the link it answers. With warnings left, say which and why before you acknowledge them.",
4082
+ "",
4083
+ "Report the entry you picked and why, what you changed, and how the publish ended."
4084
+ ].join("\n");
4085
+ }
4086
+ //#endregion
3429
4087
  //#region src/mcp/endpoint.ts
3430
4088
  /**
3431
4089
  * `tools/list` is the same for every client and changes only when the Worker is deployed again:
@@ -3453,15 +4111,6 @@ function authInfoOf(principal, resourceMetadataUrl) {
3453
4111
  };
3454
4112
  }
3455
4113
  /**
3456
- * Calls the handler of an operation. This is the one place where the typed pairing of specs and
3457
- * handlers meets the dynamic protocol: the SDK parsed `input` with the input schema of `spec`,
3458
- * which is the schema that types the handler of the same name.
3459
- */
3460
- function invoke(handlers, spec, input, context) {
3461
- const handler = handlers[spec.name];
3462
- return handler(input, context);
3463
- }
3464
- /**
3465
4114
  * The MCP endpoint of Hollware: one tool for each operation, served in both protocol eras, for
3466
4115
  * the gateway to mount at `/mcp` after it has checked the Host, the Origin and the token.
3467
4116
  *
@@ -3547,7 +4196,10 @@ function createMcpEndpoint(options) {
3547
4196
  const call = request.authInfo === void 0 ? void 0 : calls.get(request.authInfo);
3548
4197
  if (call === void 0) throw new Error("A request reached the MCP server without the call the gateway made for it");
3549
4198
  const server = new McpServer(options.serverInfo, {
3550
- capabilities: { tools: { listChanged: false } },
4199
+ capabilities: {
4200
+ tools: { listChanged: false },
4201
+ prompts: { listChanged: false }
4202
+ },
3551
4203
  cacheHints: { "tools/list": TOOLS_LIST_CACHE_HINT }
3552
4204
  });
3553
4205
  for (const tool of tools) server.registerTool(tool.spec.name, {
@@ -3567,6 +4219,17 @@ function createMcpEndpoint(options) {
3567
4219
  detail: null
3568
4220
  }))
3569
4221
  }, (input, mcp) => runTool(tool.spec, input, mcp, call, request.era));
4222
+ server.registerPrompt(REFRESH_CONTENT.name, {
4223
+ title: REFRESH_CONTENT.title,
4224
+ description: REFRESH_CONTENT.description,
4225
+ argsSchema: REFRESH_CONTENT.args
4226
+ }, ({ days }) => ({ messages: [{
4227
+ role: "user",
4228
+ content: {
4229
+ type: "text",
4230
+ text: refreshContentText(days === void 0 ? 30 : Number(days))
4231
+ }
4232
+ }] }));
3570
4233
  return server;
3571
4234
  };
3572
4235
  const handler = createMcpHandler(buildServer, { onerror: (error) => emit({
@@ -3666,7 +4329,8 @@ async function run(deps, env, method, site, schema, build, handler) {
3666
4329
  store: createD1Store(env.DB.withSession("first-unconstrained")),
3667
4330
  clock: deps.clock,
3668
4331
  client: null,
3669
- requestId
4332
+ requestId,
4333
+ semantic: semanticOfEnv(env)
3670
4334
  };
3671
4335
  try {
3672
4336
  return await handler(input, context);
@@ -3686,7 +4350,7 @@ function createRpc(deps, env) {
3686
4350
  collection,
3687
4351
  locale,
3688
4352
  slug,
3689
- ...readOptions(options, ["formats"])
4353
+ ...readOptions(options, ["formats", "related"])
3690
4354
  }), deps.handlers.get_entry),
3691
4355
  listEntries: (site, collection, options) => run(deps, env, "listEntries", site, listEntries.input, () => ({
3692
4356
  collection,
@@ -3701,7 +4365,16 @@ function createRpc(deps, env) {
3701
4365
  collection,
3702
4366
  translationGroup
3703
4367
  }), deps.handlers.get_translations),
3704
- describeSite: (site) => run(deps, env, "describeSite", site, describeSite.input, () => ({}), deps.handlers.describe_site)
4368
+ describeSite: (site) => run(deps, env, "describeSite", site, describeSite.input, () => ({}), deps.handlers.describe_site),
4369
+ search: (site, query, options) => run(deps, env, "search", site, search.input, () => ({
4370
+ query,
4371
+ ...readOptions(options, [
4372
+ "collection",
4373
+ "locale",
4374
+ "limit",
4375
+ "mode"
4376
+ ])
4377
+ }), deps.handlers.search)
3705
4378
  };
3706
4379
  }
3707
4380
  //#endregion
@@ -3730,6 +4403,9 @@ function createEntrypoint(deps) {
3730
4403
  describeSite(...args) {
3731
4404
  return createRpc(deps, this.env).describeSite(...args);
3732
4405
  }
4406
+ search(...args) {
4407
+ return createRpc(deps, this.env).search(...args);
4408
+ }
3733
4409
  };
3734
4410
  }
3735
4411
  //#endregion
@@ -3813,6 +4489,10 @@ async function runDueSchedules(input) {
3813
4489
  //#endregion
3814
4490
  //#region src/compose.ts
3815
4491
  const systemClock = { now: () => /* @__PURE__ */ new Date() };
4492
+ /** Whether the run of the cron at `time` wakes the agents: on the minutes that divide by ten. */
4493
+ function wakesAgents(time) {
4494
+ return Math.floor(time / 6e4) % 10 === 0;
4495
+ }
3816
4496
  /** What the MCP endpoint calls the server, in `server/discover` and `initialize`. */
3817
4497
  const SERVER_INFO = {
3818
4498
  name: "hollware",
@@ -3859,7 +4539,8 @@ function createHollwareWith(config, handlers, options = {}) {
3859
4539
  secret: (name) => {
3860
4540
  const value = Reflect.get(env, name);
3861
4541
  return typeof value === "string" ? value : void 0;
3862
- }
4542
+ },
4543
+ semantic: semanticOfEnv(env)
3863
4544
  });
3864
4545
  return {
3865
4546
  fetch: createGateway({
@@ -3876,7 +4557,7 @@ function createHollwareWith(config, handlers, options = {}) {
3876
4557
  auth: resolved.auth,
3877
4558
  handlers
3878
4559
  }),
3879
- scheduled: async (_controller, env, ctx) => {
4560
+ scheduled: async (controller, env, ctx) => {
3880
4561
  const each = async (site) => {
3881
4562
  await runDueSchedules({
3882
4563
  site,
@@ -3889,7 +4570,27 @@ function createHollwareWith(config, handlers, options = {}) {
3889
4570
  queue: env.EVENTS,
3890
4571
  deps: deliveryDeps(env, site)
3891
4572
  });
4573
+ const semantic = semanticOfEnv(env);
4574
+ if (semantic !== null) {
4575
+ const now = clock.now().toISOString();
4576
+ await healVectors(connect(env.DB), semantic, {
4577
+ site: site.key,
4578
+ now
4579
+ });
4580
+ await refreshRelated(connect(env.DB), semantic, {
4581
+ site: site.key,
4582
+ now
4583
+ });
4584
+ }
3892
4585
  };
4586
+ const agents = env.AGENTS;
4587
+ const run = typeof agents === "object" && agents !== null ? Reflect.get(agents, "run") : void 0;
4588
+ if (typeof run === "function" && wakesAgents(controller.scheduledTime)) ctx.waitUntil(Promise.resolve(Reflect.apply(run, agents, [])).catch((error) => {
4589
+ writeLog({
4590
+ kind: "agents_failed",
4591
+ message: String(error)
4592
+ });
4593
+ }));
3893
4594
  ctx.waitUntil((async () => {
3894
4595
  for (const site of sites.values()) await each(site).catch((error) => {
3895
4596
  writeLog({
@@ -3915,53 +4616,6 @@ function createHollwareWith(config, handlers, options = {}) {
3915
4616
  };
3916
4617
  }
3917
4618
  //#endregion
3918
- //#region src/ops/errors.ts
3919
- /**
3920
- * The error of something that must never happen: a bug of Hollware, not a mistake of the caller.
3921
- * Like the `internal` error of the adapters, its message says nothing about the cause, which goes
3922
- * to the log through `cause`, and its fix names the request id the operator finds the log line by.
3923
- */
3924
- function internalError(context, cause) {
3925
- return new HollwareError("internal", "The operation failed on the server.", `Try again. If it keeps failing, give the site operator this request id: ${context.requestId}.`, { cause: new Error(cause) });
3926
- }
3927
- /**
3928
- * The error for an answer of the store that the call it came from can never get: an entry that
3929
- * is created cannot conflict, a publish has no slug to take. It is a bug, not an outcome of the
3930
- * operation, so it is not one of the outcomes of its spec.
3931
- */
3932
- function unexpectedAnswer(context, call, answer) {
3933
- return internalError(context, `The store answered ${answer.outcome} to ${call}, which it never does.`);
3934
- }
3935
- //#endregion
3936
- //#region src/ops/actor.ts
3937
- /**
3938
- * Who a write is made by: the person behind the token of the call, with what its client said
3939
- * about itself. A personal token writes as itself (ADR-019); an OAuth access token writes as the
3940
- * client the person allowed, whose id goes with what the client said (ADR-026). The adapters check
3941
- * the scopes of the operation before any handler runs and never route a write for the RPC
3942
- * binding, so reaching one with it is a bug, answered as `internal` before anything is read or
3943
- * written.
3944
- */
3945
- function actorOf(context) {
3946
- const { principal } = context;
3947
- if (principal.kind === "token") return {
3948
- kind: "token",
3949
- userId: principal.userId,
3950
- tokenId: principal.tokenId,
3951
- meta: context.client
3952
- };
3953
- if (principal.kind === "oauth") return {
3954
- kind: "oauth_client",
3955
- userId: principal.userId,
3956
- tokenId: null,
3957
- meta: {
3958
- ...context.client,
3959
- oauthClient: principal.clientId
3960
- }
3961
- };
3962
- throw internalError(context, `A write was routed for a principal of kind ${principal.kind}; only a person writes.`);
3963
- }
3964
- //#endregion
3965
4619
  //#region src/ops/answers.ts
3966
4620
  /** A review as an output. */
3967
4621
  function reviewView(review) {
@@ -4062,6 +4716,117 @@ function publicationView(publication) {
4062
4716
  at: publication.createdAt
4063
4717
  };
4064
4718
  }
4719
+ /** A comment on a review as the operations answer it; `client` is the name its client gave. */
4720
+ function reviewCommentView(comment) {
4721
+ const name = comment.authorMeta?.["name"];
4722
+ return {
4723
+ id: comment.id,
4724
+ author: comment.authorId,
4725
+ authorName: comment.authorName,
4726
+ client: typeof name === "string" ? name : null,
4727
+ body: comment.body,
4728
+ at: comment.createdAt
4729
+ };
4730
+ }
4731
+ //#endregion
4732
+ //#region src/ops/content-stats.ts
4733
+ /** A path without its trailing slash, so that `/blog/a` and `/blog/a/` are one page. */
4734
+ function trimmed(path) {
4735
+ return path.length > 1 ? path.replace(/\/+$/, "") : path;
4736
+ }
4737
+ /**
4738
+ * The entry that a path of the site shows, by the routes of its collections: the slug is the part
4739
+ * of the path where the route has `:slug`. `null` for a path that no route has.
4740
+ */
4741
+ function entryRefOf(site, path) {
4742
+ const wanted = trimmed(path);
4743
+ for (const [collection, resolved] of site.collections) for (const [locale, route] of Object.entries(resolved.routes)) {
4744
+ const [before, after, ...rest] = trimmed(route).split(":slug");
4745
+ if (before === void 0 || after === void 0 || rest.length > 0) continue;
4746
+ if (!wanted.startsWith(before) || !wanted.endsWith(after)) continue;
4747
+ const slug = wanted.slice(before.length, wanted.length - after.length);
4748
+ if (slug !== "" && !slug.includes("/")) return {
4749
+ collection,
4750
+ locale,
4751
+ slug
4752
+ };
4753
+ }
4754
+ return null;
4755
+ }
4756
+ /**
4757
+ * The most read pages of the site, from the counts of its beacons (ADR-031), each with the
4758
+ * published entry it shows. `unavailable` when the deployment cannot read the counts.
4759
+ */
4760
+ const contentStats = async (input, context) => {
4761
+ const analytics = context.analytics ?? null;
4762
+ if (analytics === null) return { outcome: "unavailable" };
4763
+ const site = context.site;
4764
+ const counts = await analytics.topPages(site.key, {
4765
+ days: input.days,
4766
+ limit: input.limit
4767
+ });
4768
+ const pages = await Promise.all(counts.map(async (count) => {
4769
+ const ref = entryRefOf(site, count.path);
4770
+ const found = ref === null ? null : await context.store.findEntry(site.key, ref, "published");
4771
+ return {
4772
+ path: count.path,
4773
+ views: count.views,
4774
+ entry: found === null ? null : entrySummary(found)
4775
+ };
4776
+ }));
4777
+ return {
4778
+ outcome: "ok",
4779
+ since: (/* @__PURE__ */ new Date(context.clock.now().getTime() - input.days * 864e5)).toISOString(),
4780
+ pages
4781
+ };
4782
+ };
4783
+ //#endregion
4784
+ //#region src/ops/errors.ts
4785
+ /**
4786
+ * The error of something that must never happen: a bug of Hollware, not a mistake of the caller.
4787
+ * Like the `internal` error of the adapters, its message says nothing about the cause, which goes
4788
+ * to the log through `cause`, and its fix names the request id the operator finds the log line by.
4789
+ */
4790
+ function internalError(context, cause) {
4791
+ return new HollwareError("internal", "The operation failed on the server.", `Try again. If it keeps failing, give the site operator this request id: ${context.requestId}.`, { cause: new Error(cause) });
4792
+ }
4793
+ /**
4794
+ * The error for an answer of the store that the call it came from can never get: an entry that
4795
+ * is created cannot conflict, a publish has no slug to take. It is a bug, not an outcome of the
4796
+ * operation, so it is not one of the outcomes of its spec.
4797
+ */
4798
+ function unexpectedAnswer(context, call, answer) {
4799
+ return internalError(context, `The store answered ${answer.outcome} to ${call}, which it never does.`);
4800
+ }
4801
+ //#endregion
4802
+ //#region src/ops/actor.ts
4803
+ /**
4804
+ * Who a write is made by: the person behind the token of the call, with what its client said
4805
+ * about itself. A personal token writes as itself (ADR-019); an OAuth access token writes as the
4806
+ * client the person allowed, whose id goes with what the client said (ADR-026). The adapters check
4807
+ * the scopes of the operation before any handler runs and never route a write for the RPC
4808
+ * binding, so reaching one with it is a bug, answered as `internal` before anything is read or
4809
+ * written.
4810
+ */
4811
+ function actorOf(context) {
4812
+ const { principal } = context;
4813
+ if (principal.kind === "token") return {
4814
+ kind: "token",
4815
+ userId: principal.userId,
4816
+ tokenId: principal.tokenId,
4817
+ meta: context.client
4818
+ };
4819
+ if (principal.kind === "oauth") return {
4820
+ kind: "oauth_client",
4821
+ userId: principal.userId,
4822
+ tokenId: null,
4823
+ meta: {
4824
+ ...context.client,
4825
+ oauthClient: principal.clientId
4826
+ }
4827
+ };
4828
+ throw internalError(context, `A write was routed for a principal of kind ${principal.kind}; only a person writes.`);
4829
+ }
4065
4830
  //#endregion
4066
4831
  //#region src/ops/collections.ts
4067
4832
  /**
@@ -4261,6 +5026,7 @@ const getEntry$1 = async (input, context) => {
4261
5026
  if (found === null) return { outcome: "not_found" };
4262
5027
  const { entry, revision } = found;
4263
5028
  const publication = await context.store.lastPublication(context.site.key, entry.id);
5029
+ const related = input.related ? await relatedEntries(context, entry.id) : void 0;
4264
5030
  return {
4265
5031
  outcome: "ok",
4266
5032
  entry: {
@@ -4271,9 +5037,19 @@ const getEntry$1 = async (input, context) => {
4271
5037
  ...input.formats.includes("markdown") ? { markdown: revision.markdown } : {},
4272
5038
  ...input.formats.includes("document") ? { document: revision.document } : {},
4273
5039
  ...input.formats.includes("html") ? { html: await renderHtml(revision.document) } : {}
4274
- }
5040
+ },
5041
+ ...related === void 0 ? {} : { related }
4275
5042
  };
4276
5043
  };
5044
+ /**
5045
+ * The published entries closest to one, as its vectors found them when it was indexed: none when
5046
+ * the deployment has no semantic search, and none that is no longer published.
5047
+ */
5048
+ async function relatedEntries(context, entryId) {
5049
+ if ((context.semantic ?? null) === null) return [];
5050
+ const related = await context.store.relatedOf(context.site.key, entryId);
5051
+ return (await context.store.findSummaries(context.site.key, related.map((item) => item.id), "published")).map(entrySummary);
5052
+ }
4277
5053
  //#endregion
4278
5054
  //#region src/ops/get-translations.ts
4279
5055
  /** Reads the entries of a translation group in the view of the call, one per locale. */
@@ -4308,6 +5084,93 @@ const listEntries$1 = async (input, context) => {
4308
5084
  nextCursor: page.nextCursor
4309
5085
  };
4310
5086
  };
5087
+ /** The published entries closest to the meaning of the query, closest first. */
5088
+ async function semanticSearch(semantic, store, site, query) {
5089
+ const [vector] = await semantic.embedder.embed([query.text]);
5090
+ const matches = await semantic.index.query(vector ?? [], {
5091
+ topK: 20,
5092
+ site,
5093
+ ...query.collection === void 0 ? {} : { collection: query.collection },
5094
+ ...query.locale === void 0 ? {} : { locale: query.locale }
5095
+ });
5096
+ const best = /* @__PURE__ */ new Map();
5097
+ for (const match of matches) {
5098
+ const kept = best.get(match.metadata.entry);
5099
+ if (kept === void 0 || match.score > kept.score) best.set(match.metadata.entry, match);
5100
+ }
5101
+ const ranked = [...best.values()].sort((a, b) => b.score - a.score).slice(0, query.limit);
5102
+ const summaries = await store.findSummaries(site, ranked.map((match) => match.metadata.entry), "published");
5103
+ const byId = new Map(summaries.map((summary) => [summary.entry.id, summary]));
5104
+ return ranked.flatMap((match) => {
5105
+ const entry = byId.get(match.metadata.entry);
5106
+ return entry === void 0 ? [] : [{
5107
+ entry,
5108
+ snippet: match.metadata.excerpt,
5109
+ score: match.score
5110
+ }];
5111
+ });
5112
+ }
5113
+ /**
5114
+ * Merges rankings by reciprocal rank fusion: an entry scores `1 / (FUSION_K + place)` in each list
5115
+ * it is in, places counted from 1. An entry keeps the snippet of the first list that has it, so
5116
+ * the words found by full text win over the excerpt of a match by meaning.
5117
+ */
5118
+ function fuse(lists, limit) {
5119
+ const merged = /* @__PURE__ */ new Map();
5120
+ for (const list of lists) list.forEach((hit, place) => {
5121
+ const gain = 1 / (60 + place + 1);
5122
+ const kept = merged.get(hit.entry.entry.id);
5123
+ merged.set(hit.entry.entry.id, kept === void 0 ? {
5124
+ ...hit,
5125
+ score: gain
5126
+ } : {
5127
+ ...kept,
5128
+ score: kept.score + gain
5129
+ });
5130
+ });
5131
+ return [...merged.values()].sort((a, b) => b.score - a.score).slice(0, limit);
5132
+ }
5133
+ //#endregion
5134
+ //#region src/ops/search.ts
5135
+ /**
5136
+ * The published entries of the site that match the query, best first: by its words, by meaning,
5137
+ * or both (ADR-030). Without the semantic search of the deployment, every mode is full text, and
5138
+ * the answer's `mode` says which one ran.
5139
+ */
5140
+ const search$1 = async (input, context) => {
5141
+ if (input.collection !== void 0) requireCollection(context.site, input.collection);
5142
+ if (input.locale !== void 0) requireLocale(context.site, input.locale);
5143
+ const query = {
5144
+ text: input.query,
5145
+ collection: input.collection,
5146
+ locale: input.locale,
5147
+ limit: input.limit
5148
+ };
5149
+ const semantic = context.semantic ?? null;
5150
+ const asked = input.mode ?? "hybrid";
5151
+ const mode = semantic === null ? "fulltext" : asked;
5152
+ const site = context.site.key;
5153
+ let hits;
5154
+ if (semantic === null || mode === "fulltext") hits = await context.store.searchEntries(site, query);
5155
+ else if (mode === "semantic") hits = await semanticSearch(semantic, context.store, site, query);
5156
+ else {
5157
+ const wide = {
5158
+ ...query,
5159
+ limit: Math.max(query.limit, 20)
5160
+ };
5161
+ const [words, meaning] = await Promise.all([context.store.searchEntries(site, wide), semanticSearch(semantic, context.store, site, wide)]);
5162
+ hits = fuse([words, meaning], query.limit);
5163
+ }
5164
+ return {
5165
+ outcome: "ok",
5166
+ mode,
5167
+ results: hits.map((hit) => ({
5168
+ entry: entrySummary(hit.entry),
5169
+ snippet: hit.snippet,
5170
+ score: hit.score
5171
+ }))
5172
+ };
5173
+ };
4311
5174
  //#endregion
4312
5175
  //#region src/ops/event.ts
4313
5176
  /** The path of an entry: the route of its locale, with its slug in place of `:slug`. */
@@ -4625,13 +5488,15 @@ const listReviews = async (input, context) => {
4625
5488
  status: input.status,
4626
5489
  limit: input.limit
4627
5490
  });
5491
+ const comments = await context.store.reviewComments(context.site.key, found.map((review) => review.id));
4628
5492
  const reviews = [];
4629
5493
  for (const review of found) {
4630
5494
  const view = await context.store.findEntryById(context.site.key, review.entryId, "head");
4631
5495
  if (view === null) throw unexpectedAnswer(context, "list_reviews", { outcome: "no entry" });
4632
5496
  reviews.push({
4633
5497
  ...reviewView(review),
4634
- entry: entrySummary(view)
5498
+ entry: entrySummary(view),
5499
+ comments: (comments.get(review.id) ?? []).map(reviewCommentView)
4635
5500
  });
4636
5501
  }
4637
5502
  return {
@@ -4697,6 +5562,56 @@ const requestChanges = async (input, context) => {
4697
5562
  review: reviewView(closed.review)
4698
5563
  };
4699
5564
  };
5565
+ /**
5566
+ * A comment on a pending review, in the name of the caller: for the person who decides and for
5567
+ * the author. It decides nothing; a review that is no longer pending takes none.
5568
+ */
5569
+ const commentReview = async (input, context) => {
5570
+ const answer = await context.store.commentReview(context.site.key, {
5571
+ reviewId: input.id,
5572
+ actor: actorOf(context),
5573
+ body: input.body
5574
+ });
5575
+ return answer.outcome === "commented" ? {
5576
+ outcome: "commented",
5577
+ comment: reviewCommentView(answer.comment)
5578
+ } : { outcome: answer.outcome };
5579
+ };
5580
+ /**
5581
+ * Asks for a review of the head revision of an entry through `publish`, with `content:publish`
5582
+ * taken from the caller for this call: the same checks run, and the one thing that cannot happen
5583
+ * is a publication. It is how an agent sends its work to a person, whatever its token holds.
5584
+ */
5585
+ const requestReview = async (input, context) => {
5586
+ const { principal: given } = context;
5587
+ const principal = given.kind === "binding" ? given : {
5588
+ ...given,
5589
+ scopes: given.scopes.filter((scope) => scope !== "content:publish")
5590
+ };
5591
+ const answer = await publish$1({
5592
+ id: input.id,
5593
+ version: input.version,
5594
+ acknowledgeWarnings: input.acknowledgeWarnings,
5595
+ group: []
5596
+ }, {
5597
+ ...context,
5598
+ principal
5599
+ });
5600
+ switch (answer.outcome) {
5601
+ case "pending_review":
5602
+ case "blocked":
5603
+ case "needs_acknowledgement":
5604
+ case "conflict":
5605
+ case "not_found": return {
5606
+ outcome: answer.outcome,
5607
+ ...answer.entry === void 0 ? {} : { entry: answer.entry },
5608
+ ...answer.verdict === void 0 ? {} : { verdict: answer.verdict },
5609
+ ...answer.conflict === void 0 ? {} : { conflict: answer.conflict },
5610
+ ...answer.review === void 0 ? {} : { review: answer.review }
5611
+ };
5612
+ default: throw unexpectedAnswer(context, "request_review", answer);
5613
+ }
5614
+ };
4700
5615
  //#endregion
4701
5616
  //#region src/ops/unpublish.ts
4702
5617
  /**
@@ -4842,14 +5757,18 @@ function defineHandlers(handlers) {
4842
5757
  */
4843
5758
  const handlers = defineHandlers({
4844
5759
  approve_review: approveReview,
5760
+ comment_review: commentReview,
5761
+ content_stats: contentStats,
4845
5762
  create_draft: createDraft,
4846
5763
  describe_site: describeSite$1,
4847
5764
  get_entry: getEntry$1,
4848
5765
  get_translations: getTranslations$1,
4849
5766
  list_entries: listEntries$1,
5767
+ search: search$1,
4850
5768
  list_reviews: listReviews,
4851
5769
  publish: publish$1,
4852
5770
  request_changes: requestChanges,
5771
+ request_review: requestReview,
4853
5772
  unpublish,
4854
5773
  update_draft: updateDraft,
4855
5774
  validate