@hollware/agents 0.0.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/LICENSE +21 -0
- package/README.md +29 -1
- package/dist/index.d.mts +259 -0
- package/dist/index.mjs +694 -0
- package/package.json +42 -3
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
1
|
# @hollware/agents
|
|
2
2
|
|
|
3
|
-
|
|
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
|
package/dist/index.d.mts
ADDED
|
@@ -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,12 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hollware/agents",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Agents for Hollware on Cloudflare
|
|
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
5
|
"homepage": "https://hollware.com",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/hollware/hollware/issues"
|
|
8
|
+
},
|
|
6
9
|
"license": "MIT",
|
|
7
10
|
"repository": {
|
|
8
11
|
"type": "git",
|
|
9
12
|
"url": "git+https://github.com/hollware/hollware.git",
|
|
10
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"
|
|
11
50
|
}
|
|
12
|
-
}
|
|
51
|
+
}
|