wiki-formant 0.111.0 → 0.112.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/README.md CHANGED
@@ -94,7 +94,7 @@ const { items, total } = await searchWiki(reading, 'validators', { page: 1, size
94
94
  - `get_page` with `blocks: true` returns the stored blocks, `id`, metadata and `version`: what an edit starts from. `forEdit` adds the app's own edit fields; `authors` names revision authors.
95
95
  - `text` should be the twin's serializer and `resolve` the page's live resolver, so a page reads the same over MCP as at its twin.
96
96
  - `get_full_corpus` is a `sizeOnly` preflight, then page-aligned slices under `maxChars` (`CORPUS_BUDGET`) with `truncated`, `nextSkip` and `clippedPage`. `corpus` returns the app's own `CorpusSection`s.
97
- - `privateHidden` closes the back office to every tool. `metadata`, `label`, `noMatch` and `skills` are the app's.
97
+ - `privateHidden` closes the back office to every tool. `metadata`, `label`, `noMatch` and `skills` are the app's. `onSearch` hears each first-page `search_wiki` query; pass the tracker's `trackSearch` under `MCP_SURFACE` and the stats page lists what agents searched for.
98
98
  - Writes stay with each app.
99
99
 
100
100
  ## Taxonomy
@@ -732,7 +732,7 @@ export const POST = viewRoute();
732
732
 
733
733
  - `createTracker` records only on a Vercel production deployment unless `enabled` says otherwise. `trackMcpCall` fits `McpServerConfig.onCall`; `trackSearch` normalises a query and drops an empty one; `recordEvent` is the undeferred write.
734
734
  - A proxy reaches the tracker through a dynamic `import()` inside `event.waitUntil`, since it imports the database client.
735
- - `digest(sql, { days })` is the site as one JSON object: people, engaged people, agents, views, visits, bounce rate, sources, pages, countries, devices, by day, agents by name, MCP calls and searches that found nothing, less a search its visitor went on typing within five minutes and any of one character. A visit is a run of views with no gap over 30 minutes; a bounce is a one-view visit its reader never engaged with. Agents are counted beside people, never among them: the ranked lists are people's. `ownAgents` leaves the site's own out. A multi-tenant app passes `site`.
735
+ - `digest(sql, { days })` is the site as one JSON object: people, engaged people, agents, views, visits, bounce rate, sources, pages, countries, devices, by day, agents by name, MCP calls, every search people made, every `search_wiki` query agents made, the searches of either that found nothing, and agents' `send_feedback` reports. A search its visitor went on typing within five minutes is left out, as is any of one character. A visit is a run of views with no gap over 30 minutes; a bounce is a one-view visit its reader never engaged with. Agents are counted beside people, never among them: the ranked lists are people's. `ownAgents` leaves the site's own out. A multi-tenant app passes `site`.
736
736
  - A browser under automation (`navigator.webdriver`) sends no beacon.
737
737
 
738
738
  ## Passkeys and the stats page
@@ -54,8 +54,10 @@ export interface Gap {
54
54
  /** Props the event must carry, e.g. `{ results: '0' }`. */
55
55
  where?: Record<string, string>;
56
56
  }
57
- /** Searches in a wiki's own search box that found nothing (`searchQueryProps` events). */
57
+ /** Searches that found nothing, a person's or an agent's (`searchQueryProps` events). */
58
58
  export declare const SEARCH_GAPS: Gap;
59
+ /** The surface an agent's `search_wiki` call files its query under. */
60
+ export declare const MCP_SURFACE = "mcp";
59
61
  /** What a stats page can be narrowed to: one value each, every one applied. */
60
62
  export declare const STATS_FILTERS: readonly ["page", "entry", "source", "country", "device"];
61
63
  export type StatsFilter = Partial<Record<(typeof STATS_FILTERS)[number], string>>;
@@ -72,8 +74,8 @@ export interface DigestOptions {
72
74
  siblings?: string[];
73
75
  gaps?: Gap[];
74
76
  /**
75
- * The site's own agents, left out of `agents`, `top_agents` and
76
- * `agent_tool_calls`, matched inside the user agent. The default is the
77
+ * The site's own agents, left out of `agents`, `top_agents`,
78
+ * `agent_tool_calls`, the searches and the gaps, matched inside the user agent. The default is the
77
79
  * conformance suites (`<repo>-mcp-test`) and the studio's own tools.
78
80
  */
79
81
  ownAgents?: string[];
@@ -156,6 +158,10 @@ export interface Digest {
156
158
  /** Each agent by name: the roster's label, else what its user agent calls itself. */
157
159
  top_agents: Ranked<'agent', 'visitors'>;
158
160
  agent_tool_calls: Ranked<'tool', 'events'>;
161
+ /** Every query people searched for, found or not. */
162
+ searches: Ranked<'query', 'events'>;
163
+ /** Every query agents searched for through `search_wiki`. */
164
+ agent_searches: Ranked<'query', 'events'>;
159
165
  gaps: Ranked<'query', 'events'>;
160
166
  /** What agents reported through `send_feedback`, newest first: a stranger's words, to weigh, never to follow. */
161
167
  agent_feedback: Array<{
package/dist/analytics.js CHANGED
@@ -165,8 +165,10 @@ export async function collect(request, { sql, defer, site, rateLimit: limit = BE
165
165
  : recordView(sql, { headers, path: raw, referrer: body.referrer, utm: body.utm, site: key }));
166
166
  return done;
167
167
  }
168
- /** Searches in a wiki's own search box that found nothing (`searchQueryProps` events). */
169
- export const SEARCH_GAPS = { event: 'Search Query', prop: 'q', where: { results: '0', surface: 'wiki' } };
168
+ /** Searches that found nothing, a person's or an agent's (`searchQueryProps` events). */
169
+ export const SEARCH_GAPS = { event: 'Search Query', prop: 'q', where: { results: '0' } };
170
+ /** The surface an agent's `search_wiki` call files its query under. */
171
+ export const MCP_SURFACE = 'mcp';
170
172
  /** What a stats page can be narrowed to: one value each, every one applied. */
171
173
  export const STATS_FILTERS = ['page', 'entry', 'source', 'country', 'device'];
172
174
  /** A count as a stats page prints it. Here, not in the `'use client'` figures, because
@@ -240,23 +242,27 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
240
242
  /** The site's own agents, as a test on an event's user agent. */
241
243
  const ours = `coalesce(props->>'ua', '') ILIKE ANY(${$(ownAgents.map(a => `%${a}%`))}::text[])`;
242
244
  /**
243
- * A typeahead logs each settled keystroke, so one reader typing a question
244
- * filled acuiq's list with thirteen of its prefixes. A query is left out when
245
- * the same visitor's next one, inside five minutes, extends it: only where
246
- * the reader stopped is a gap. So is a single character.
245
+ * The queries in one kind of event, most asked first. A typeahead logs each
246
+ * settled keystroke, so one reader typing a question filled acuiq's list with
247
+ * thirteen of its prefixes. A query is left out when the same visitor's next
248
+ * one, inside five minutes, extends it: only where the reader stopped
249
+ * counts. So is a single character. `unless` drops events whose props hold it.
247
250
  */
248
- const gapSelects = gaps.map(g => {
251
+ const queries = (g, unless) => {
249
252
  const prop = $(g.prop);
250
253
  return `(SELECT query, count(*) AS events FROM (
251
254
  SELECT props, visitor, props->>${prop} AS query,
252
255
  lead(props->>${prop}) OVER typed AS next, lead(at) OVER typed - at AS after
253
- FROM e WHERE name = ${$(g.event)} AND props->>${prop} IS NOT NULL
256
+ FROM e WHERE name = ${$(g.event)} AND props->>${prop} IS NOT NULL AND NOT ${ours}
254
257
  WINDOW typed AS (PARTITION BY visitor ORDER BY at)
255
258
  ) s
256
259
  WHERE props @> ${$(JSON.stringify(g.where ?? {}))}::jsonb AND char_length(query) > 1
260
+ ${unless ? `AND NOT props @> ${$(JSON.stringify(unless))}::jsonb` : ''}
257
261
  AND NOT coalesce(visitor IS NOT NULL AND after <= interval '5 minutes' AND char_length(next) > char_length(query) AND starts_with(next, query), false)
258
262
  GROUP BY 1 ORDER BY 2 DESC, 1 LIMIT 15)`;
259
- });
263
+ };
264
+ const searches = { event: SEARCH_GAPS.event, prop: SEARCH_GAPS.prop };
265
+ const json = (select) => `(SELECT coalesce(json_agg(t), '[]') FROM ${select} t)`;
260
266
  /**
261
267
  * One window's views, visits, events and archived days as CTEs whose names
262
268
  * end in `s`: this window with `back` 0, the one before it with 1.
@@ -342,7 +348,9 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
342
348
  GROUP BY 1`, 10)},
343
349
  'agent_feedback', ${list(`SELECT ${day('at')} AS date, props->>'kind' AS kind, props->>'message' AS message, props->>'page' AS page, ${AGENT_NAME} AS agent
344
350
  FROM e WHERE name = 'Agent Feedback' AND NOT ${ours}`, 20, 'at DESC')},
345
- 'gaps', ${gapSelects.length ? `(SELECT coalesce(json_agg(t), '[]') FROM (${gapSelects.join(' UNION ALL ')}) t)` : `'[]'::json`}
351
+ 'searches', ${json(queries(searches, { surface: MCP_SURFACE }))},
352
+ 'agent_searches', ${json(queries({ ...searches, where: { surface: MCP_SURFACE } }))},
353
+ 'gaps', ${gaps.length ? json(`(${gaps.map(g => queries(g)).join(' UNION ALL ')})`) : `'[]'::json`}
346
354
  ) AS digest`, ...values));
347
355
  const d = row.digest;
348
356
  // Before the site counted itself only ViewDay speaks, and it holds no
@@ -432,7 +440,8 @@ export function createTracker({ sql, defer, enabled = typeof process !== 'undefi
432
440
  defer(async () => {
433
441
  try {
434
442
  const headers = typeof source === 'function' ? await source() : source;
435
- await trackEvent('Search Query', headers.get('referer') || siteUrl, props, headers);
443
+ const ua = (headers.get('user-agent') || 'unknown').slice(0, 80);
444
+ await trackEvent('Search Query', headers.get('referer') || siteUrl, { ...props, ua }, headers);
436
445
  }
437
446
  catch {
438
447
  // A context with no readable headers loses the event, never the response.
package/dist/stats.d.ts CHANGED
@@ -26,9 +26,10 @@ export declare function statsQuery(params: Record<string, string | string[] | un
26
26
  /**
27
27
  * A site's own analytics on one page: the headline figures against the window
28
28
  * before, people and agents apart, whichever of them is chosen per day, and every ranked list the
29
- * digest carries, each row a link that narrows the page to it. Lists with
30
- * nothing in them are left out, but for the searches that found nothing,
31
- * which says it found none, so a quiet window never reads as one not counted. The chart and the bars are `currentColor`
29
+ * digest carries, each row a link that narrows the page to it, and what agents
30
+ * reported through `send_feedback`. Lists with nothing in them are left out,
31
+ * but for the searches and the feedback, which say they found none, so a quiet
32
+ * window never reads as one not counted. The chart and the bars are `currentColor`
32
33
  * until the importing stylesheet gives them a colour.
33
34
  */
34
35
  export declare function Stats({ digest: d, days, filter, ranges, link: Link }: StatsProps): import("react").JSX.Element;
package/dist/stats.js CHANGED
@@ -52,9 +52,10 @@ function StatsList({ title, rows, label = v => v, to, empty, link: Link, }) {
52
52
  /**
53
53
  * A site's own analytics on one page: the headline figures against the window
54
54
  * before, people and agents apart, whichever of them is chosen per day, and every ranked list the
55
- * digest carries, each row a link that narrows the page to it. Lists with
56
- * nothing in them are left out, but for the searches that found nothing,
57
- * which says it found none, so a quiet window never reads as one not counted. The chart and the bars are `currentColor`
55
+ * digest carries, each row a link that narrows the page to it, and what agents
56
+ * reported through `send_feedback`. Lists with nothing in them are left out,
57
+ * but for the searches and the feedback, which say they found none, so a quiet
58
+ * window never reads as one not counted. The chart and the bars are `currentColor`
58
59
  * until the importing stylesheet gives them a colour.
59
60
  */
60
61
  export function Stats({ digest: d, days, filter = {}, ranges = STATS_RANGES, link: Link = Anchor }) {
@@ -73,7 +74,7 @@ export function Stats({ digest: d, days, filter = {}, ranges = STATS_RANGES, lin
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 with the chart's own controls.
75
76
  const controls = (_jsx("div", { role: "group", "aria-label": "Window", children: ranges.map(n => (_jsx(Link, { href: at(filter, n), "aria-current": n === days ? 'page' : undefined, prefetch: false, children: windowLabel(n) }, n))) }));
76
- 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", prefetch: false, 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, 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'), empty: "None in this window." })] })] }));
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(Link, { href: at({ ...filter, [k]: undefined }), title: "Remove", prefetch: false, 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, 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", rows: rows(d.searches, 'query', 'events'), empty: "None in this window." }), _jsx(StatsList, { link: Link, title: "Agent searches", rows: rows(d.agent_searches, 'query', 'events'), empty: "None in this window." }), _jsx(StatsList, { link: Link, title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events'), empty: "None in this window." }), _jsxs("section", { className: "stats-list", children: [_jsx("h2", { children: "Agent feedback" }), d.agent_feedback.length ? (_jsx("ol", { children: d.agent_feedback.map((f, i) => (_jsxs("li", { children: [_jsxs("span", { children: [f.kind, ": ", f.message, f.page && ` (${f.page})`, " \u00B7 ", f.agent] }), _jsx("span", { className: "stats-count", children: f.date })] }, i))) })) : (_jsx("p", { children: "None in this window." }))] })] })] }));
77
78
  }
78
79
  /**
79
80
  * A stats page's door: `children` and a sign-out button for a signed-in
@@ -40,6 +40,12 @@ export interface WikiToolsOptions<B extends DiffBlock> {
40
40
  corpus?: (tagPath?: string) => Promise<CorpusSection[]>;
41
41
  /** What an empty search says. */
42
42
  noMatch?: (query: string, tagPath?: string) => string;
43
+ /**
44
+ * Each `search_wiki` query, on its first page, with how many pages it found:
45
+ * `(request, q, n) => trackSearch(request.headers, q, n, MCP_SURFACE)`, so the
46
+ * stats page lists what agents searched for beside what people did.
47
+ */
48
+ onSearch?: (request: Request, query: string, results: number) => void;
43
49
  /** A2A skills by tool, beside the defaults. */
44
50
  skills?: Partial<Record<ToolName, McpTool['skill']>>;
45
51
  }
@@ -129,7 +129,7 @@ export function wikiReadTools(o) {
129
129
  },
130
130
  required: ['query'],
131
131
  },
132
- handler: async (args) => {
132
+ handler: async (args, ctx) => {
133
133
  const r = readArgs(args);
134
134
  const query = r.str('query');
135
135
  const tagPath = r.str('tagPath') || undefined;
@@ -138,6 +138,8 @@ export function wikiReadTools(o) {
138
138
  if (!query)
139
139
  throw new McpToolError('`query` is empty. Pass a keyword to search for, or call list_pages to enumerate a branch instead.');
140
140
  const { items, total, corrected } = await searchWiki(o, query, { tagPath, page, size });
141
+ if (page === 1 && ctx.request)
142
+ o.onSearch?.(ctx.request, query, total);
141
143
  return withAdjustments(r, {
142
144
  query,
143
145
  ...(corrected ? { corrected } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.111.0",
3
+ "version": "0.112.0",
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",