wiki-formant 0.94.0 → 0.97.1
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/README.md +30 -7
- package/dist/analytics.d.ts +14 -0
- package/dist/analytics.js +8 -0
- package/dist/chart.d.ts +3 -3
- package/dist/chart.js +5 -6
- package/dist/mcp.d.ts +33 -0
- package/dist/mcp.js +73 -4
- package/dist/og.d.ts +17 -0
- package/dist/og.js +38 -0
- package/dist/react-server.d.ts +7 -2
- package/dist/stats.d.ts +4 -1
- package/dist/stats.js +6 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -231,6 +231,14 @@ handler: async (args, ctx) => {
|
|
|
231
231
|
|
|
232
232
|
`config.gate` is envelope-level middleware: it may withhold entries before dispatch and merge its own responses back afterwards. A payment gate has to sit there rather than in a handler, because the demand *replaces* the call and the receipt rides on the envelope. `RpcRequest` and `toolText` are exported for a gate to build with; acuiq2's x402 gate is the one that does.
|
|
233
233
|
|
|
234
|
+
### Agent feedback
|
|
235
|
+
|
|
236
|
+
`feedbackTool(record, noun)` is `send_feedback`: an agent says what it needed and did not find, what is wrong or what broke (`kind` is `missing`, `wrong`, `broken` or `suggestion`), in `FEEDBACK_MAX` characters. It asks for something to act on rather than for a rating, which an agent answers with praise. List it among a server's tools and the transport does the inviting: `initialize` appends `FEEDBACK_INVITE` to `instructions`, and an empty `listEnvelope` page or an `McpToolError` carries a one-line `feedback` pointer, because a miss is when an agent knows what it was after. A tool whose miss has another shape quotes `FEEDBACK_NUDGE` in its own note. A server without the tool mentions it nowhere. Pass `tracker.trackFeedback` as `record` and each report is an "Agent Feedback" event, listed newest first in the digest's `agent_feedback`. Nothing is published: a report is a stranger's text, read as data.
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
tools: [...TOOLS, feedbackTool(tracker.trackFeedback, 'the Radix Wiki')]
|
|
240
|
+
```
|
|
241
|
+
|
|
234
242
|
### Tool arguments and the corpus tool
|
|
235
243
|
|
|
236
244
|
`readArgs` reads a tool's raw arguments as typed, clamped values and records every value it had to override, so a result can say `adjustments: [{ param: 'limit', requested: -4, used: 1, … }]` instead of silently answering a different question. Defaults belong in the tool description; the result echoes only overrides.
|
|
@@ -574,6 +582,16 @@ return new ImageResponse(<SocialCard {...{ title, eyebrow, description, backgrou
|
|
|
574
582
|
|
|
575
583
|
Fonts stay with the site: Next traces a font file into the function only from a literal path in the app's own code, and satori reads TTF or OTF, never the woff2 `next/font` serves. The frame never sets a style key to `undefined`, which satori throws on rather than skipping; `scrim` darkens the foot for art that is a photograph.
|
|
576
584
|
|
|
585
|
+
**A page's banner is its card.** The generated field exists so a page with no banner still unfurls with something; it never stands in for a banner the page has. Every route picks the same way, and every page's metadata passes its banner to the card URL, the homepage and category hubs included:
|
|
586
|
+
|
|
587
|
+
```tsx
|
|
588
|
+
const field = (await bannerField(searchParams.get('banner'), /\.public\.blob\.vercel-storage\.com$/))
|
|
589
|
+
?? bubbles(cardRandom(title), { field, colors });
|
|
590
|
+
return new ImageResponse(<SocialCard {...{ title, description }} {...field} colors={colors} />, { ...OG_SIZE, headers: OG_HEADERS });
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
`bannerField` fetches the banner only from the hosts you name, so `/og` is no proxy to any URL a caller writes, crops it to cover the card, inlines it as PNG (satori decodes no WebP or AVIF) and turns the scrim on. A missing, foreign or undecodable banner returns null and the generated field draws instead. It loads `sharp`, an optional peer, so the route runs on Node.
|
|
594
|
+
|
|
577
595
|
## Revisions
|
|
578
596
|
|
|
579
597
|
What changed between two versions of a page, and therefore which semver bump to
|
|
@@ -998,7 +1016,7 @@ export const DELETE = gate.signOut;
|
|
|
998
1016
|
const q = statsQuery(params); // ?days= (1, 7, 30, 90, 365 or all, else 30), ?agents=apart and the filters
|
|
999
1017
|
return (
|
|
1000
1018
|
<StatsGate gate={gate} session={(await cookies()).get(gate.cookie)?.value} invite={params.invite} host="caper.network">
|
|
1001
|
-
{async () => <Stats digest={await digest(sql, q)} {...q} />}
|
|
1019
|
+
{async () => <Stats digest={await digest(sql, q)} {...q} link={Link} />}
|
|
1002
1020
|
</StatsGate>
|
|
1003
1021
|
);
|
|
1004
1022
|
```
|
|
@@ -1013,13 +1031,16 @@ total, a rate's mean), every non-empty
|
|
|
1013
1031
|
ranked list, and `?days=` links between windows, all time among them, pinned in
|
|
1014
1032
|
the chart's frame as its own buttons are (`TimeChart`'s `controls`). Each
|
|
1015
1033
|
figure is a button that charts it: visitors, page views, bounce rate,
|
|
1016
|
-
visit length and clicks to X. Visitors count agents
|
|
1017
|
-
|
|
1034
|
+
visit length and clicks to X. Visitors count agents while the switch beside the windows, drawn as a robot's
|
|
1035
|
+
head, is lit; turning it off (`?agents=apart`) splits them into people and agents, and every
|
|
1018
1036
|
other link on the page keeps the choice. `digest`'s `by_day` carries every
|
|
1019
1037
|
one of those per UTC day, a visit on the day it began, so the days add up to the
|
|
1020
1038
|
totals. A day with no visits has no bounce rate or visit length and is left out
|
|
1021
1039
|
of those charts rather than drawn as zero. Everything but the figures and the
|
|
1022
|
-
chart is server-rendered.
|
|
1040
|
+
chart is server-rendered. Every link on the page is `link`, a bare `<a>` unless
|
|
1041
|
+
the site passes its router's: with `next/link` a window, the switch or a
|
|
1042
|
+
narrowing re-renders the page in place rather than reloading it, and the chart
|
|
1043
|
+
keeps its figure and step.
|
|
1023
1044
|
|
|
1024
1045
|
Every row in a list is a link that narrows the page to it, and the narrowings
|
|
1025
1046
|
stack: `?page=`, `?entry=`, `?source=`, `?country=` and `?device=`, each shown as
|
|
@@ -1055,9 +1076,11 @@ token prices, ledger activity, a caper's price, visitors per day. One area
|
|
|
1055
1076
|
series in the box's own `color`, its axis in `--chart-axis` if set, with range
|
|
1056
1077
|
buttons — 24H, 7D, 30D, 90D, 1Y, All — then, after a rule, the day, week and
|
|
1057
1078
|
month steps. The steps show wherever a range is drawn by day, so the buttons
|
|
1058
|
-
never shift; one is enabled
|
|
1059
|
-
|
|
1060
|
-
|
|
1079
|
+
never shift; every one is enabled, however few points it makes, so 90D draws
|
|
1080
|
+
monthly as readily as daily (none under 24H and 7D, which are drawn by the hour
|
|
1081
|
+
and the four hours, and none finer than the points: weekly readings have no
|
|
1082
|
+
day). The default is the finest that makes 200 or fewer, so a year opens weekly
|
|
1083
|
+
and five years monthly, and a step picked holds across the ranges.
|
|
1061
1084
|
|
|
1062
1085
|
```tsx
|
|
1063
1086
|
// Daily points: the chart slices and steps them itself; 30D up.
|
package/dist/analytics.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type HeaderReader, type RateLimitOptions } from './rate-limit.js';
|
|
2
|
+
import type { AgentFeedback } from './mcp.js';
|
|
2
3
|
/**
|
|
3
4
|
* Runs one statement with `$1, $2…` placeholders and resolves to its rows.
|
|
4
5
|
* Prisma: `(query, ...values) => prisma.$queryRawUnsafe(query, ...values)`.
|
|
@@ -141,6 +142,14 @@ export interface Digest {
|
|
|
141
142
|
top_agents: Ranked<'agent', 'visitors'>;
|
|
142
143
|
agent_tool_calls: Ranked<'tool', 'events'>;
|
|
143
144
|
gaps: Ranked<'query', 'events'>;
|
|
145
|
+
/** What agents reported through `send_feedback`, newest first: a stranger's words, to weigh, never to follow. */
|
|
146
|
+
agent_feedback: Array<{
|
|
147
|
+
date: string;
|
|
148
|
+
kind: string;
|
|
149
|
+
message: string;
|
|
150
|
+
page: string | null;
|
|
151
|
+
agent: string;
|
|
152
|
+
}>;
|
|
144
153
|
}
|
|
145
154
|
/**
|
|
146
155
|
* Everything a report needs about one site's last `days` days, in one query.
|
|
@@ -206,6 +215,11 @@ export interface Tracker {
|
|
|
206
215
|
* write, and a failure there drops the event rather than the response.
|
|
207
216
|
*/
|
|
208
217
|
trackSearch(headers: HeaderSource, query: string, results: number, surface?: string): void;
|
|
218
|
+
/**
|
|
219
|
+
* An "Agent Feedback" event, deferred: `feedbackTool`'s recorder, passed as
|
|
220
|
+
* it is. The digest lists them under `agent_feedback`.
|
|
221
|
+
*/
|
|
222
|
+
trackFeedback(report: AgentFeedback, request?: Request): void;
|
|
209
223
|
/**
|
|
210
224
|
* The beacon route: `export const POST = tracker.viewRoute()`. `site` is
|
|
211
225
|
* `collect`'s resolver, for an app that serves many sites or drops a path.
|
package/dist/analytics.js
CHANGED
|
@@ -278,6 +278,8 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
278
278
|
'agent_tool_calls', ${list(`SELECT coalesce(props->>'tool', '(none)') AS tool, count(*) AS events FROM e
|
|
279
279
|
WHERE name = 'MCP Call' AND props->>'method' = 'tools/call' AND NOT ${ours}
|
|
280
280
|
GROUP BY 1`, 10)},
|
|
281
|
+
'agent_feedback', ${list(`SELECT ${day('at')} AS date, props->>'kind' AS kind, props->>'message' AS message, props->>'page' AS page, ${AGENT_NAME} AS agent
|
|
282
|
+
FROM e WHERE name = 'Agent Feedback' AND NOT ${ours}`, 20, 'at DESC')},
|
|
281
283
|
'gaps', ${gapSelects.length ? `(SELECT coalesce(json_agg(t), '[]') FROM (${gapSelects.join(' UNION ALL ')}) t)` : `'[]'::json`}
|
|
282
284
|
) AS digest`, ...values));
|
|
283
285
|
const d = row.digest;
|
|
@@ -375,6 +377,12 @@ export function createTracker({ sql, defer, enabled = typeof process !== 'undefi
|
|
|
375
377
|
}
|
|
376
378
|
});
|
|
377
379
|
},
|
|
380
|
+
trackFeedback(report, request) {
|
|
381
|
+
if (!enabled)
|
|
382
|
+
return;
|
|
383
|
+
const ua = (request?.headers.get('user-agent') || 'unknown').slice(0, 80);
|
|
384
|
+
defer(() => trackEvent('Agent Feedback', request?.url ?? siteUrl, { ...report, ua }, request?.headers));
|
|
385
|
+
},
|
|
378
386
|
viewRoute(opts = {}) {
|
|
379
387
|
return async (request) => enabled ? collect(request, { sql, defer, ...opts }) : new Response(null, { status: 204 });
|
|
380
388
|
},
|
package/dist/chart.d.ts
CHANGED
|
@@ -33,9 +33,9 @@ export type ChartAggregate = 'last' | 'sum' | 'mean';
|
|
|
33
33
|
/** Every range, shortest first. A chart fed daily points offers the ones from 30D up. */
|
|
34
34
|
export declare const CHART_RANGES: readonly ChartRange[];
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
37
|
-
* makes 200 or fewer. None is finer than the points' own `spacing`: weekly
|
|
38
|
-
* have no day.
|
|
36
|
+
* Every step is offered, however few points it makes, and the default is the finest
|
|
37
|
+
* that makes 200 or fewer. None is finer than the points' own `spacing`: weekly
|
|
38
|
+
* readings have no day.
|
|
39
39
|
*/
|
|
40
40
|
export declare function chartSteps(span: number, spacing?: number): {
|
|
41
41
|
steps: ChartStep[];
|
package/dist/chart.js
CHANGED
|
@@ -31,14 +31,13 @@ const STEPS = ['day', 'week', 'month'];
|
|
|
31
31
|
export const CHART_RANGES = ['24h', '7d', '30d', '90d', '1y', 'all'];
|
|
32
32
|
const DAILY_RANGES = ['30d', '90d', '1y', 'all'];
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
35
|
-
* makes 200 or fewer. None is finer than the points' own `spacing`: weekly
|
|
36
|
-
* have no day.
|
|
34
|
+
* Every step is offered, however few points it makes, and the default is the finest
|
|
35
|
+
* that makes 200 or fewer. None is finer than the points' own `spacing`: weekly
|
|
36
|
+
* readings have no day.
|
|
37
37
|
*/
|
|
38
38
|
export function chartSteps(span, spacing = DAY) {
|
|
39
39
|
const fine = STEPS.filter(s => STEP[s].span >= spacing * 0.9);
|
|
40
|
-
const
|
|
41
|
-
const steps = STEPS.filter(s => s === finest || (fine.includes(s) && span / STEP[s].span >= 4));
|
|
40
|
+
const steps = fine.length ? fine : ['month'];
|
|
42
41
|
return { steps, fallback: steps.find(s => span / STEP[s].span <= 200) ?? steps.at(-1) };
|
|
43
42
|
}
|
|
44
43
|
/** The points' own spacing: the median gap, so a missing day or a skipped week does not change it. */
|
|
@@ -380,5 +379,5 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
380
379
|
top: box.current.offsetTop + tip.y,
|
|
381
380
|
...(tip.x < box.current.clientWidth / 2 ? { left: tip.x } : { right: box.current.clientWidth - tip.x }),
|
|
382
381
|
translate: tip.y < 48 ? '0 1rem' : '0 calc(-100% - 0.75rem)',
|
|
383
|
-
}, children: tip.label })), (!ready || (!points && !status)) && _jsx("div", { className: "chart-status skeleton", "aria-hidden": true }), status && _jsx("p", { className: "chart-status", children: status }), (controls || ranges.length > 1 || daily || view?.ohlc) && (_jsxs("div", { className: "chart-controls", children: [controls, ranges.length > 1 && (_jsx("div", { role: "group", "aria-label": "Range", children: ranges.map(r => (_jsx("button", { type: "button", "aria-pressed": r === range, onClick: () =>
|
|
382
|
+
}, children: tip.label })), (!ready || (!points && !status)) && _jsx("div", { className: "chart-status skeleton", "aria-hidden": true }), status && _jsx("p", { className: "chart-status", children: status }), (controls || ranges.length > 1 || daily || view?.ohlc) && (_jsxs("div", { className: "chart-controls", children: [controls, ranges.length > 1 && (_jsx("div", { role: "group", "aria-label": "Range", children: ranges.map(r => (_jsx("button", { type: "button", "aria-pressed": r === range, onClick: () => setRange(r), children: RANGE[r].label }, r))) })), daily && (_jsx("div", { role: "group", "aria-label": "Step", children: STEPS.map(s => (_jsx("button", { type: "button", "aria-pressed": s === view?.step, disabled: !view?.steps.includes(s), onClick: () => setStep(s), title: `One point per ${s}`, children: STEP[s].label }, s))) })), view?.ohlc && (_jsx("div", { children: _jsx("button", { type: "button", "aria-pressed": candles, "aria-label": "Candles", title: "Candles", onClick: () => setCandles(c => !c), children: _jsxs("svg", { className: "chart-icon", viewBox: "0 0 12 12", "aria-hidden": true, children: [_jsx("path", { d: "M3.5 1v2M3.5 9v2M8.5 2v3M8.5 9v1", stroke: "currentColor" }), _jsx("rect", { x: "2", y: "3", width: "3", height: "6", rx: "0.5", fill: "currentColor" }), _jsx("rect", { x: "7", y: "5", width: "3", height: "4", rx: "0.5", fill: "currentColor" })] }) }) }))] }))] }));
|
|
384
383
|
}
|
package/dist/mcp.d.ts
CHANGED
|
@@ -287,6 +287,39 @@ export declare const withAdjustments: <T extends object>(args: {
|
|
|
287
287
|
}, result: T) => T | (T & {
|
|
288
288
|
adjustments: readonly ArgAdjustment[];
|
|
289
289
|
});
|
|
290
|
+
/** The tool an agent reports through. A server listing it is told so on every miss. */
|
|
291
|
+
export declare const FEEDBACK_TOOL = "send_feedback";
|
|
292
|
+
export declare const FEEDBACK_KINDS: readonly ["missing", "wrong", "broken", "suggestion"];
|
|
293
|
+
export type FeedbackKind = (typeof FEEDBACK_KINDS)[number];
|
|
294
|
+
/** The longest message kept: the bound an event's string prop is stored at. */
|
|
295
|
+
export declare const FEEDBACK_MAX = 300;
|
|
296
|
+
export interface AgentFeedback {
|
|
297
|
+
kind: FeedbackKind;
|
|
298
|
+
message: string;
|
|
299
|
+
/** The page or tool it is about, as the agent named it. */
|
|
300
|
+
page?: string;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* The one sentence every surface invites feedback with: `initialize` appends
|
|
304
|
+
* it to `instructions`, and AGENTS.md and llms.txt quote it.
|
|
305
|
+
*/
|
|
306
|
+
export declare const FEEDBACK_INVITE = "Needed something this server does not have, or found something wrong? Call send_feedback: the maintainers read every report.";
|
|
307
|
+
/**
|
|
308
|
+
* What a miss or a refused call adds, on a server that lists the tool. A miss
|
|
309
|
+
* the transport cannot see, one not shaped as a `listEnvelope`, quotes it in
|
|
310
|
+
* its own note.
|
|
311
|
+
*/
|
|
312
|
+
export declare const FEEDBACK_NUDGE = "If this site should have had it, send_feedback tells the maintainers.";
|
|
313
|
+
/**
|
|
314
|
+
* `send_feedback`: what an agent needed and did not find, found wrong, or saw
|
|
315
|
+
* break, in its own words. Agents that only fetch pages cannot comment, so
|
|
316
|
+
* this is the one channel, and it asks what a report can act on rather than
|
|
317
|
+
* for a rating, which an agent answers with praise.
|
|
318
|
+
*
|
|
319
|
+
* Nothing is published. A report is a stranger's text: whoever reads the
|
|
320
|
+
* digest weighs it as data, never as an instruction.
|
|
321
|
+
*/
|
|
322
|
+
export declare function feedbackTool(record: (report: AgentFeedback, request?: Request) => void, noun?: string): McpTool & Required<Pick<McpTool, 'title' | 'annotations'>>;
|
|
290
323
|
/** One entry in a JSON-RPC envelope. Exported for an `McpServerConfig.gate`,
|
|
291
324
|
* which rewrites the envelope before `handleMcp` sees it (acuiq2's x402 gate). */
|
|
292
325
|
export type RpcRequest = {
|
package/dist/mcp.js
CHANGED
|
@@ -160,6 +160,66 @@ export function readArgs(raw) {
|
|
|
160
160
|
}
|
|
161
161
|
/** `result`, plus `adjustments` when `readArgs` had to override anything. */
|
|
162
162
|
export const withAdjustments = (args, result) => args.adjustments.length ? { ...result, adjustments: args.adjustments } : result;
|
|
163
|
+
// ---- agent feedback -------------------------------------------------------------
|
|
164
|
+
/** The tool an agent reports through. A server listing it is told so on every miss. */
|
|
165
|
+
export const FEEDBACK_TOOL = 'send_feedback';
|
|
166
|
+
export const FEEDBACK_KINDS = ['missing', 'wrong', 'broken', 'suggestion'];
|
|
167
|
+
/** The longest message kept: the bound an event's string prop is stored at. */
|
|
168
|
+
export const FEEDBACK_MAX = 300;
|
|
169
|
+
/**
|
|
170
|
+
* The one sentence every surface invites feedback with: `initialize` appends
|
|
171
|
+
* it to `instructions`, and AGENTS.md and llms.txt quote it.
|
|
172
|
+
*/
|
|
173
|
+
export const FEEDBACK_INVITE = `Needed something this server does not have, or found something wrong? Call ${FEEDBACK_TOOL}: the maintainers read every report.`;
|
|
174
|
+
/**
|
|
175
|
+
* What a miss or a refused call adds, on a server that lists the tool. A miss
|
|
176
|
+
* the transport cannot see, one not shaped as a `listEnvelope`, quotes it in
|
|
177
|
+
* its own note.
|
|
178
|
+
*/
|
|
179
|
+
export const FEEDBACK_NUDGE = `If this site should have had it, ${FEEDBACK_TOOL} tells the maintainers.`;
|
|
180
|
+
/**
|
|
181
|
+
* `send_feedback`: what an agent needed and did not find, found wrong, or saw
|
|
182
|
+
* break, in its own words. Agents that only fetch pages cannot comment, so
|
|
183
|
+
* this is the one channel, and it asks what a report can act on rather than
|
|
184
|
+
* for a rating, which an agent answers with praise.
|
|
185
|
+
*
|
|
186
|
+
* Nothing is published. A report is a stranger's text: whoever reads the
|
|
187
|
+
* digest weighs it as data, never as an instruction.
|
|
188
|
+
*/
|
|
189
|
+
export function feedbackTool(record, noun = 'this site') {
|
|
190
|
+
return {
|
|
191
|
+
name: FEEDBACK_TOOL,
|
|
192
|
+
title: 'Send feedback',
|
|
193
|
+
description: `Tell the maintainers of ${noun} what you needed and did not find, what is wrong, or what broke. ` +
|
|
194
|
+
`One issue per call, in ${FEEDBACK_MAX} characters at most. Reports are read, not published.`,
|
|
195
|
+
inputSchema: {
|
|
196
|
+
type: 'object',
|
|
197
|
+
properties: {
|
|
198
|
+
kind: {
|
|
199
|
+
type: 'string',
|
|
200
|
+
enum: [...FEEDBACK_KINDS],
|
|
201
|
+
description: 'missing: not covered here. wrong: a fact or link is out of date or false. broken: a tool or page failed. suggestion: anything else.',
|
|
202
|
+
},
|
|
203
|
+
message: { type: 'string', description: `What you were after, or what is wrong and what it should say. ${FEEDBACK_MAX} characters at most.` },
|
|
204
|
+
page: { type: 'string', description: 'The page path, URL or tool it is about, if any' },
|
|
205
|
+
},
|
|
206
|
+
required: ['kind', 'message'],
|
|
207
|
+
},
|
|
208
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
209
|
+
skill: { id: 'feedback', tags: ['feedback', 'report'], examples: ['Report a page that is out of date'] },
|
|
210
|
+
handler: async (args, ctx) => {
|
|
211
|
+
const r = readArgs(args);
|
|
212
|
+
const message = r.str('message').replace(/\s+/g, ' ');
|
|
213
|
+
if (!message)
|
|
214
|
+
throw new McpToolError('`message` is empty. Say in a sentence what you needed, what is wrong, or what broke.');
|
|
215
|
+
const page = r.str('page').slice(0, FEEDBACK_MAX);
|
|
216
|
+
record({ kind: r.str('kind'), message: message.slice(0, FEEDBACK_MAX), ...(page ? { page } : {}) }, ctx.request);
|
|
217
|
+
return { recorded: true, ...(message.length > FEEDBACK_MAX ? { keptChars: FEEDBACK_MAX } : {}) };
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/** Whether a result is an empty `listEnvelope` page: a search or listing that found nothing. */
|
|
222
|
+
const foundNothing = (data) => !!data && typeof data === 'object' && data.total === 0 && Array.isArray(data.pages);
|
|
163
223
|
/** How many entries one JSON-RPC batch may carry. Quoted by the conformance suite. */
|
|
164
224
|
export const DEFAULT_MAX_BATCH = 20;
|
|
165
225
|
const quote = (list) => list.map(s => `"${s}"`).join(', ');
|
|
@@ -291,6 +351,11 @@ const methodNotFound = (id, method, config, era) => {
|
|
|
291
351
|
* `resources` whose list comes back empty reads as a bug to a client, not as
|
|
292
352
|
* honesty. One function, so `initialize` and `server/discover` cannot disagree.
|
|
293
353
|
*/
|
|
354
|
+
const listsFeedback = (config) => config.tools.some(t => t.name === FEEDBACK_TOOL);
|
|
355
|
+
/** `instructions`, inviting feedback when the server takes it, so the invitation cannot be the line an app forgets. */
|
|
356
|
+
const instructionsOf = (config) => listsFeedback(config) && !config.instructions.includes(FEEDBACK_TOOL)
|
|
357
|
+
? `${config.instructions.trimEnd()}\n\n${FEEDBACK_INVITE}`
|
|
358
|
+
: config.instructions;
|
|
294
359
|
const capabilitiesOf = (config) => ({
|
|
295
360
|
tools: {},
|
|
296
361
|
...(config.resources?.length ? { resources: {} } : {}),
|
|
@@ -324,7 +389,7 @@ async function handleRpc(req, config, dispatch) {
|
|
|
324
389
|
protocolVersion: negotiateProtocol(p.protocolVersion),
|
|
325
390
|
capabilities: capabilitiesOf(config),
|
|
326
391
|
serverInfo: config.serverInfo,
|
|
327
|
-
instructions: config
|
|
392
|
+
instructions: instructionsOf(config),
|
|
328
393
|
});
|
|
329
394
|
// What `initialize` tells a legacy client, asked the modern way. The
|
|
330
395
|
// transport adds `resultType`, the cache hints and `_meta` serverInfo.
|
|
@@ -332,7 +397,7 @@ async function handleRpc(req, config, dispatch) {
|
|
|
332
397
|
return ok(id, {
|
|
333
398
|
supportedVersions: [...MCP_PROTOCOL_VERSIONS],
|
|
334
399
|
capabilities: capabilitiesOf(config),
|
|
335
|
-
instructions: config
|
|
400
|
+
instructions: instructionsOf(config),
|
|
336
401
|
});
|
|
337
402
|
case 'ping':
|
|
338
403
|
return ok(id, {});
|
|
@@ -399,8 +464,11 @@ async function handleRpc(req, config, dispatch) {
|
|
|
399
464
|
protocolVersion: dispatch.protocolVersion,
|
|
400
465
|
request: dispatch.request,
|
|
401
466
|
};
|
|
467
|
+
// A miss is when an agent knows what it was after, so that is where it hears it can say so.
|
|
468
|
+
const nudge = tool.name !== FEEDBACK_TOOL && listsFeedback(config);
|
|
402
469
|
try {
|
|
403
|
-
const
|
|
470
|
+
const raw = await tool.handler(args, ctx);
|
|
471
|
+
const data = nudge && foundNothing(raw) ? { ...raw, feedback: FEEDBACK_NUDGE } : raw;
|
|
404
472
|
// `?? null`: a handler that returns nothing still owes a string, and
|
|
405
473
|
// JSON.stringify(undefined) is undefined.
|
|
406
474
|
const text = typeof data === 'string' ? data : JSON.stringify(data ?? null, null, 2);
|
|
@@ -423,7 +491,8 @@ async function handleRpc(req, config, dispatch) {
|
|
|
423
491
|
catch (err) {
|
|
424
492
|
if (!(err instanceof McpToolError))
|
|
425
493
|
throw err;
|
|
426
|
-
|
|
494
|
+
const feedback = nudge ? { feedback: FEEDBACK_NUDGE } : {};
|
|
495
|
+
return toolText(id, JSON.stringify({ error: err.message, ...err.details, ...feedback }, null, 2), true);
|
|
427
496
|
}
|
|
428
497
|
}
|
|
429
498
|
default:
|
package/dist/og.d.ts
CHANGED
|
@@ -25,6 +25,23 @@ export declare function bubbles(rand: () => number, o: BubbleOptions): {
|
|
|
25
25
|
background: string;
|
|
26
26
|
art: ReactNode;
|
|
27
27
|
};
|
|
28
|
+
/**
|
|
29
|
+
* A page's banner as the card's field: fetched, cropped to cover 1200×630 and
|
|
30
|
+
* inlined, under the scrim a photograph needs. Null when there is no banner,
|
|
31
|
+
* it is not an https URL on `hosts`, or it cannot be fetched and decoded, and
|
|
32
|
+
* the route draws its generated field instead.
|
|
33
|
+
*
|
|
34
|
+
* `hosts` is where the site stores banners. Fetching whatever `?banner=` named
|
|
35
|
+
* would make `/og` an anonymous proxy to any URL the server reaches, decoded by
|
|
36
|
+
* sharp on the way through. The bytes are inlined as PNG because satori decodes
|
|
37
|
+
* PNG and JPEG, not the WebP or AVIF an upload is stored as. `sharp` is an
|
|
38
|
+
* optional peer, loaded only here; run the route on Node.
|
|
39
|
+
*/
|
|
40
|
+
export declare function bannerField(banner: string | null | undefined, hosts: RegExp): Promise<{
|
|
41
|
+
background: string;
|
|
42
|
+
art: ReactNode;
|
|
43
|
+
scrim: true;
|
|
44
|
+
} | null>;
|
|
28
45
|
export interface SocialCardProps {
|
|
29
46
|
title: string;
|
|
30
47
|
/** The line above the title: a section, a code, a handle. */
|
package/dist/og.js
CHANGED
|
@@ -22,6 +22,44 @@ export function bubbles(rand, o) {
|
|
|
22
22
|
});
|
|
23
23
|
return { background, art };
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* A page's banner as the card's field: fetched, cropped to cover 1200×630 and
|
|
27
|
+
* inlined, under the scrim a photograph needs. Null when there is no banner,
|
|
28
|
+
* it is not an https URL on `hosts`, or it cannot be fetched and decoded, and
|
|
29
|
+
* the route draws its generated field instead.
|
|
30
|
+
*
|
|
31
|
+
* `hosts` is where the site stores banners. Fetching whatever `?banner=` named
|
|
32
|
+
* would make `/og` an anonymous proxy to any URL the server reaches, decoded by
|
|
33
|
+
* sharp on the way through. The bytes are inlined as PNG because satori decodes
|
|
34
|
+
* PNG and JPEG, not the WebP or AVIF an upload is stored as. `sharp` is an
|
|
35
|
+
* optional peer, loaded only here; run the route on Node.
|
|
36
|
+
*/
|
|
37
|
+
export async function bannerField(banner, hosts) {
|
|
38
|
+
if (!banner)
|
|
39
|
+
return null;
|
|
40
|
+
try {
|
|
41
|
+
const url = new URL(banner);
|
|
42
|
+
if (url.protocol !== 'https:' || !hosts.test(url.hostname))
|
|
43
|
+
return null;
|
|
44
|
+
const res = await fetch(url, { redirect: 'error' });
|
|
45
|
+
if (!res.ok)
|
|
46
|
+
return null;
|
|
47
|
+
const { default: sharp } = await import('sharp');
|
|
48
|
+
const png = await sharp(Buffer.from(await res.arrayBuffer()))
|
|
49
|
+
.resize(OG_SIZE.width, OG_SIZE.height, { fit: 'cover' })
|
|
50
|
+
.png()
|
|
51
|
+
.toBuffer();
|
|
52
|
+
const src = `data:image/png;base64,${png.toString('base64')}`;
|
|
53
|
+
return {
|
|
54
|
+
background: '#111',
|
|
55
|
+
art: _jsx("img", { src: src, alt: "", width: OG_SIZE.width, height: OG_SIZE.height, style: { position: 'absolute', width: '100%', height: '100%', objectFit: 'cover' } }),
|
|
56
|
+
scrim: true,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
25
63
|
const SCRIM = 'linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.3) 50%, transparent 100%)';
|
|
26
64
|
/** One card, 1200×630: the eyebrow, title and anything under it, then the description and the mark along the foot. */
|
|
27
65
|
export function SocialCard(p) {
|
package/dist/react-server.d.ts
CHANGED
|
@@ -5,8 +5,8 @@ import type { MaintenanceQueue } from './queues.js';
|
|
|
5
5
|
/**
|
|
6
6
|
* The shape a router's link component has to satisfy here.
|
|
7
7
|
*
|
|
8
|
-
* Deliberately the intersection the three rails
|
|
9
|
-
* use, not a re-declaration of `AnchorHTMLAttributes`: a wider surface would
|
|
8
|
+
* Deliberately the intersection the three rails, three facet bars and the stats
|
|
9
|
+
* page actually use, not a re-declaration of `AnchorHTMLAttributes`: a wider surface would
|
|
10
10
|
* let a consumer pass something `next/link` accepts and a bare `<a>` silently
|
|
11
11
|
* drops. `next/link` satisfies this as it stands.
|
|
12
12
|
*/
|
|
@@ -17,6 +17,11 @@ export interface WikiLinkProps {
|
|
|
17
17
|
'aria-current'?: 'true' | 'page' | undefined;
|
|
18
18
|
/** `prev`/`next` on the foot nav — the half that makes the pair machine-readable. */
|
|
19
19
|
rel?: string;
|
|
20
|
+
/** `switch`, with `aria-checked`, on the stats page's toggle between two views. */
|
|
21
|
+
role?: string;
|
|
22
|
+
'aria-checked'?: boolean;
|
|
23
|
+
/** The name of a link drawn as an icon. */
|
|
24
|
+
'aria-label'?: string;
|
|
20
25
|
children?: ReactNode;
|
|
21
26
|
}
|
|
22
27
|
export type WikiLinkComponent = ComponentType<WikiLinkProps>;
|
package/dist/stats.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
import { type Digest, type StatsFilter } from './analytics.js';
|
|
3
3
|
import type { PasskeyGate } from './passkey.js';
|
|
4
|
+
import { type WikiLinkComponent } from './react-server.js';
|
|
4
5
|
export interface StatsProps {
|
|
5
6
|
/** One window's figures, from `digest` in `wiki-formant/analytics`. */
|
|
6
7
|
digest: Digest;
|
|
@@ -12,6 +13,8 @@ export interface StatsProps {
|
|
|
12
13
|
apart?: boolean;
|
|
13
14
|
/** The windows offered as `?days=` links on the same page. */
|
|
14
15
|
ranges?: readonly number[];
|
|
16
|
+
/** Every link on the page: `next/link`, say, so a window or a filter does not reload it. */
|
|
17
|
+
link?: WikiLinkComponent;
|
|
15
18
|
}
|
|
16
19
|
/**
|
|
17
20
|
* A stats page's query as the window and the filter, both ready for `digest`
|
|
@@ -30,7 +33,7 @@ export declare function statsQuery(params: Record<string, string | string[] | un
|
|
|
30
33
|
* nothing in them are left out. The chart and the bars are `currentColor`
|
|
31
34
|
* until the importing stylesheet gives them a colour.
|
|
32
35
|
*/
|
|
33
|
-
export declare function Stats({ digest: d, days, filter, apart, ranges }: StatsProps): import("react").JSX.Element;
|
|
36
|
+
export declare function Stats({ digest: d, days, filter, apart, ranges, link: Link }: StatsProps): import("react").JSX.Element;
|
|
34
37
|
export interface StatsGateProps {
|
|
35
38
|
gate: PasskeyGate;
|
|
36
39
|
/** The gate's cookie from the request: `(await cookies()).get(gate.cookie)?.value`. */
|
package/dist/stats.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { STATS_FILTERS, count } from './analytics.js';
|
|
3
|
+
import { Anchor } from './react-server.js';
|
|
3
4
|
import { PasskeyButton, PasskeySignOut, StatsFigures } from './stats-figures.js';
|
|
4
5
|
/** The windows the stats page offers, in days; `Infinity` is all time. */
|
|
5
6
|
const STATS_RANGES = [1, 7, 30, 90, 365, Infinity];
|
|
@@ -44,11 +45,11 @@ const country = (code) => {
|
|
|
44
45
|
};
|
|
45
46
|
const NAMES = { page: 'Page', entry: 'Entry page', source: 'Source', country: 'Country', device: 'Device' };
|
|
46
47
|
const DAY_MS = 86_400_000;
|
|
47
|
-
function StatsList({ title, rows, label = v => v, to, }) {
|
|
48
|
+
function StatsList({ title, rows, label = v => v, to, link: Link, }) {
|
|
48
49
|
if (!rows.length)
|
|
49
50
|
return null;
|
|
50
51
|
const max = Math.max(...rows.map(([, n]) => n));
|
|
51
|
-
return (_jsxs("section", { className: "stats-list", children: [_jsx("h2", { children: title }), _jsx("ol", { children: rows.map(([v, n]) => (_jsxs("li", { style: { '--share': n / max }, children: [_jsx("span", { children: to ? _jsx(
|
|
52
|
+
return (_jsxs("section", { className: "stats-list", children: [_jsx("h2", { children: title }), _jsx("ol", { children: rows.map(([v, n]) => (_jsxs("li", { style: { '--share': n / max }, children: [_jsx("span", { children: to ? _jsx(Link, { href: to(v), children: label(v) }) : label(v) }), _jsx("span", { className: "stats-count", children: count(n) })] }, v))) })] }));
|
|
52
53
|
}
|
|
53
54
|
/**
|
|
54
55
|
* A site's own analytics on one page: the headline figures against the window
|
|
@@ -57,7 +58,7 @@ function StatsList({ title, rows, label = v => v, to, }) {
|
|
|
57
58
|
* nothing in them are left out. The chart and the bars are `currentColor`
|
|
58
59
|
* until the importing stylesheet gives them a colour.
|
|
59
60
|
*/
|
|
60
|
-
export function Stats({ digest: d, days, filter = {}, apart = false, ranges = STATS_RANGES }) {
|
|
61
|
+
export function Stats({ digest: d, days, filter = {}, apart = false, ranges = STATS_RANGES, link: Link = Anchor }) {
|
|
61
62
|
const today = Math.floor(Date.now() / DAY_MS);
|
|
62
63
|
const counted = d.counted_from ? Date.parse(d.counted_from) / DAY_MS : today;
|
|
63
64
|
const narrowed = STATS_FILTERS.filter(k => filter[k]);
|
|
@@ -73,8 +74,8 @@ export function Stats({ digest: d, days, filter = {}, apart = false, ranges = ST
|
|
|
73
74
|
const to = (k) => (v) => at({ ...filter, [k]: v });
|
|
74
75
|
// Each window is another digest, so a link rather than a button, pinned
|
|
75
76
|
// with the chart's own controls, and so is the switch that counts agents apart.
|
|
76
|
-
const controls = (_jsxs(_Fragment, { children: [_jsx("div", { role: "group", "aria-label": "Window", children: ranges.map(n => (_jsx(
|
|
77
|
-
return (_jsxs("div", { className: "stats", children: [narrowed.length > 0 && (_jsxs("div", { className: "stats-filters", children: [_jsx("ul", { "aria-label": "Narrowed to", children: narrowed.map(k => (_jsx("li", { children: _jsxs(
|
|
77
|
+
const controls = (_jsxs(_Fragment, { children: [_jsx("div", { role: "group", "aria-label": "Window", children: ranges.map(n => (_jsx(Link, { href: at(filter, n), "aria-current": n === days ? 'page' : undefined, children: windowLabel(n) }, n))) }), _jsx("div", { children: _jsx(Link, { href: at(filter, days, !apart), role: "switch", "aria-checked": !apart, "aria-label": "Agents counted among visitors", title: "Agents counted among visitors", children: _jsxs("svg", { className: "chart-icon", viewBox: "0 0 12 12", "aria-hidden": true, children: [_jsx("path", { d: "M6 1.5v2M1 6.5v2M11 6.5v2", stroke: "currentColor" }), _jsx("rect", { x: "2.5", y: "3.5", width: "7", height: "7", rx: "1.5", fill: "none", stroke: "currentColor" }), _jsx("circle", { cx: "4.75", cy: "7", r: "0.9", fill: "currentColor" }), _jsx("circle", { cx: "7.25", cy: "7", r: "0.9", fill: "currentColor" })] }) }) })] }));
|
|
78
|
+
return (_jsxs("div", { className: "stats", children: [narrowed.length > 0 && (_jsxs("div", { className: "stats-filters", children: [_jsx("ul", { "aria-label": "Narrowed to", children: narrowed.map(k => (_jsx("li", { children: _jsxs(Link, { href: at({ ...filter, [k]: undefined }), title: "Remove", children: [NAMES[k], ": ", k === 'country' ? country(filter[k]) : filter[k], " \u00D7"] }) }, k))) }), start < counted && d.counted_from && (_jsxs("p", { children: ["Narrowed figures start on ", d.counted_from, ", the first day this site counted its own visits."] }))] })), _jsx(StatsFigures, { digest: d, first: first, apart: apart, controls: controls }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { link: Link, title: "Pages", rows: rows(d.top_pages, 'page', 'visitors'), to: to('page') }), _jsx(StatsList, { link: Link, title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors'), to: to('entry') }), _jsx(StatsList, { link: Link, title: "Sources", rows: rows(d.top_sources, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { link: Link, title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors'), to: v => at({ ...filter, source: 'x', entry: v }) }), _jsx(StatsList, { link: Link, title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { link: Link, title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country, to: to('country') }), _jsx(StatsList, { link: Link, title: "Devices", rows: rows(d.devices, 'device', 'visitors'), to: to('device') }), _jsx(StatsList, { link: Link, title: "Agents", rows: rows(d.top_agents, 'agent', 'visitors') }), _jsx(StatsList, { link: Link, title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { link: Link, title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events') })] })] }));
|
|
78
79
|
}
|
|
79
80
|
/**
|
|
80
81
|
* A stats page's door: `children` and a sign-out button for a signed-in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.97.1",
|
|
4
4
|
"description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, conditional-GET plumbing, and the headers and CSP every origin sends.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|