@hollware/agents 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026-present Sergio Azócar and Hollware contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,31 @@
1
- # Temporary Holding Version
1
+ # @hollware/agents
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Agents for [Hollware](https://hollware.com), an open-source headless CMS for the agent era,
4
+ native to Cloudflare Workers. They run on a Worker of their own, next to your Hollware:
5
+
6
+ - **a reviewer**, which comments once on each pending review: clarity, the voice of the style
7
+ guide, claims without an example; what the contracts cannot see;
8
+ - **a translator**, which drafts the missing translation of a published entry, fixes what the
9
+ contracts refuse, and asks a person to review it;
10
+ - **a weekly auditor**, which reports the entries readers read most that have not changed in
11
+ months, and the published entries the contracts refuse now, as a draft and, if you want, to a
12
+ Slack webhook.
13
+
14
+ None of them publishes: their MCP client refuses every tool that publishes or decides, whatever
15
+ the token holds.
16
+
17
+ ```ts
18
+ import { createHollwareAgents } from '@hollware/agents'
19
+
20
+ export default createHollwareAgents({
21
+ auditor: { collection: 'reports', locale: 'en' },
22
+ })
23
+ ```
24
+
25
+ The guide is at <https://hollware.com/docs/agents/>.
26
+
27
+ ## Links
28
+
29
+ - Repository: <https://github.com/hollware/hollware>
30
+ - Issues: <https://github.com/hollware/hollware/issues>
31
+ - License: MIT
@@ -0,0 +1,259 @@
1
+ //#region src/hollware.d.ts
2
+ /**
3
+ * How the agents reach Hollware: as any agent does, over MCP, with a personal token. They call
4
+ * only the tools of `ALLOWED_TOOLS`, which read, write drafts, comment and ask for reviews; the
5
+ * tools that publish or decide (`publish`, `approve_review`, `request_changes`, `unpublish`) are
6
+ * refused here, before any request, whatever the token holds.
7
+ */
8
+ /** The tools an agent may call. */
9
+ export declare const ALLOWED_TOOLS: readonly ['comment_review', 'content_stats', 'create_draft', 'describe_site', 'get_entry', 'get_translations', 'list_entries', 'list_reviews', 'request_review', 'search', 'update_draft', 'validate'];
10
+ type AllowedTool = (typeof ALLOWED_TOOLS)[number];
11
+ /** A tool's structured output, as JSON. */
12
+ type ToolOutput = Readonly<Record<string, unknown>>;
13
+ /** Calls a tool of Hollware. */
14
+ interface Hollware {
15
+ call(tool: AllowedTool, input: Readonly<Record<string, unknown>>): Promise<ToolOutput>;
16
+ }
17
+ /** Refuses every tool outside the allowlist, so a mistake of a role can never publish. */
18
+ export declare function allowOnly(call: (tool: string, input: Readonly<Record<string, unknown>>) => Promise<ToolOutput>): Hollware;
19
+ /** What the MCP client needs: where Hollware is, how to reach it, and the token. */
20
+ interface McpOptions {
21
+ /** The public URL of Hollware: its guard answers only requests for its own host. */
22
+ readonly url: string;
23
+ readonly token: string;
24
+ /** `fetch` of a service binding to the Hollware Worker, or the global one. */
25
+ readonly fetch: (request: Request) => Promise<Response>;
26
+ }
27
+ /**
28
+ * Hollware over MCP, one JSON-RPC request per call (Hollware keeps no session). The answer may come
29
+ * as JSON or as a server-sent event; the tool's output is its `structuredContent`.
30
+ */
31
+ export declare function mcpHollware(options: McpOptions): Hollware;
32
+ //#endregion
33
+ //#region src/memory.d.ts
34
+ /** What the agents remember between runs: a few small values by key, some for a while only. */
35
+ interface Memory {
36
+ get(key: string): Promise<string | null>;
37
+ put(key: string, value: string, ttlSeconds?: number): Promise<void>;
38
+ }
39
+ interface KvBinding {
40
+ get(key: string): Promise<string | null>;
41
+ put(key: string, value: string, options?: {
42
+ expirationTtl?: number;
43
+ }): Promise<void>;
44
+ }
45
+ /** Memory in a KV namespace. KV keeps a value at least 60 seconds. */
46
+ export declare function kvMemory(kv: KvBinding): Memory;
47
+ /** Memory in a Map, for tests and for a single run. */
48
+ export declare function mapMemory(): Memory & {
49
+ readonly values: Map<string, string>;
50
+ };
51
+ //#endregion
52
+ //#region src/auditor.d.ts
53
+ /** Where the weekly report goes: a draft in a collection of the site, and a webhook if set. */
54
+ interface ReportOptions {
55
+ readonly collection: string;
56
+ readonly locale: string;
57
+ /** The frontmatter of the report of a day (YYYY-MM-DD), as the collection's schema asks. */
58
+ readonly frontmatter?: (day: string) => Readonly<Record<string, string>>;
59
+ }
60
+ interface AuditorDeps {
61
+ readonly hollware: Hollware;
62
+ /** Posts the report as `{ "text": "<markdown>" }`, as Slack's incoming webhooks take it. */
63
+ readonly webhook?: ((report: string) => Promise<void>) | undefined;
64
+ }
65
+ interface AuditOptions {
66
+ readonly report: ReportOptions;
67
+ /** The day of the report, YYYY-MM-DD. */
68
+ readonly day: string;
69
+ /** An entry read this much but not updated in these many days is out of date. */
70
+ readonly staleDays?: number;
71
+ }
72
+ interface Finding {
73
+ readonly kind: 'stale' | 'contract';
74
+ readonly where: string;
75
+ readonly what: string;
76
+ }
77
+ /** The report as Markdown, with its frontmatter. */
78
+ export declare function reportOf(day: string, findings: readonly Finding[], report: ReportOptions): string;
79
+ /** The memory's key of what the auditor has checked: where it is, and what it found by entry. */
80
+ export declare const AUDIT_STATE = "audit:state";
81
+ interface StepDeps {
82
+ readonly hollware: Hollware;
83
+ readonly memory: Memory;
84
+ }
85
+ /**
86
+ * One step of the audit: the next `perRun` published entries of the site (outside the report's
87
+ * collection), each checked against the contracts as they are now, and what was found kept in the
88
+ * memory. A step reads a dozen entries, two calls each, so that a run stays within the fifty calls
89
+ * a free Worker has; the steps go round the entries, and the report is written from them.
90
+ */
91
+ export declare function auditStep(deps: StepDeps, options: {
92
+ readonly report: ReportOptions;
93
+ readonly now: string;
94
+ readonly perRun?: number;
95
+ }): Promise<{
96
+ readonly checked: number;
97
+ }>;
98
+ /**
99
+ * The report of the audit: the most read pages whose entry has not changed in `staleDays`, and
100
+ * what the steps of the last eight days found. It is saved as a draft (never published) in the
101
+ * report's collection, its slug the day, and posted to the webhook when there is one.
102
+ */
103
+ export declare function writeReport(deps: AuditorDeps & StepDeps, options: AuditOptions): Promise<{
104
+ readonly findings: number;
105
+ }>;
106
+ /** A whole audit at once, every published entry and then the report: for a site with few entries. */
107
+ export declare function audit(deps: AuditorDeps & Partial<StepDeps>, options: AuditOptions): Promise<{
108
+ readonly findings: number;
109
+ }>;
110
+ //#endregion
111
+ //#region src/model.d.ts
112
+ /** A message of a conversation with a model. */
113
+ interface Message {
114
+ readonly role: 'system' | 'user';
115
+ readonly content: string;
116
+ }
117
+ /** A model that answers a conversation with text. */
118
+ type Model = (messages: readonly Message[]) => Promise<string>;
119
+ /** The model of Workers AI the agents use when the options name none. */
120
+ export declare const DEFAULT_MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast";
121
+ interface AiBinding {
122
+ run(model: string, input: object, options?: object): Promise<unknown>;
123
+ }
124
+ /** A model of Workers AI, through an AI Gateway when its id is given. */
125
+ export declare function workersAiModel(ai: AiBinding, model: string, options?: {
126
+ readonly gateway?: string;
127
+ readonly maxTokens?: number;
128
+ }): Model;
129
+ /** The day's cap of model calls was reached; the agents stop until the next day. */
130
+ export declare class DailyLimitReached extends Error {
131
+ constructor(limit: number);
132
+ }
133
+ /** A model that counts its calls of the day in the memory and refuses one over `limit`. */
134
+ export declare function capped(model: Model, memory: Memory, limit: number, today: () => string): Model;
135
+ //#endregion
136
+ //#region src/reviewer.d.ts
137
+ /** The name the agents' client gives: a comment of theirs carries it. */
138
+ export declare const AGENTS_CLIENT = "hollware-agents";
139
+ interface ReviewerDeps {
140
+ readonly hollware: Hollware;
141
+ readonly model: Model;
142
+ readonly memory: Memory;
143
+ }
144
+ /**
145
+ * Comments once on each pending review: what the contracts cannot see. A review that has a
146
+ * comment of the agents, or that the memory has, is left alone, so a run that fails halfway is
147
+ * picked up by the next one without commenting twice.
148
+ */
149
+ export declare function reviewPending(deps: ReviewerDeps): Promise<{
150
+ readonly commented: number;
151
+ }>;
152
+ //#endregion
153
+ //#region src/translator.d.ts
154
+ /** A slug from a title: lowercase ASCII words joined by hyphens. */
155
+ export declare function slugOf(title: string): string;
156
+ interface TranslatorDeps {
157
+ readonly hollware: Hollware;
158
+ readonly model: Model;
159
+ readonly memory: Memory;
160
+ }
161
+ /**
162
+ * For each published entry of a collection with translations whose group lacks a locale of the
163
+ * site, writes the translation as a draft, fixes what the contracts refuse up to `FIX_ROUNDS`
164
+ * times, and asks a person to review it, at most `limit` a run. It reads each locale's entries in
165
+ * one listing, so a run stays within the fifty calls a free Worker has; an entry of any status
166
+ * in a locale counts as the translation, and the memory keeps what was tried, once it was saved or
167
+ * refused: a run that stops before tries it again.
168
+ */
169
+ export declare function translateMissing(deps: TranslatorDeps, options?: {
170
+ readonly limit?: number;
171
+ }): Promise<{
172
+ readonly drafted: number;
173
+ readonly sentToReview: number;
174
+ }>;
175
+ //#endregion
176
+ //#region src/worker.d.ts
177
+ /** The bindings and variables of the Worker of the agents. */
178
+ interface AgentsEnv {
179
+ /** A service binding to the Hollware Worker; without it, the agents reach it over the network. */
180
+ readonly HOLLWARE?: {
181
+ fetch(request: Request): Promise<Response>;
182
+ };
183
+ /** The public URL of Hollware, such as https://cms.example.com. */
184
+ readonly HOLLWARE_PUBLIC_URL: string;
185
+ /** A personal token of the site, with content:read and content:write (a secret). */
186
+ readonly HOLLWARE_AGENT_TOKEN: string;
187
+ /** Workers AI, for the default model. */
188
+ readonly AI?: {
189
+ run(model: string, input: object, options?: object): Promise<unknown>;
190
+ };
191
+ /** What the agents remember: the reviews they read, the translations they tried, the day's calls. */
192
+ readonly AGENTS_KV: {
193
+ get(key: string): Promise<string | null>;
194
+ put(key: string, value: string, options?: {
195
+ expirationTtl?: number;
196
+ }): Promise<void>;
197
+ };
198
+ /** Where the weekly report is posted as well, such as a Slack incoming webhook (a secret). */
199
+ readonly AGENTS_WEBHOOK_URL?: string;
200
+ }
201
+ /** The options of `createHollwareAgents`. */
202
+ interface AgentsOptions {
203
+ /** A model of Workers AI by its id, or a model of your own; `DEFAULT_MODEL` when not given. */
204
+ readonly model?: string | Model;
205
+ /** The id of an AI Gateway for the calls to Workers AI. */
206
+ readonly gateway?: string;
207
+ /** The model calls of a day, at most; 100 by default. */
208
+ readonly dailyLimit?: number;
209
+ /** The reviewer comments on pending reviews; on by default. */
210
+ readonly reviewer?: boolean;
211
+ /** The translator drafts missing translations, at most `limit` a run (3); on by default. */
212
+ readonly translator?: boolean | {
213
+ readonly limit?: number;
214
+ };
215
+ /**
216
+ * The auditor writes a report every `everyDays` days (7), on the first trigger after; off unless
217
+ * a collection for it is given.
218
+ */
219
+ readonly auditor?: false | (ReportOptions & {
220
+ readonly everyDays?: number;
221
+ readonly staleDays?: number;
222
+ });
223
+ /** The time; the system clock when absent. */
224
+ readonly clock?: () => Date;
225
+ }
226
+ /** The roles, by turn: each run is one, so that it stays within the fifty calls of a free run. */
227
+ export declare const ROLES: readonly ['reviewer', 'translator', 'auditor'];
228
+ /** The role whose turn it is at `time`: the ten minutes of the time, in turn. */
229
+ export declare function roleAt(time: number): (typeof ROLES)[number];
230
+ /** The memory's key of the day of the last audit. */
231
+ export declare const LAST_AUDIT = "audit:last";
232
+ /** Whether an audit is due: none yet, or the last one `everyDays` or more days before `day`. */
233
+ export declare function auditDue(last: string | null, day: string, everyDays: number): boolean;
234
+ interface AgentsWorker {
235
+ /**
236
+ * One run of the agents, awaited: what a named entrypoint calls when Hollware's own cron wakes
237
+ * the agents over a service binding, so that they need no cron trigger of their own.
238
+ */
239
+ readonly run: (env: AgentsEnv) => Promise<void>;
240
+ readonly scheduled: (controller: {
241
+ readonly cron: string;
242
+ }, env: AgentsEnv, ctx: {
243
+ waitUntil(promise: Promise<unknown>): void;
244
+ }) => Promise<void>;
245
+ readonly fetch: (request: Request, env: AgentsEnv) => Promise<Response>;
246
+ }
247
+ /**
248
+ * The Worker of the agents. Hollware's cron wakes them every ten minutes over a service binding
249
+ * (`run`), or a cron trigger of their own does (`scheduled`). Each run is one role, in turn: the
250
+ * reviewer, the translator, then the auditor, which checks a dozen entries and writes its report
251
+ * when it is due. A free Worker makes fifty calls a run at most, which one role fits in. A role
252
+ * that fails is logged; reaching the day's limit of model calls ends it quietly.
253
+ */
254
+ export declare function createHollwareAgents(options?: AgentsOptions): AgentsWorker;
255
+ //#endregion
256
+ //#region src/index.d.ts
257
+ export declare const name = "@hollware/agents";
258
+ //#endregion
259
+ export type { AgentsEnv, AgentsOptions, AgentsWorker, AllowedTool, AuditOptions, Finding, Hollware, Memory, Message, Model, ReportOptions };
package/dist/index.mjs ADDED
@@ -0,0 +1,694 @@
1
+ //#region src/read.ts
2
+ function record(value) {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {};
4
+ }
5
+ function list(value) {
6
+ return Array.isArray(value) ? value.map(record) : [];
7
+ }
8
+ function text(value) {
9
+ return typeof value === "string" ? value : null;
10
+ }
11
+ function number(value) {
12
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
13
+ }
14
+ /** The entries of a collection, in a locale and status when given, a page of 100 at a time. */
15
+ async function entriesOf(hollware, query) {
16
+ const entries = [];
17
+ let cursor = null;
18
+ for (let page = 0; page < 3; page++) {
19
+ const answer = await hollware.call("list_entries", {
20
+ ...query,
21
+ limit: 100,
22
+ ...cursor === null ? {} : { cursor }
23
+ });
24
+ entries.push(...list(answer["entries"]));
25
+ cursor = text(answer["nextCursor"]);
26
+ if (cursor === null) break;
27
+ }
28
+ return entries;
29
+ }
30
+ //#endregion
31
+ //#region src/memory.ts
32
+ /** Memory in a KV namespace. KV keeps a value at least 60 seconds. */
33
+ function kvMemory(kv) {
34
+ return {
35
+ get: (key) => kv.get(key),
36
+ put: (key, value, ttlSeconds) => kv.put(key, value, ttlSeconds === void 0 ? void 0 : { expirationTtl: Math.max(60, ttlSeconds) })
37
+ };
38
+ }
39
+ /** Memory in a Map, for tests and for a single run. */
40
+ function mapMemory() {
41
+ const values = /* @__PURE__ */ new Map();
42
+ return {
43
+ values,
44
+ async get(key) {
45
+ return values.get(key) ?? null;
46
+ },
47
+ async put(key, value) {
48
+ values.set(key, value);
49
+ }
50
+ };
51
+ }
52
+ //#endregion
53
+ //#region src/auditor.ts
54
+ function yamlValue(value) {
55
+ return JSON.stringify(value);
56
+ }
57
+ /** The report as Markdown, with its frontmatter. */
58
+ function reportOf(day, findings, report) {
59
+ const frontmatter = report.frontmatter?.(day) ?? {
60
+ title: `Content audit of ${day}`,
61
+ description: `What the weekly audit of ${day} found: read but old, and failing contracts.`
62
+ };
63
+ const lines = [
64
+ "---",
65
+ ...Object.entries(frontmatter).map(([key, value]) => `${key}: ${yamlValue(value)}`),
66
+ "---",
67
+ ""
68
+ ];
69
+ lines.push(`# Content audit of ${day}`, "");
70
+ const stale = findings.filter((finding) => finding.kind === "stale");
71
+ const failing = findings.filter((finding) => finding.kind === "contract");
72
+ lines.push("## Read a lot, updated long ago", "");
73
+ lines.push(...stale.length === 0 ? ["Nothing."] : stale.map((finding) => `- ${finding.where}: ${finding.what}`), "");
74
+ lines.push("## Published, and failing the contracts now", "");
75
+ lines.push(...failing.length === 0 ? ["Nothing."] : failing.map((finding) => `- ${finding.where}: ${finding.what}`), "");
76
+ return lines.join("\n");
77
+ }
78
+ function problemsOf$1(verdict) {
79
+ return [...list(verdict["errors"]), ...list(verdict["warnings"])].map((problem) => `${String(problem["severity"])} ${String(problem["contract"])}: ${String(problem["message"])}`);
80
+ }
81
+ /** The memory's key of what the auditor has checked: where it is, and what it found by entry. */
82
+ const AUDIT_STATE = "audit:state";
83
+ function stateOf(text_) {
84
+ if (text_ === null) return {
85
+ offset: 0,
86
+ found: {}
87
+ };
88
+ try {
89
+ const parsed = record(JSON.parse(text_));
90
+ return {
91
+ offset: number(parsed["offset"]) ?? 0,
92
+ found: Object.fromEntries(Object.entries(record(parsed["found"])).map(([key, value]) => {
93
+ const one = record(value);
94
+ return [key, {
95
+ problems: Array.isArray(one["problems"]) ? one["problems"].map(String) : [],
96
+ at: text(one["at"]) ?? ""
97
+ }];
98
+ }))
99
+ };
100
+ } catch {
101
+ return {
102
+ offset: 0,
103
+ found: {}
104
+ };
105
+ }
106
+ }
107
+ /**
108
+ * One step of the audit: the next `perRun` published entries of the site (outside the report's
109
+ * collection), each checked against the contracts as they are now, and what was found kept in the
110
+ * memory. A step reads a dozen entries, two calls each, so that a run stays within the fifty calls
111
+ * a free Worker has; the steps go round the entries, and the report is written from them.
112
+ */
113
+ async function auditStep(deps, options) {
114
+ const perRun = options.perRun ?? 12;
115
+ const site = record((await deps.hollware.call("describe_site", {}))["site"]);
116
+ const refs = [];
117
+ for (const collection of list(site["collections"])) {
118
+ const name = text(collection["name"]);
119
+ if (name === null || name === options.report.collection) continue;
120
+ for (const entry of await entriesOf(deps.hollware, {
121
+ collection: name,
122
+ status: "published"
123
+ })) {
124
+ const locale = text(entry["locale"]);
125
+ const slug = text(entry["slug"]);
126
+ if (locale !== null && slug !== null) refs.push({
127
+ collection: name,
128
+ locale,
129
+ slug
130
+ });
131
+ }
132
+ }
133
+ const state = stateOf(await deps.memory.get(AUDIT_STATE));
134
+ const found = { ...state.found };
135
+ const count = Math.min(perRun, refs.length);
136
+ for (let step = 0; step < count; step++) {
137
+ const ref = refs[(state.offset + step) % refs.length];
138
+ if (ref === void 0) continue;
139
+ const markdown = text(record((await deps.hollware.call("get_entry", {
140
+ ...ref,
141
+ published: true
142
+ }))["entry"])["markdown"]);
143
+ if (markdown === null) continue;
144
+ const verdict = record((await deps.hollware.call("validate", {
145
+ ...ref,
146
+ markdown
147
+ }))["verdict"]);
148
+ found[`${ref.collection}/${ref.locale}/${ref.slug}`] = {
149
+ problems: problemsOf$1(verdict),
150
+ at: options.now
151
+ };
152
+ }
153
+ const offset = refs.length === 0 ? 0 : (state.offset + count) % refs.length;
154
+ await deps.memory.put(AUDIT_STATE, JSON.stringify({
155
+ offset,
156
+ found
157
+ }), 2592e3);
158
+ return { checked: count };
159
+ }
160
+ /**
161
+ * The report of the audit: the most read pages whose entry has not changed in `staleDays`, and
162
+ * what the steps of the last eight days found. It is saved as a draft (never published) in the
163
+ * report's collection, its slug the day, and posted to the webhook when there is one.
164
+ */
165
+ async function writeReport(deps, options) {
166
+ const staleDays = options.staleDays ?? 180;
167
+ const findings = [];
168
+ const stats = await deps.hollware.call("content_stats", {
169
+ days: 30,
170
+ limit: 20
171
+ });
172
+ const cutoff = Date.parse(options.day) - staleDays * 864e5;
173
+ for (const page of list(stats["pages"])) {
174
+ const entry = record(page["entry"]);
175
+ const updated = text(entry["updatedAt"]);
176
+ if (updated === null || Date.parse(updated) > cutoff) continue;
177
+ findings.push({
178
+ kind: "stale",
179
+ where: `${String(page["path"])} (${String(entry["collection"])}/${String(entry["locale"])}/${String(entry["slug"])})`,
180
+ what: `${number(page["views"]) ?? 0} views in 30 days, last updated ${updated.slice(0, 10)}`
181
+ });
182
+ }
183
+ const since = Date.parse(options.day) - 6912e5;
184
+ const state = stateOf(await deps.memory.get(AUDIT_STATE));
185
+ for (const [where, { problems, at }] of Object.entries(state.found).sort(([a], [b]) => a.localeCompare(b))) {
186
+ if (Date.parse(at) < since) continue;
187
+ for (const problem of problems) findings.push({
188
+ kind: "contract",
189
+ where,
190
+ what: problem
191
+ });
192
+ }
193
+ const report = reportOf(options.day, findings, options.report);
194
+ const slug = `audit-${options.day}`;
195
+ const where = {
196
+ collection: options.report.collection,
197
+ locale: options.report.locale,
198
+ slug
199
+ };
200
+ const existing = record((await deps.hollware.call("get_entry", where))["entry"]);
201
+ if (text(existing["id"]) === null) await deps.hollware.call("create_draft", {
202
+ ...where,
203
+ markdown: report
204
+ });
205
+ else await deps.hollware.call("update_draft", {
206
+ id: existing["id"],
207
+ version: existing["version"],
208
+ markdown: report
209
+ });
210
+ await deps.webhook?.(report);
211
+ return { findings: findings.length };
212
+ }
213
+ /** A whole audit at once, every published entry and then the report: for a site with few entries. */
214
+ async function audit(deps, options) {
215
+ const memory = deps.memory ?? mapMemory();
216
+ await auditStep({
217
+ hollware: deps.hollware,
218
+ memory
219
+ }, {
220
+ report: options.report,
221
+ now: `${options.day}T00:00:00.000Z`,
222
+ perRun: Number.POSITIVE_INFINITY
223
+ });
224
+ return writeReport({
225
+ ...deps,
226
+ memory
227
+ }, options);
228
+ }
229
+ //#endregion
230
+ //#region src/hollware.ts
231
+ /**
232
+ * How the agents reach Hollware: as any agent does, over MCP, with a personal token. They call
233
+ * only the tools of `ALLOWED_TOOLS`, which read, write drafts, comment and ask for reviews; the
234
+ * tools that publish or decide (`publish`, `approve_review`, `request_changes`, `unpublish`) are
235
+ * refused here, before any request, whatever the token holds.
236
+ */
237
+ /** The tools an agent may call. */
238
+ const ALLOWED_TOOLS = [
239
+ "comment_review",
240
+ "content_stats",
241
+ "create_draft",
242
+ "describe_site",
243
+ "get_entry",
244
+ "get_translations",
245
+ "list_entries",
246
+ "list_reviews",
247
+ "request_review",
248
+ "search",
249
+ "update_draft",
250
+ "validate"
251
+ ];
252
+ function isRecord$1(value) {
253
+ return typeof value === "object" && value !== null && !Array.isArray(value);
254
+ }
255
+ /** Refuses every tool outside the allowlist, so a mistake of a role can never publish. */
256
+ function allowOnly(call) {
257
+ const allowed = new Set(ALLOWED_TOOLS);
258
+ return { call(tool, input) {
259
+ if (!allowed.has(tool)) return Promise.reject(/* @__PURE__ */ new Error(`The agents never call ${tool}`));
260
+ return call(tool, input);
261
+ } };
262
+ }
263
+ /**
264
+ * Hollware over MCP, one JSON-RPC request per call (Hollware keeps no session). The answer may come
265
+ * as JSON or as a server-sent event; the tool's output is its `structuredContent`.
266
+ */
267
+ function mcpHollware(options) {
268
+ const endpoint = `${options.url.replace(/\/+$/, "")}/mcp`;
269
+ return allowOnly(async (tool, input) => {
270
+ const response = await options.fetch(new Request(endpoint, {
271
+ method: "POST",
272
+ headers: {
273
+ host: new URL(endpoint).host,
274
+ authorization: `Bearer ${options.token}`,
275
+ "content-type": "application/json",
276
+ accept: "application/json, text/event-stream",
277
+ "mcp-protocol-version": "2026-07-28",
278
+ "mcp-method": "tools/call",
279
+ "mcp-name": tool
280
+ },
281
+ body: JSON.stringify({
282
+ jsonrpc: "2.0",
283
+ id: 1,
284
+ method: "tools/call",
285
+ params: {
286
+ name: tool,
287
+ arguments: input,
288
+ _meta: {
289
+ "io.modelcontextprotocol/protocolVersion": "2026-07-28",
290
+ "io.modelcontextprotocol/clientInfo": {
291
+ name: "hollware-agents",
292
+ version: "0.6.0"
293
+ },
294
+ "io.modelcontextprotocol/clientCapabilities": {}
295
+ }
296
+ }
297
+ })
298
+ }));
299
+ const text = await response.text();
300
+ const json = text.trimStart().startsWith("{") ? text : /^data: (.*)$/m.exec(text)?.[1] ?? "";
301
+ let message = null;
302
+ try {
303
+ message = JSON.parse(json);
304
+ } catch {
305
+ message = null;
306
+ }
307
+ const result = isRecord$1(message) ? message["result"] : void 0;
308
+ const output = isRecord$1(result) ? result["structuredContent"] : void 0;
309
+ if (!isRecord$1(output)) throw new Error(`Hollware answered ${tool} with ${response.status}: ${text.slice(0, 200)}`);
310
+ return output;
311
+ });
312
+ }
313
+ //#endregion
314
+ //#region src/model.ts
315
+ /** The model of Workers AI the agents use when the options name none. */
316
+ const DEFAULT_MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast";
317
+ function isRecord(value) {
318
+ return typeof value === "object" && value !== null && !Array.isArray(value);
319
+ }
320
+ /** The text of an answer of Workers AI, in the shapes its text models answer with. */
321
+ function textOf(answer) {
322
+ if (typeof answer === "string") return answer;
323
+ if (!isRecord(answer)) return null;
324
+ if (typeof answer["response"] === "string") return answer["response"];
325
+ const choices = answer["choices"];
326
+ if (Array.isArray(choices) && isRecord(choices[0]) && isRecord(choices[0]["message"])) {
327
+ const content = choices[0]["message"]["content"];
328
+ if (typeof content === "string") return content;
329
+ }
330
+ return null;
331
+ }
332
+ /** A model of Workers AI, through an AI Gateway when its id is given. */
333
+ function workersAiModel(ai, model, options = {}) {
334
+ return async (messages) => {
335
+ const text = textOf(await ai.run(model, {
336
+ messages: messages.map(({ role, content }) => ({
337
+ role,
338
+ content
339
+ })),
340
+ max_tokens: options.maxTokens ?? 4096
341
+ }, options.gateway === void 0 ? void 0 : { gateway: { id: options.gateway } }));
342
+ if (text === null) throw new Error(`${model} answered no text`);
343
+ return text;
344
+ };
345
+ }
346
+ /** The day's cap of model calls was reached; the agents stop until the next day. */
347
+ var DailyLimitReached = class extends Error {
348
+ constructor(limit) {
349
+ super(`The agents reached their ${limit} model calls of the day`);
350
+ this.name = "DailyLimitReached";
351
+ }
352
+ };
353
+ /** A model that counts its calls of the day in the memory and refuses one over `limit`. */
354
+ function capped(model, memory, limit, today) {
355
+ return async (messages) => {
356
+ const key = `usage:${today()}`;
357
+ const used = Number(await memory.get(key) ?? "0");
358
+ if (used >= limit) throw new DailyLimitReached(limit);
359
+ await memory.put(key, String(used + 1), 172800);
360
+ return model(messages);
361
+ };
362
+ }
363
+ //#endregion
364
+ //#region src/reviewer.ts
365
+ /** The name the agents' client gives: a comment of theirs carries it. */
366
+ const AGENTS_CLIENT = "hollware-agents";
367
+ /** The longest comment the reviewer writes, under the 4,000 characters of `comment_review`. */
368
+ const MAX_COMMENT = 3500;
369
+ const REVIEWER_PROMPT = [
370
+ "You review an entry of a website before a person decides whether to publish it.",
371
+ "The contracts of the site already checked its schema, links, images and translations: never repeat those.",
372
+ "Read it as an editor: is it clear, does it follow the style guide, does each claim have an example, is anything missing, wrong or out of date?",
373
+ "Answer in the language of the entry, in Markdown, in at most eight short bullet points, the most important first.",
374
+ "Name the section of each point. Say what reads well in one bullet at most. Do not rewrite the entry."
375
+ ].join(" ");
376
+ /**
377
+ * Comments once on each pending review: what the contracts cannot see. A review that has a
378
+ * comment of the agents, or that the memory has, is left alone, so a run that fails halfway is
379
+ * picked up by the next one without commenting twice.
380
+ */
381
+ async function reviewPending(deps) {
382
+ const site = record((await deps.hollware.call("describe_site", {}))["site"]);
383
+ const reviews = list((await deps.hollware.call("list_reviews", { status: "pending" }))["reviews"]);
384
+ let commented = 0;
385
+ for (const review of reviews) {
386
+ if (commented >= 8) break;
387
+ const id = text(review["id"]);
388
+ if (id === null) continue;
389
+ if (list(review["comments"]).some((comment) => comment["client"] === "hollware-agents") || await deps.memory.get(`reviewed:${id}`) !== null) continue;
390
+ const summary = record(review["entry"]);
391
+ const markdown = text(record((await deps.hollware.call("get_entry", {
392
+ collection: summary["collection"],
393
+ locale: summary["locale"],
394
+ slug: summary["slug"]
395
+ }))["entry"])["markdown"]);
396
+ if (markdown === null) continue;
397
+ const collection = list(site["collections"]).find((one) => one["name"] === summary["collection"]);
398
+ const guides = [text(site["styleGuide"]), text(collection?.["styleGuide"])].filter((guide) => guide !== null);
399
+ const answer = (await deps.model([{
400
+ role: "system",
401
+ content: REVIEWER_PROMPT
402
+ }, {
403
+ role: "user",
404
+ content: [`Style guide:\n${guides.length === 0 ? "(none)" : guides.join("\n\n")}`, `Entry (${String(summary["locale"])}):\n\n${markdown}`].join("\n\n")
405
+ }])).trim();
406
+ if (answer === "") continue;
407
+ const body = answer.length > 3500 ? `${answer.slice(0, MAX_COMMENT)}…` : answer;
408
+ const result = await deps.hollware.call("comment_review", {
409
+ id,
410
+ body
411
+ });
412
+ await deps.memory.put(`reviewed:${id}`, String(result["outcome"]), 2592e3);
413
+ if (result["outcome"] === "commented") commented += 1;
414
+ }
415
+ return { commented };
416
+ }
417
+ const TRANSLATOR_PROMPT = [
418
+ "You translate a Markdown file of a website into another language.",
419
+ "Answer with the whole translated file and nothing else: no code fence around it, no comment.",
420
+ "Keep the frontmatter between its --- lines, with the same keys. Translate the values a reader sees, such as title and description;",
421
+ "keep dates, tags, identifiers and the value of the translation group exactly as they are.",
422
+ "Put a frontmatter value in double quotes when it holds a colon or starts with a symbol.",
423
+ "Keep Markdown structure, links, code blocks and components (lines that start with ::) as they are; translate only their text.",
424
+ "Write naturally, as a native speaker would, in the voice of the style guide."
425
+ ].join(" ");
426
+ /** A slug from a title: lowercase ASCII words joined by hyphens. */
427
+ function slugOf(title) {
428
+ return title.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80).replace(/-+$/, "") || "translation";
429
+ }
430
+ /** The model's file without a code fence around it, if it put one. */
431
+ function unfenced(answer) {
432
+ const trimmed = answer.trim();
433
+ return `${(/^```[a-z]*\n([\s\S]*?)\n```$/.exec(trimmed)?.[1] ?? trimmed).trimEnd()}\n`;
434
+ }
435
+ function titleOf(markdown) {
436
+ return /^title:\s*["']?(.+?)["']?\s*$/m.exec(markdown)?.[1] ?? null;
437
+ }
438
+ function problemsOf(verdict) {
439
+ return list(verdict["errors"]).map((error) => `- ${String(error["path"])}: ${String(error["message"])} Fix: ${String(error["fix"])}`).join("\n");
440
+ }
441
+ /**
442
+ * For each published entry of a collection with translations whose group lacks a locale of the
443
+ * site, writes the translation as a draft, fixes what the contracts refuse up to `FIX_ROUNDS`
444
+ * times, and asks a person to review it, at most `limit` a run. It reads each locale's entries in
445
+ * one listing, so a run stays within the fifty calls a free Worker has; an entry of any status
446
+ * in a locale counts as the translation, and the memory keeps what was tried, once it was saved or
447
+ * refused: a run that stops before tries it again.
448
+ */
449
+ async function translateMissing(deps, options = {}) {
450
+ const limit = options.limit ?? 2;
451
+ const site = record((await deps.hollware.call("describe_site", {}))["site"]);
452
+ const given = site["locales"];
453
+ const locales = Array.isArray(given) ? given.filter((one) => typeof one === "string") : [];
454
+ let drafted = 0;
455
+ let sentToReview = 0;
456
+ for (const collection of list(site["collections"])) {
457
+ const name = text(collection["name"]);
458
+ if (name === null || text(collection["translationGroupField"]) === null) continue;
459
+ const style = text(collection["styleGuide"]) ?? text(site["styleGuide"]) ?? "(none)";
460
+ const byLocale = /* @__PURE__ */ new Map();
461
+ for (const locale of locales) byLocale.set(locale, await entriesOf(deps.hollware, {
462
+ collection: name,
463
+ locale
464
+ }));
465
+ const groupsIn = new Map(locales.map((locale) => [locale, new Set((byLocale.get(locale) ?? []).map((entry) => text(entry["translationGroup"])))]));
466
+ for (const [source, entries] of byLocale) for (const entry of entries) {
467
+ const group = text(entry["translationGroup"]);
468
+ const id = text(entry["id"]);
469
+ if (entry["status"] !== "published" || group === null || id === null) continue;
470
+ for (const locale of locales) {
471
+ const present = groupsIn.get(locale);
472
+ if (locale === source || present === void 0 || present.has(group)) continue;
473
+ if (drafted >= limit) return {
474
+ drafted,
475
+ sentToReview
476
+ };
477
+ present.add(group);
478
+ const key = `translated:${id}:${locale}`;
479
+ if (await deps.memory.get(key) !== null) continue;
480
+ const source_ = text(record((await deps.hollware.call("get_entry", {
481
+ collection: name,
482
+ locale: source,
483
+ slug: entry["slug"]
484
+ }))["entry"])["markdown"]);
485
+ if (source_ === null) continue;
486
+ const translated = unfenced(await deps.model([{
487
+ role: "system",
488
+ content: TRANSLATOR_PROMPT
489
+ }, {
490
+ role: "user",
491
+ content: `Translate from ${source} into ${locale}.\n\nStyle guide:\n${style}\n\nFile:\n\n${source_}`
492
+ }]));
493
+ const slug = slugOf(titleOf(translated) ?? String(entry["slug"]));
494
+ let saved = await deps.hollware.call("create_draft", {
495
+ collection: name,
496
+ locale,
497
+ slug,
498
+ markdown: translated
499
+ });
500
+ if (saved["outcome"] === "refused") saved = await deps.hollware.call("create_draft", {
501
+ collection: name,
502
+ locale,
503
+ slug: `${slug}-${locale}`,
504
+ markdown: translated
505
+ });
506
+ await deps.memory.put(key, String(saved["outcome"]), 7776e3);
507
+ if (saved["outcome"] !== "saved") continue;
508
+ drafted += 1;
509
+ if (await fixAndAsk(deps, saved, name, locale)) sentToReview += 1;
510
+ }
511
+ }
512
+ }
513
+ return {
514
+ drafted,
515
+ sentToReview
516
+ };
517
+ }
518
+ /** Fixes a saved draft up to `FIX_ROUNDS` times, then asks for its review if the contracts allow it. */
519
+ async function fixAndAsk(deps, saved, collection, locale) {
520
+ let entry = record(saved["entry"]);
521
+ let verdict = record(saved["verdict"]);
522
+ for (let round = 0; round < 2 && verdict["ok"] === false; round++) {
523
+ const current = text(record((await deps.hollware.call("get_entry", {
524
+ collection,
525
+ locale,
526
+ slug: entry["slug"]
527
+ }))["entry"])["markdown"]);
528
+ if (current === null) return false;
529
+ const fixed = unfenced(await deps.model([{
530
+ role: "system",
531
+ content: TRANSLATOR_PROMPT
532
+ }, {
533
+ role: "user",
534
+ content: `The contracts of the site refuse this file. Fix these problems, change nothing else, and answer with the whole file.\n\n${problemsOf(verdict)}\n\nFile:\n\n${current}`
535
+ }]));
536
+ const updated = await deps.hollware.call("update_draft", {
537
+ id: entry["id"],
538
+ version: entry["version"],
539
+ markdown: fixed
540
+ });
541
+ if (updated["outcome"] !== "saved") return false;
542
+ entry = record(updated["entry"]);
543
+ verdict = record(updated["verdict"]);
544
+ }
545
+ if (verdict["ok"] === false) return false;
546
+ const version = number(entry["version"]);
547
+ let asked = await deps.hollware.call("request_review", {
548
+ id: entry["id"],
549
+ version
550
+ });
551
+ if (asked["outcome"] === "needs_acknowledgement") {
552
+ const warnings = list(record(asked["verdict"])["warnings"]).map((warning) => ({
553
+ contract: warning["contract"],
554
+ path: warning["path"]
555
+ }));
556
+ asked = await deps.hollware.call("request_review", {
557
+ id: entry["id"],
558
+ version,
559
+ acknowledgeWarnings: warnings
560
+ });
561
+ }
562
+ return asked["outcome"] === "pending_review";
563
+ }
564
+ //#endregion
565
+ //#region src/worker.ts
566
+ /** The roles, by turn: each run is one, so that it stays within the fifty calls of a free run. */
567
+ const ROLES = [
568
+ "reviewer",
569
+ "translator",
570
+ "auditor"
571
+ ];
572
+ /** The role whose turn it is at `time`: the ten minutes of the time, in turn. */
573
+ function roleAt(time) {
574
+ return ROLES[Math.floor(time / 6e5) % ROLES.length] ?? "reviewer";
575
+ }
576
+ /** The memory's key of the day of the last audit. */
577
+ const LAST_AUDIT = "audit:last";
578
+ /** Whether an audit is due: none yet, or the last one `everyDays` or more days before `day`. */
579
+ function auditDue(last, day, everyDays) {
580
+ return last === null || Date.parse(day) - Date.parse(last) >= everyDays * 864e5;
581
+ }
582
+ function log(entry) {
583
+ console.log(JSON.stringify({
584
+ source: "hollware-agents",
585
+ ...entry
586
+ }));
587
+ }
588
+ /**
589
+ * The Worker of the agents. Hollware's cron wakes them every ten minutes over a service binding
590
+ * (`run`), or a cron trigger of their own does (`scheduled`). Each run is one role, in turn: the
591
+ * reviewer, the translator, then the auditor, which checks a dozen entries and writes its report
592
+ * when it is due. A free Worker makes fifty calls a run at most, which one role fits in. A role
593
+ * that fails is logged; reaching the day's limit of model calls ends it quietly.
594
+ */
595
+ function createHollwareAgents(options = {}) {
596
+ const clock = options.clock ?? (() => /* @__PURE__ */ new Date());
597
+ const auditor = options.auditor === void 0 ? false : options.auditor;
598
+ const depsOf = (env) => {
599
+ const hollware = mcpHollware({
600
+ url: env.HOLLWARE_PUBLIC_URL,
601
+ token: env.HOLLWARE_AGENT_TOKEN,
602
+ fetch: (request) => env.HOLLWARE === void 0 ? fetch(request) : env.HOLLWARE.fetch(request)
603
+ });
604
+ const memory = kvMemory(env.AGENTS_KV);
605
+ const chosen = options.model ?? "@cf/meta/llama-3.3-70b-instruct-fp8-fast";
606
+ const base = typeof chosen === "function" ? chosen : (messages) => {
607
+ if (env.AI === void 0) throw new Error("The agents need the AI binding for a Workers AI model");
608
+ return workersAiModel(env.AI, chosen, options.gateway === void 0 ? {} : { gateway: options.gateway })(messages);
609
+ };
610
+ const today = () => clock().toISOString().slice(0, 10);
611
+ return {
612
+ hollware,
613
+ memory,
614
+ model: capped(base, memory, options.dailyLimit ?? 100, today)
615
+ };
616
+ };
617
+ const run = async (role, work) => {
618
+ try {
619
+ log({
620
+ role,
621
+ ...await work()
622
+ });
623
+ return true;
624
+ } catch (error) {
625
+ if (error instanceof DailyLimitReached) {
626
+ log({
627
+ role,
628
+ stopped: error.message
629
+ });
630
+ return false;
631
+ }
632
+ log({
633
+ role,
634
+ failed: error instanceof Error ? error.message : String(error)
635
+ });
636
+ return true;
637
+ }
638
+ };
639
+ /** One role a run, by the ten minutes of the time: the reviewer, the translator, the auditor. */
640
+ const runOnce = async (env) => {
641
+ const deps = depsOf(env);
642
+ const now = clock();
643
+ const role = roleAt(now.getTime());
644
+ if (role === "reviewer") {
645
+ if (options.reviewer !== false) await run("reviewer", () => reviewPending(deps));
646
+ return;
647
+ }
648
+ if (role === "translator") {
649
+ if (options.translator === false) return;
650
+ const limit = typeof options.translator === "object" ? options.translator.limit : void 0;
651
+ await run("translator", () => translateMissing(deps, limit === void 0 ? {} : { limit }));
652
+ return;
653
+ }
654
+ if (auditor === false) return;
655
+ await run("auditor", () => auditStep(deps, {
656
+ report: auditor,
657
+ now: now.toISOString()
658
+ }));
659
+ const day = now.toISOString().slice(0, 10);
660
+ if (!auditDue(await deps.memory.get("audit:last"), day, auditor.everyDays ?? 7)) return;
661
+ const url = env.AGENTS_WEBHOOK_URL;
662
+ if (await run("report", () => writeReport({
663
+ hollware: deps.hollware,
664
+ memory: deps.memory,
665
+ webhook: url === void 0 || url === "" ? void 0 : async (report) => {
666
+ const response = await fetch(url, {
667
+ method: "POST",
668
+ headers: { "content-type": "application/json" },
669
+ body: JSON.stringify({ text: report })
670
+ });
671
+ if (!response.ok) throw new Error(`The webhook answered ${response.status}`);
672
+ }
673
+ }, {
674
+ report: auditor,
675
+ day,
676
+ ...auditor.staleDays === void 0 ? {} : { staleDays: auditor.staleDays }
677
+ }))) await deps.memory.put(LAST_AUDIT, day);
678
+ };
679
+ return {
680
+ run: runOnce,
681
+ async scheduled(_controller, env, ctx) {
682
+ ctx.waitUntil(runOnce(env));
683
+ },
684
+ async fetch(request) {
685
+ const { pathname } = new URL(request.url);
686
+ return pathname === "/health" ? Response.json({ ok: true }, { headers: { "cache-control": "no-store" } }) : new Response("Not found", { status: 404 });
687
+ }
688
+ };
689
+ }
690
+ //#endregion
691
+ //#region src/index.ts
692
+ const name = "@hollware/agents";
693
+ //#endregion
694
+ export { AGENTS_CLIENT, ALLOWED_TOOLS, AUDIT_STATE, DEFAULT_MODEL, DailyLimitReached, LAST_AUDIT, ROLES, allowOnly, audit, auditDue, auditStep, capped, createHollwareAgents, kvMemory, mapMemory, mcpHollware, name, reportOf, reviewPending, roleAt, slugOf, translateMissing, workersAiModel, writeReport };
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@hollware/agents",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.6.0",
4
+ "description": "Agents for Hollware on Cloudflare: an editorial reviewer, a translator and a weekly auditor, none of which publishes.",
5
+ "homepage": "https://hollware.com",
6
+ "bugs": {
7
+ "url": "https://github.com/hollware/hollware/issues"
8
+ },
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/hollware/hollware.git",
13
+ "directory": "packages/agents"
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "type": "module",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.mts",
22
+ "default": "./dist/index.mjs"
23
+ },
24
+ "./package.json": "./package.json"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public",
28
+ "provenance": true
29
+ },
30
+ "dependencies": {
31
+ "@hollware/core": "0.6.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "24.19.2",
35
+ "publint": "0.3.25",
36
+ "typescript": "7.0.2",
37
+ "vite-plus": "1.1.0",
38
+ "zod": "4.6.5"
39
+ },
40
+ "peerDependencies": {
41
+ "zod": "^4.2.0"
42
+ },
43
+ "engines": {
44
+ "node": ">=24.11.0"
45
+ },
46
+ "scripts": {
47
+ "build": "vp pack",
48
+ "test": "vp test",
49
+ "publint": "publint --strict"
50
+ }
6
51
  }