wiki-formant 0.61.0 → 0.63.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
@@ -41,7 +41,9 @@ await store.save({ tagPath, slug, content, authorId, message, baseVersion: page.
41
41
  - **The bump is the diff's.** Added, removed or moved blocks are `major`, changed prose or title `minor`; `change` overrides it for a script that knows better. A metadata-only save moves no version and writes no revision.
42
42
  - **History is diffed on read.** A revision stores the page as it was saved, never a diff, so a script's revision and the editor's carry the same history. `history(pageId, { limit })` reads one row more than it returns, so the oldest entry shown is diffed against its real predecessor.
43
43
  - **Listings carry a snippet, not the article.** `list`, `recent` and `byIds` drop `content` for one line of it; a category of 200 cards otherwise ships 200 block arrays.
44
- - **Search is four tiers**: title prefix, title, literal prose, then full text on `search_tsv` with Postgres' own headline as the snippet. Hidden paths are left out unless the search names one.
44
+ - **Search is four tiers**: title prefix, title, literal prose, then full text on `search_tsv` with Postgres' own headline as the snippet. Hidden paths are left out unless the search names one; `content: true` keeps each row's blocks.
45
+ - **A path under a hidden one is hidden too**, in every listing, whether or not the tree names it.
46
+ - **`branch` is an agent's walk**: a path and everything under it, newest first or A–Z, with the total a pager needs and an optional `since`. `counts` is pages per tag path, the back office and the home page left out.
45
47
  - Ids and timestamps default in the database, so the store's SQL and a Prisma client write the same rows. Copy the models with `npx prisma-models wiki` (a tenant-keyed app checks its own copy with `--tenant`), then build the full-text column with `npx fts-ddl '"WikiPage"'`.
46
48
 
47
49
  ## Tree
@@ -61,6 +63,37 @@ parseWikiPath(tree, ['contents', 'tech', 'radix-engine', 'history']);
61
63
  - A path that is itself a category wins over a mode suffix, so a category named `history` stays reachable. The empty slug is a category's hub article, and `/<category>/edit` edits it.
62
64
  - `crumbs` makes one crumb per URL segment, linked only where the tree knows the segment, so a trail can never be shorter than the address bar.
63
65
 
66
+ ## Category view
67
+
68
+ `categoryView` is a category page as radix.wiki browses one, from the tree, the taxonomy and anything that reads like the store: the count, a sort that shows only once the set needs an A–Z index (by a `date` key where the category declares one), the facets its `select` keys give, a letter that exists, and a card per visible section listing its own pages, then its sections.
69
+
70
+ ```ts
71
+ import { categoryView } from 'wiki-formant/category';
72
+
73
+ const view = await categoryView({ tree, taxonomy, pages: store }, 'ecosystem', searchParams);
74
+ // { sort, total, pages, all, filters, letter, facets, letters, sortable, clearHref, sortHref, sections }
75
+ ```
76
+
77
+ - `pages` is the store, or the app's cached and decorated wrapper over its `list` and `subtree`.
78
+ - A link carries `sort` only when it is not the category's own, so the bare category and its default are one address.
79
+
80
+ ## Wiki read tools
81
+
82
+ `wikiReadTools` is the read half of a wiki's MCP server over its store and tree: `search_wiki`, `get_page`, `get_pages`, `list_pages`, `get_categories`, `get_recent_changes` and `get_full_corpus`. `searchWiki` is the same rows for an app's plain-GET `?q=` and its search page, so the three never disagree.
83
+
84
+ ```ts
85
+ import { wikiReadTools, searchWiki } from 'wiki-formant/wiki-tools';
86
+
87
+ const reading = { store, tree, url: (tagPath, slug) => `${SITE}/${tagPath}/${slug}`, text: extractText, noun: 'the Radix Wiki', corpusTitle: 'Radix Wiki' };
88
+ const tools = [...wikiReadTools(reading), ...myOwnTools];
89
+ const { items, total } = await searchWiki(reading, 'validators', { page: 1, size: 20 });
90
+ ```
91
+
92
+ - Every row carries `path`, which every read tool takes back, and `chars` where the page's text was read. A pasted URL resolves, with `pathPrefix` stripped; a category's path reads its hub article; a miss names the nearest pages.
93
+ - `privateHidden` closes the back office to every tool; without it a hidden page is unlisted but readable by path, as its URL is.
94
+ - `metadata`, `label`, `corpus`, `noMatch` and `skills` are the app's: what a row adds, how a category is named, the sections its llms-full.txt is made of, what an empty search says, and the A2A skills.
95
+ - Writes stay with each app, because who may write is the one thing no two of them agree on.
96
+
64
97
  ## Taxonomy
65
98
 
66
99
  `tagPath` puts a page in exactly one place in a tree. The `select`-typed metadata it already carries is a *cross-cutting* axis — and it is almost always stored, rendered once in an infobox as dead text, and never made pressable. The failure mode is a 141-page category rendered as one flat grid while the data to split it sits unread in a JSON column.
@@ -653,7 +686,7 @@ leafBlocks(blocks, BLOCK_SHAPE.containers);
653
686
 
654
687
  The dispatch over a repo's own block union stays in that repo; the bodies come from here. `coreAtomicText` has a prose body for every core leaf — the stats, link-grid and page-list bodies joined the originals when two of the three wikis turned out to extract nothing from them, so their MCP `get_page` answered a hub page as nearly empty.
655
688
 
656
- The core leaves — `content`, `codeTabs`, `banner`, `references`, `stats`, `linkGrid`, `recentPages`, `pageList` — had the same case bodies in every wiki's text export, markdown twin, validator and new-block record. A repo's switch hands those cases over and keeps its own, so it stays exhaustive over its union:
689
+ The core leaves — `content`, `codeTabs`, `banner`, `references`, `stats`, `linkGrid`, `testimonial`, `recentPages`, `pageList` — had the same case bodies in every wiki's text export, markdown twin, validator and new-block record. A repo's switch hands those cases over and keeps its own, so it stays exhaustive over its union:
657
690
 
658
691
  ```ts
659
692
  import { coreAtomicText, coreAtomicToMarkdown, coreBlockDefaults } from 'wiki-formant/blocks';
@@ -788,9 +821,11 @@ The dispatch stays a `switch` over the wiki's own union, so a new type is a comp
788
821
 
789
822
  An editor that draws each block in place, with a list of its own (miow's), hosts the same fields in `<BlockFieldsKit kit={KIT}>`, without `<BlockEditor>`. Only the two container fields need the full editor, since they hold a block list.
790
823
 
824
+ A `testimonial` is `{ quote, author, role?, avatarUrl? }`, drawn by `TestimonialView` as `figure.testimonial`: the words in a `blockquote`, then a `figcaption` holding the photo and a `span` of `strong` (who) and `small` (their role). radix-wiki and miow each had one under its own field names; a site with sized images passes `picture`, which turns the photo's URL into its `<img>` attributes.
825
+
791
826
  A banner with no text of its own shows `BANNER_MESSAGES` from `wiki-formant/text`, a sentence per variant that names no wiki; `BannerView` takes it unless `message` overrides it.
792
827
 
793
- The core blocks are `ContentBlock`, `CodeTabsBlock`, `StatsBlock`, `LinkGridBlock`, `ReferencesBlock`, `BannerBlock`, `ColumnsBlock<A>` and `InfoboxBlock<A>`, `A` being the wiki's atomic union. A wiki builds its union from them beside its own `recentPages` and `pageList`, which hold its own page rows or a section, and its native types.
828
+ The core blocks are `ContentBlock`, `CodeTabsBlock`, `StatsBlock`, `LinkGridBlock`, `ReferencesBlock`, `BannerBlock`, `TestimonialBlock`, `ColumnsBlock<A>` and `InfoboxBlock<A>`, `A` being the wiki's atomic union. A wiki builds its union from them beside its own `recentPages` and `pageList`, which hold its own page rows or a section, and its native types.
794
829
 
795
830
  ## Analytics
796
831
 
@@ -1,4 +1,4 @@
1
- import { type ReactNode } from 'react';
1
+ import { type ImgHTMLAttributes, type ReactNode } from 'react';
2
2
  import type { CodeTab, LinkGridGroup, ReferenceItem, ResolvedPageRef, StatItem } from './blocks.js';
3
3
  import type { WikiLinkComponent } from './react-server.js';
4
4
  export interface CodeTabsViewProps {
@@ -104,6 +104,16 @@ export declare function StatsView({ items, columns, }: {
104
104
  })[];
105
105
  columns?: number;
106
106
  }): import("react").JSX.Element | null;
107
+ export interface TestimonialViewProps {
108
+ quote: string;
109
+ author: string;
110
+ role?: string;
111
+ avatarUrl?: string;
112
+ /** The photo's `<img>` attributes; a site with an image pipeline passes its sized `srcSet`. Default the bare URL. */
113
+ picture?: (src: string) => ImgHTMLAttributes<HTMLImageElement>;
114
+ }
115
+ /** Someone's words over who said them, with their photo when there is one. */
116
+ export declare function TestimonialView({ quote, author, role, avatarUrl, picture }: TestimonialViewProps): import("react").JSX.Element | null;
107
117
  /**
108
118
  * A maintenance notice.
109
119
  *
@@ -114,6 +114,13 @@ export function StatsView({ items, columns = 4, }) {
114
114
  return null;
115
115
  return (_jsx("div", { className: cx('stat-grid', `stat-grid-${columns}`), children: rows.map((item, i) => (_jsxs("div", { className: "stat-card", children: [_jsxs("span", { className: "stat-value", children: [item.value, item.suffix && _jsx("span", { className: "stat-suffix", children: item.suffix })] }), _jsx("span", { className: "stat-label", children: item.label })] }, item.id ?? i))) }));
116
116
  }
117
+ /** Someone's words over who said them, with their photo when there is one. */
118
+ export function TestimonialView({ quote, author, role, avatarUrl, picture = src => ({ src }) }) {
119
+ if (!quote.trim())
120
+ return null;
121
+ const photo = avatarUrl && safeLinkHref(avatarUrl);
122
+ return (_jsxs("figure", { className: "testimonial", children: [_jsx("blockquote", { children: quote }), _jsxs("figcaption", { children: [photo && _jsx("img", { ...picture(photo), alt: "", loading: "lazy" }), _jsxs("span", { children: [_jsx("strong", { children: author }), role && _jsx("small", { children: role })] })] })] }));
123
+ }
117
124
  // ---- banner -----------------------------------------------------------------
118
125
  /**
119
126
  * A maintenance notice.
package/dist/blocks.d.ts CHANGED
@@ -94,6 +94,15 @@ export interface ReferencesBlock {
94
94
  title?: string;
95
95
  items: EditableReferenceItem[];
96
96
  }
97
+ /** Someone's words, as they said them, and who they are. */
98
+ export interface TestimonialBlock {
99
+ id: string;
100
+ type: 'testimonial';
101
+ quote: string;
102
+ author: string;
103
+ role?: string;
104
+ avatarUrl?: string;
105
+ }
97
106
  /** A Wikipedia-style notice. `text` replaces the variant's standard message. */
98
107
  export interface BannerBlock {
99
108
  id: string;
@@ -247,6 +256,11 @@ export type CoreAtomicBlock<P = ResolvedPageRef> = {
247
256
  type: 'linkGrid';
248
257
  intro?: string | null;
249
258
  groups: readonly LinkGridGroup[];
259
+ } | {
260
+ type: 'testimonial';
261
+ quote: string;
262
+ author: string;
263
+ role?: string | null;
250
264
  } | {
251
265
  type: 'recentPages' | 'pageList';
252
266
  resolvedPages?: readonly P[] | null;
@@ -346,6 +360,11 @@ export interface CoreBlockDefaults {
346
360
  type: 'banner';
347
361
  variant: 'stub';
348
362
  };
363
+ testimonial: () => {
364
+ type: 'testimonial';
365
+ quote: string;
366
+ author: string;
367
+ };
349
368
  }
350
369
  /**
351
370
  * The new-block factories for the core types, which all three wikis had typed
package/dist/blocks.js CHANGED
@@ -12,7 +12,7 @@
12
12
  // keeps this file free of any one repo's type union.
13
13
  import { htmlToMarkdown, inlineToMarkdown } from './markdown.js';
14
14
  import { relativeTime } from './freshness.js';
15
- import { BANNER_LABELS, bannerToText, codeTabsToText, linkGridToText, pageListToText, referencesToText, statsToText, stripHtml, } from './text.js';
15
+ import { BANNER_LABELS, bannerToText, codeTabsToText, linkGridToText, pageListToText, referencesToText, statsToText, stripHtml, testimonialToText, } from './text.js';
16
16
  // ---- the shapes the standard block types carry ------------------------------
17
17
  /**
18
18
  * The languages a code block or code tab offers, and the one a new one opens
@@ -188,6 +188,10 @@ export function statsToMarkdown(items) {
188
188
  return '';
189
189
  return items.map(s => `- **${s.value}${s.suffix ?? ''}** — ${s.label}`).join('\n');
190
190
  }
191
+ /** A quotation over its attribution. @internal */
192
+ export function testimonialToMarkdown(quote, author, role) {
193
+ return `> ${quote.trim().replace(/\n+/g, '\n> ')}\n>\n> — ${[author, role].filter(Boolean).join(', ')}`;
194
+ }
191
195
  /** Grouped links under their headings, with an optional lead paragraph. @internal */
192
196
  export function linkGridToMarkdown(groups, intro) {
193
197
  const lead = intro ? `${inlineToMarkdown(intro)}\n\n` : '';
@@ -216,6 +220,7 @@ export function coreAtomicText(block) {
216
220
  case 'references': return referencesToText(block.items);
217
221
  case 'stats': return statsToText(block.items);
218
222
  case 'linkGrid': return linkGridToText(block.groups, block.intro);
223
+ case 'testimonial': return testimonialToText(block.quote, block.author, block.role);
219
224
  case 'recentPages':
220
225
  case 'pageList': return pageListToText(block.resolvedPages ?? []);
221
226
  }
@@ -229,6 +234,7 @@ export function coreAtomicToMarkdown(block, opts) {
229
234
  case 'references': return referencesToMarkdown(block.items, block.title || opts.referencesTitle || 'References');
230
235
  case 'stats': return statsToMarkdown(block.items);
231
236
  case 'linkGrid': return linkGridToMarkdown(block.groups, block.intro);
237
+ case 'testimonial': return testimonialToMarkdown(block.quote, block.author, block.role);
232
238
  case 'recentPages':
233
239
  case 'pageList':
234
240
  return linkList((block.resolvedPages ?? []).map(p => ({
@@ -268,5 +274,6 @@ export function coreBlockDefaults(opts = {}) {
268
274
  linkGrid: () => ({ type: 'linkGrid', groups: [{ id: newId(), heading: 'Group', links: [] }] }),
269
275
  references: () => ({ type: 'references', title: referencesTitle, items: [] }),
270
276
  banner: () => ({ type: 'banner', variant: 'stub' }),
277
+ testimonial: () => ({ type: 'testimonial', quote: '', author: '' }),
271
278
  };
272
279
  }
@@ -0,0 +1,64 @@
1
+ import type { ListSort, WikiPageRef } from './store.js';
2
+ import type { Taxonomy, FacetablePage, FacetFilters, FacetControlGroup, Control } from './taxonomy.js';
3
+ import { type WikiNode, type WikiTree } from './tree.js';
4
+ /** What the view reads pages through: the store, or the app's cached and decorated wrappers over it. */
5
+ export interface CategoryReader<P> {
6
+ list: (tagPath: string, opts: {
7
+ sort: ListSort;
8
+ limit: number;
9
+ }) => Promise<P[]>;
10
+ subtree: (tagPath: string) => Promise<WikiPageRef[]>;
11
+ }
12
+ export interface CategoryOptions {
13
+ /** A page's address. Defaults to `wikiPath`; a wiki mounted under a prefix adds it. */
14
+ href?: (tagPath: string, slug: string) => string;
15
+ /** How many of a section's own pages its card lists. */
16
+ cardPages?: number;
17
+ /** The most pages one category reads. */
18
+ limit?: number;
19
+ }
20
+ export interface CategorySection {
21
+ name: string;
22
+ href: string;
23
+ description?: string;
24
+ /** Pages in the section's whole branch. */
25
+ total: number;
26
+ /** Its own pages, in that listing's order, then its sections, so neither half of a mixed one hides. */
27
+ links: {
28
+ title: string;
29
+ href: string;
30
+ }[];
31
+ }
32
+ export type CategoryQuery = Record<string, string | string[] | undefined>;
33
+ /**
34
+ * A category's pages narrowed by its facets and an A–Z letter, sorted A–Z or
35
+ * newest first (by a `date` key where the category declares one), and a card
36
+ * per visible child. A set small enough to need no index offers no sort: it is
37
+ * A–Z.
38
+ */
39
+ export declare function categoryView<P extends FacetablePage & {
40
+ tagPath: string;
41
+ slug: string;
42
+ }>({ tree, taxonomy, pages: reader }: {
43
+ tree: WikiTree<WikiNode>;
44
+ taxonomy: Taxonomy;
45
+ pages: CategoryReader<P>;
46
+ }, tagPath: string, query: CategoryQuery, { href, cardPages, limit }?: CategoryOptions): Promise<{
47
+ sort: "title" | "recent";
48
+ filters: FacetFilters;
49
+ letter: string | undefined;
50
+ total: number;
51
+ pages: P[];
52
+ /** Every page before narrowing, for what describes the category as a whole (its JSON-LD). */
53
+ all: P[];
54
+ facets: FacetControlGroup[];
55
+ letters: Control[];
56
+ sortable: boolean;
57
+ clearHref: string | undefined;
58
+ sortHref: (to: "title" | "recent") => string;
59
+ sections: CategorySection[];
60
+ }>;
61
+ export type CategoryView<P> = Awaited<ReturnType<typeof categoryView<P & FacetablePage & {
62
+ tagPath: string;
63
+ slug: string;
64
+ }>>>;
@@ -0,0 +1,65 @@
1
+ // category.ts – a category page as radix-wiki browses one, from the tree, the
2
+ // taxonomy and the store.
3
+ //
4
+ // radix-wiki assembled this inside its route and miow copied it line for line:
5
+ // the count, a sort that only shows once the set needs an A–Z index, the facets
6
+ // its `select` keys give, a letter that exists, and a card per section listing
7
+ // what it holds. acuiq2 kept a subset that had drifted (no sort, no cards). The
8
+ // view is the app's; the arithmetic is here.
9
+ import { wikiPath } from './tree.js';
10
+ const one = (v) => (Array.isArray(v) ? v[0] : v);
11
+ /**
12
+ * A category's pages narrowed by its facets and an A–Z letter, sorted A–Z or
13
+ * newest first (by a `date` key where the category declares one), and a card
14
+ * per visible child. A set small enough to need no index offers no sort: it is
15
+ * A–Z.
16
+ */
17
+ export async function categoryView({ tree, taxonomy, pages: reader }, tagPath, query, { href = wikiPath, cardPages = 6, limit = 2000 } = {}) {
18
+ const resolved = tree.resolve(tagPath);
19
+ const order = (path, sort) => sort === 'recent' && tree.metadataKeys(path).some(k => k.key === 'date' && k.type === 'date') ? 'date' : sort;
20
+ const fallback = (path) => (tree.resolve(path).sort === 'title' ? 'title' : 'recent');
21
+ const asked = one(query.sort);
22
+ const requested = asked === 'title' || asked === 'recent' ? asked : fallback(tagPath);
23
+ const fetched = await reader.list(tagPath, { sort: order(tagPath, requested), limit });
24
+ const sort = taxonomy.needsAlphaIndex(fetched.length) ? requested : 'title';
25
+ const all = sort === requested ? fetched : await reader.list(tagPath, { sort: order(tagPath, sort), limit });
26
+ const filters = taxonomy.facetFilters(tagPath, query);
27
+ const letter = taxonomy.resolveLetter(all, filters, one(query.letter)?.toUpperCase());
28
+ const sortable = taxonomy.needsAlphaIndex(all.length);
29
+ // A link carries the sort only when it is not the category's own, so the bare
30
+ // category and its default are one address, not two.
31
+ const state = { sort: sortable && sort !== fallback(tagPath) ? sort : undefined, filters, letter };
32
+ const children = (resolved.node?.children ?? []).filter(c => !c.hidden);
33
+ const refs = children.length ? await reader.subtree(tagPath) : [];
34
+ const sections = await Promise.all(children.map(async (child) => {
35
+ const path = `${tagPath}/${child.slug}`;
36
+ const direct = await reader.list(path, { sort: order(path, fallback(path)), limit: cardPages });
37
+ return {
38
+ name: child.name,
39
+ href: href(path, ''),
40
+ description: child.description,
41
+ total: refs.filter(r => r.tagPath === path || r.tagPath.startsWith(`${path}/`)).length,
42
+ links: [
43
+ ...direct.slice(0, cardPages).map(p => ({ title: p.title, href: href(p.tagPath, p.slug) })),
44
+ ...(child.children ?? []).filter(g => !g.hidden).map(g => ({ title: g.name, href: href(`${path}/${g.slug}`, '') })),
45
+ ],
46
+ };
47
+ }));
48
+ const facets = taxonomy.facetControls(tagPath, all, state);
49
+ const letters = taxonomy.alphaControls(tagPath, all, state);
50
+ return {
51
+ sort,
52
+ filters,
53
+ letter,
54
+ total: all.length,
55
+ pages: taxonomy.filterPages(all, filters, letter),
56
+ /** Every page before narrowing, for what describes the category as a whole (its JSON-LD). */
57
+ all,
58
+ facets,
59
+ letters,
60
+ sortable,
61
+ clearHref: Object.keys(filters).length || letter ? taxonomy.href(tagPath, { sort: state.sort }) : undefined,
62
+ sortHref: (to) => taxonomy.href(tagPath, { ...state, sort: to === fallback(tagPath) ? undefined : to }),
63
+ sections,
64
+ };
65
+ }
package/dist/store.d.ts CHANGED
@@ -137,17 +137,47 @@ export declare function wikiStore<B extends DiffBlock = DiffBlock>(o: WikiStoreO
137
137
  byIds: (ids: readonly string[]) => Promise<WikiPageListing[]>;
138
138
  refs: () => Promise<Omit<WikiPageRow<unknown>, "content">[]>;
139
139
  corpus: () => Promise<WikiPageRow<B>[]>;
140
+ branch: ({ tagPath, sort, limit, offset, since }?: {
141
+ tagPath?: string;
142
+ sort?: ListSort;
143
+ limit?: number;
144
+ offset?: number;
145
+ since?: Date;
146
+ }) => Promise<{
147
+ items: {
148
+ content: B[];
149
+ title: string;
150
+ metadata: PageMetadata;
151
+ id: string;
152
+ tagPath: string;
153
+ slug: string;
154
+ siteId: string;
155
+ version: string;
156
+ authorId: string | null;
157
+ lastVerifiedAt: Date | null;
158
+ createdAt: Date;
159
+ updatedAt: Date;
160
+ snippet: string;
161
+ }[];
162
+ total: number;
163
+ }>;
164
+ counts: () => Promise<{
165
+ tagPath: string;
166
+ pages: number;
167
+ }[]>;
140
168
  stats: () => Promise<{
141
169
  pages: number;
142
170
  updated: Date | null;
143
171
  }>;
144
- search: (query: string, { tagPath, skip, take }?: {
172
+ search: (query: string, { tagPath, skip, take, content: withContent }?: {
145
173
  tagPath?: string | null;
146
174
  skip?: number;
147
175
  take?: number;
176
+ content?: boolean;
148
177
  }) => Promise<{
149
178
  items: (WikiPageListing & {
150
179
  score: number;
180
+ content?: B[];
151
181
  })[];
152
182
  total: number;
153
183
  }>;
package/dist/store.js CHANGED
@@ -50,6 +50,11 @@ export function wikiStore(o) {
50
50
  const hidden = [...(o.hidden ?? [])];
51
51
  const rows = async (query, ...values) => (await o.sql(query, ...values));
52
52
  const one = async (query, ...values) => (await rows(query, ...values))[0] ?? null;
53
+ /**
54
+ * `isHidden` in SQL, over a table aliased `p`, `param` holding the list: a
55
+ * path under a hidden one is back office too, whether or not the tree names it.
56
+ */
57
+ const hiddenSql = (param) => `EXISTS (SELECT 1 FROM unnest(${param}::text[]) h WHERE p."tagPath" = h OR starts_with(p."tagPath", h || '/'))`;
53
58
  const isHidden = (tagPath) => hidden.some(h => tagPath === h || tagPath.startsWith(`${h}/`));
54
59
  /** A page with how many revisions it has, which every article's foot states. */
55
60
  const get = (tagPath, slug) => one(`SELECT ${COLUMNS}, ${REVISION_COUNT} FROM ${PAGE} p WHERE "siteId" = $1 AND "tagPath" = $2 AND slug = $3`, site, tagPath, slug);
@@ -65,8 +70,8 @@ export function wikiStore(o) {
65
70
  /** A category's pages with their content, for a listing that reads the bodies (a reading time, a word count). */
66
71
  const articles = (tagPath, { sort = 'title', limit = 200, offset = 0 } = {}) => rows(`SELECT ${COLUMNS} FROM ${PAGE} WHERE "siteId" = $1 AND "tagPath" = $2 AND slug <> '' ORDER BY ${ORDER[sort]} LIMIT $3::int OFFSET $4::int`, site, tagPath, limit, offset);
67
72
  /** Every page under a category, by title: the card that lists a subcategory's articles. */
68
- const subtree = (tagPath) => rows(`SELECT "tagPath", slug, title FROM ${PAGE}
69
- WHERE "siteId" = $1 AND "tagPath" LIKE $2 AND slug <> '' AND NOT ("tagPath" = ANY($3::text[]))
73
+ const subtree = (tagPath) => rows(`SELECT "tagPath", slug, title FROM ${PAGE} p
74
+ WHERE "siteId" = $1 AND "tagPath" LIKE $2 AND slug <> '' AND NOT ${hiddenSql('$3')}
70
75
  ORDER BY title ASC`, site, `${escapeLikeTerm(tagPath)}/%`, hidden);
71
76
  /**
72
77
  * The newest pages. One that names a path gets that path, hidden or not; one
@@ -75,14 +80,37 @@ export function wikiStore(o) {
75
80
  */
76
81
  const recent = async ({ tagPath, limit = 10 } = {}) => (await (tagPath !== undefined
77
82
  ? rows(`SELECT ${COLUMNS} FROM ${PAGE} WHERE "siteId" = $1 AND "tagPath" = $2 ORDER BY "updatedAt" DESC LIMIT $3::int`, site, tagPath, limit)
78
- : rows(`SELECT ${COLUMNS} FROM ${PAGE} WHERE "siteId" = $1 AND NOT ("tagPath" = ANY($2::text[])) ORDER BY "updatedAt" DESC LIMIT $3::int`, site, hidden, limit))).map(listing);
83
+ : rows(`SELECT ${COLUMNS} FROM ${PAGE} p WHERE "siteId" = $1 AND NOT ${hiddenSql('$2')} ORDER BY "updatedAt" DESC LIMIT $3::int`, site, hidden, limit))).map(listing);
79
84
  const byIds = async (ids) => ids.length
80
85
  ? orderByIds((await rows(`SELECT ${COLUMNS} FROM ${PAGE} WHERE "siteId" = $1 AND id = ANY($2::text[])`, site, [...ids])).map(listing), ids)
81
86
  : [];
82
87
  /** Every page without its content: the sitemap, llms.txt and a site's page list. */
83
88
  const refs = () => rows(`SELECT id, "siteId", "tagPath", slug, title, metadata, version, "authorId", "lastVerifiedAt", "createdAt", "updatedAt" FROM ${PAGE} WHERE "siteId" = $1 ORDER BY "tagPath", slug`, site);
84
89
  /** Every page with its content, for a corpus export. Hidden paths are left out. */
85
- const corpus = () => rows(`SELECT ${COLUMNS} FROM ${PAGE} WHERE "siteId" = $1 AND NOT ("tagPath" = ANY($2::text[])) ORDER BY "tagPath", slug`, site, hidden);
90
+ const corpus = () => rows(`SELECT ${COLUMNS} FROM ${PAGE} p WHERE "siteId" = $1 AND NOT ${hiddenSql('$2')} ORDER BY "tagPath", slug`, site, hidden);
91
+ /**
92
+ * A branch for an agent walking the wiki: the path named and everything
93
+ * under it, with the total a pager needs. Naming a path gets it even when it
94
+ * is hidden, and never a hidden path beneath it; naming none gets article
95
+ * space, the home page excepted. `since` keeps what changed from then on.
96
+ */
97
+ const branch = async ({ tagPath, sort = 'recent', limit = 20, offset = 0, since } = {}) => {
98
+ const where = `"siteId" = $1
99
+ AND ($2::text IS NULL OR "tagPath" = $2 OR starts_with("tagPath", $2 || '/'))
100
+ AND (("tagPath" = $2) IS TRUE OR NOT ${hiddenSql('$3')})
101
+ AND NOT ("tagPath" = '' AND slug = '')
102
+ AND ($4::timestamptz IS NULL OR "updatedAt" >= $4)`;
103
+ const values = [site, tagPath || null, hidden, since ?? null];
104
+ const [found, count] = await Promise.all([
105
+ rows(`SELECT ${COLUMNS} FROM ${PAGE} p WHERE ${where} ORDER BY ${ORDER[sort]} LIMIT $5::int OFFSET $6::int`, ...values, limit, offset),
106
+ one(`SELECT count(*)::int AS total FROM ${PAGE} p WHERE ${where}`, ...values),
107
+ ]);
108
+ return { items: found.map(row => ({ ...listing(row), content: row.content })), total: count?.total ?? 0 };
109
+ };
110
+ /** How many pages each tag path holds, back office and the home page left out: a category tree's counts. */
111
+ const counts = () => rows(`SELECT "tagPath", count(*)::int AS pages FROM ${PAGE} p
112
+ WHERE "siteId" = $1 AND NOT ("tagPath" = '' AND slug = '') AND NOT ${hiddenSql('$2')}
113
+ GROUP BY "tagPath" ORDER BY "tagPath"`, site, hidden);
86
114
  const stats = async () => {
87
115
  const row = await one(`SELECT count(*)::int AS pages, max("updatedAt") AS updated FROM ${PAGE} WHERE "siteId" = $1`, site);
88
116
  return { pages: row?.pages ?? 0, updated: row?.updated ?? null };
@@ -103,7 +131,7 @@ export function wikiStore(o) {
103
131
  * page is never a result. A tier-3 row's snippet is Postgres' own headline,
104
132
  * because a stem match has no literal substring to window on.
105
133
  */
106
- const search = async (query, { tagPath = null, skip = 0, take = 25 } = {}) => {
134
+ const search = async (query, { tagPath = null, skip = 0, take = 25, content: withContent = false } = {}) => {
107
135
  const term = escapeLikeTerm(query);
108
136
  if (!term)
109
137
  return { items: [], total: 0 };
@@ -113,15 +141,15 @@ export function wikiStore(o) {
113
141
  SELECT p.*, q.tsq,
114
142
  CASE WHEN p.title ILIKE $2 THEN 0
115
143
  WHEN p.title ILIKE $3 THEN 1
116
- WHEN NOT (p."tagPath" = ANY($4::text[])) AND ${prose} ILIKE $3 THEN 2
144
+ WHEN NOT ${hiddenSql('$4')} AND ${prose} ILIKE $3 THEN 2
117
145
  ELSE 3 END AS tier,
118
146
  ts_rank_cd(p.search_tsv, q.tsq, ${FTS_RANK_NORMALIZATION}) AS fts
119
147
  FROM ${PAGE} p CROSS JOIN q
120
148
  WHERE p."siteId" = $5 AND NOT (p."tagPath" = '' AND p.slug = '')
121
- AND ($6::text IS NOT NULL OR NOT (p."tagPath" = ANY($4::text[])))
149
+ AND ($6::text IS NOT NULL OR NOT ${hiddenSql('$4')})
122
150
  AND ($6::text IS NULL OR p."tagPath" = $6)
123
151
  AND (p.title ILIKE $3
124
- OR (NOT (p."tagPath" = ANY($4::text[]))
152
+ OR (NOT ${hiddenSql('$4')}
125
153
  AND (${prose} ILIKE $3 OR (q.tsq IS NOT NULL AND p.search_tsv @@ q.tsq))))
126
154
  ),
127
155
  paged AS (
@@ -135,6 +163,7 @@ export function wikiStore(o) {
135
163
  ORDER BY tier, CASE WHEN tier = 0 THEN title END, CASE WHEN tier = 3 THEN fts END DESC, "updatedAt" DESC`, query.trim(), `${term}%`, `%${term}%`, hidden, site, tagPath, take, skip);
136
164
  const items = found.map(({ tier, total: _, headline, content, ...row }) => ({
137
165
  ...row,
166
+ ...(withContent ? { content: content } : {}),
138
167
  score: tier,
139
168
  snippet: (headline ? decodeEntities(headline).replace(/\s+/g, ' ').trim() : '')
140
169
  || matchSnippet(content, query, () => opening({ content: content, metadata: row.metadata })),
@@ -231,7 +260,7 @@ export function wikiStore(o) {
231
260
  const verify = async (tagPath, slug) => (await rows(`UPDATE ${PAGE} SET "lastVerifiedAt" = now() WHERE "siteId" = $1 AND "tagPath" = $2 AND slug = $3 RETURNING id`, site, tagPath, slug)).length > 0;
232
261
  return {
233
262
  siteId: site, hidden, isHidden,
234
- get, getById, list, articles, subtree, recent, byIds, refs, corpus, stats, search,
263
+ get, getById, list, articles, subtree, recent, byIds, refs, corpus, branch, counts, stats, search,
235
264
  history, revision, save, restore, remove, verify,
236
265
  };
237
266
  }
package/dist/text.js CHANGED
@@ -107,6 +107,10 @@ export function bannerToText(label, text) {
107
107
  export function codeTabsToText(tabs) {
108
108
  return tabs.map(t => `[${t.label}]\n${t.code}`).join('\n');
109
109
  }
110
+ /** `“Their words” — Name, role`. @internal */
111
+ export function testimonialToText(quote, author, role) {
112
+ return `“${quote.trim()}” — ${[author, role].filter(Boolean).join(', ')}`;
113
+ }
110
114
  /** Metric cards, one per line: `99% Uptime`. @internal */
111
115
  export function statsToText(items) {
112
116
  return items.map(s => `${s.value}${s.suffix ?? ''} ${s.label}`).join('\n');
@@ -118,6 +118,8 @@ export function coreAtomicValidator(opts = {}) {
118
118
  return validateReferenceItems(b.items);
119
119
  case 'linkGrid':
120
120
  return validateLinkGroups(b.groups);
121
+ case 'testimonial':
122
+ return typeof b.quote === 'string' && typeof b.author === 'string' && optionalString(b.role) && optionalString(b.avatarUrl);
121
123
  default:
122
124
  return false;
123
125
  }
@@ -0,0 +1,66 @@
1
+ import { type McpTool } from './mcp.js';
2
+ import { type CorpusSection } from './corpus.js';
3
+ import { type WikiNode, type WikiTree } from './tree.js';
4
+ import type { WikiStore, WikiPageListing, PageMetadata } from './store.js';
5
+ import type { DiffBlock } from './revisions.js';
6
+ type ToolName = 'search_wiki' | 'get_page' | 'get_pages' | 'list_pages' | 'get_categories' | 'get_recent_changes' | 'get_full_corpus';
7
+ export interface WikiToolsOptions<B extends DiffBlock> {
8
+ store: WikiStore<B>;
9
+ tree: WikiTree<WikiNode>;
10
+ /** A page's absolute, canonical address. */
11
+ url: (tagPath: string, slug: string) => string;
12
+ /** A page's blocks as plain prose: what `get_page` returns and `chars` counts. */
13
+ text: (blocks: B[]) => string;
14
+ /** The wiki as the descriptions name it: "the Radix Wiki", "this DAO's wiki". */
15
+ noun: string;
16
+ /** The corpus document's heading. */
17
+ corpusTitle: string;
18
+ /** A prefix a pasted page URL may carry before the page's path, such as "/wiki". */
19
+ pathPrefix?: string;
20
+ /** Back-office pages are private here, not merely unlisted: no tool reads one. */
21
+ privateHidden?: boolean;
22
+ /** What a listing row adds from the page's metadata, or null for nothing. */
23
+ metadata?: (tagPath: string, metadata: PageMetadata) => Record<string, unknown> | null;
24
+ /** A category's name as the tree lists it. Defaults to the node's `name`. */
25
+ label?: (node: WikiNode) => string;
26
+ /** The corpus as sections, when the app's llms-full.txt sets their form. Defaults to one per page from the store. */
27
+ corpus?: (tagPath?: string) => Promise<CorpusSection[]>;
28
+ /** What an empty search says. */
29
+ noMatch?: (query: string, tagPath?: string) => string;
30
+ /** A2A skills by tool, beside the defaults. */
31
+ skills?: Partial<Record<ToolName, McpTool['skill']>>;
32
+ }
33
+ /** A read tool: the package's always say whether they write. */
34
+ export type WikiTool = McpTool & Required<Pick<McpTool, 'title' | 'annotations'>>;
35
+ type Rowable<B> = Pick<WikiPageListing, 'title' | 'tagPath' | 'slug' | 'snippet' | 'metadata' | 'updatedAt' | 'lastVerifiedAt'> & {
36
+ content?: B[];
37
+ };
38
+ /** The row every listing returns: search, list, recent changes, and the app's own `?q=` twin. */
39
+ declare const rowFor: <B extends DiffBlock>(o: Pick<WikiToolsOptions<B>, "url" | "text" | "metadata">) => (p: Rowable<B>) => {
40
+ metadata?: Record<string, unknown> | undefined;
41
+ lastVerified?: string | undefined;
42
+ snippet: string;
43
+ updatedAt: string;
44
+ chars?: number | undefined;
45
+ title: string;
46
+ path: string;
47
+ url: string;
48
+ tagPath: string;
49
+ slug: string;
50
+ };
51
+ export type WikiRow = ReturnType<ReturnType<typeof rowFor>>;
52
+ /**
53
+ * A page of ranked search rows, as `search_wiki` returns them. An app's plain
54
+ * GET `?q=` and its search page call this too, so the three stay row for row
55
+ * the same.
56
+ */
57
+ export declare function searchWiki<B extends DiffBlock>(o: Pick<WikiToolsOptions<B>, 'store' | 'privateHidden' | 'url' | 'text' | 'metadata'>, query: string, { tagPath, page, size }?: {
58
+ tagPath?: string;
59
+ page?: number;
60
+ size?: number;
61
+ }): Promise<{
62
+ items: WikiRow[];
63
+ total: number;
64
+ }>;
65
+ export declare function wikiReadTools<B extends DiffBlock>(o: WikiToolsOptions<B>): WikiTool[];
66
+ export {};
@@ -0,0 +1,342 @@
1
+ // wiki-tools.ts – the read half of a wiki's MCP server, over its store and tree.
2
+ //
3
+ // radix-wiki wrote these seven tools first; caper copied them, kept the names
4
+ // and defaults, and added a batch read, a `path` on every row, a size and a miss
5
+ // that names the nearest pages. Both then ran them over Prisma queries of their
6
+ // own beside the store. miow had none, so a wiki it hosted answered no agent.
7
+ // One set now, the union of the two, over `wiki-formant/store`: every wiki that
8
+ // lists a page here lists it the way its HTML route does, hidden paths included.
9
+ //
10
+ // What is the app's: where a page lives (`url`), how blocks read as text,
11
+ // what a row adds (`metadata`), and how its corpus reads when it has an
12
+ // llms-full.txt to match. Writes stay with each app, because who may write
13
+ // is the one thing no two of them agree on.
14
+ import { McpToolError, readArgs, withAdjustments } from './mcp.js';
15
+ import { listEnvelope } from './pagination.js';
16
+ import { CORPUS_BUDGET, sliceCorpus } from './corpus.js';
17
+ import { isoDate } from './freshness.js';
18
+ import { wikiPath } from './tree.js';
19
+ import { describe } from './metadata.js';
20
+ import { openingText } from './text.js';
21
+ const MAX_BATCH_PATHS = 20;
22
+ const READ = { readOnlyHint: true, idempotentHint: true, openWorldHint: false };
23
+ const pathOf = (tagPath, slug) => wikiPath(tagPath, slug).slice(1);
24
+ /** Whether a tag path is closed to these tools: a back office the wiki keeps private. */
25
+ const offFor = (o) => (tagPath) => !!tagPath && !!o.privateHidden && o.store.isHidden(tagPath);
26
+ /** The row every listing returns: search, list, recent changes, and the app's own `?q=` twin. */
27
+ const rowFor = (o) => (p) => {
28
+ const meta = o.metadata?.(p.tagPath, (p.metadata ?? {}));
29
+ return {
30
+ title: p.title,
31
+ path: pathOf(p.tagPath, p.slug),
32
+ url: o.url(p.tagPath, p.slug),
33
+ tagPath: p.tagPath,
34
+ slug: p.slug,
35
+ ...(p.content ? { chars: o.text(p.content).length } : {}),
36
+ snippet: p.snippet,
37
+ updatedAt: isoDate(p.updatedAt),
38
+ ...(p.lastVerifiedAt ? { lastVerified: isoDate(p.lastVerifiedAt) } : {}),
39
+ ...(meta ? { metadata: meta } : {}),
40
+ };
41
+ };
42
+ /**
43
+ * A page of ranked search rows, as `search_wiki` returns them. An app's plain
44
+ * GET `?q=` and its search page call this too, so the three stay row for row
45
+ * the same.
46
+ */
47
+ export async function searchWiki(o, query, { tagPath, page = 1, size = 20 } = {}) {
48
+ if (offFor(o)(tagPath))
49
+ return { items: [], total: 0 };
50
+ const { items, total } = await o.store.search(query, { tagPath, skip: (page - 1) * size, take: size, content: true });
51
+ return { items: items.map(rowFor(o)), total };
52
+ }
53
+ export function wikiReadTools(o) {
54
+ const { store, tree, noun } = o;
55
+ const off = offFor(o);
56
+ const summary = rowFor(o);
57
+ /** "tag/path/slug", from the path itself or a pasted URL of it. */
58
+ const target = (input) => {
59
+ let clean = input.trim().replace(/^https?:\/\/[^/]+/, '');
60
+ if (o.pathPrefix && (clean === o.pathPrefix || clean.startsWith(`${o.pathPrefix}/`)))
61
+ clean = clean.slice(o.pathPrefix.length);
62
+ const parts = clean.split(/[?#]/)[0].split('/').filter(Boolean);
63
+ const slug = parts.pop();
64
+ return slug ? { tagPath: parts.join('/'), slug } : null;
65
+ };
66
+ /** The article at a path, else the hub of the category the whole path is. The article wins where both exist. */
67
+ const read = async (t) => {
68
+ const page = (await store.get(t.tagPath, t.slug)) ?? (await store.get(pathOf(t.tagPath, t.slug), ''));
69
+ return page && !off(page.tagPath) ? page : null;
70
+ };
71
+ const full = (page) => ({
72
+ ...summary({ ...page, snippet: describe(openingText(page.content)) }),
73
+ version: page.version,
74
+ content: o.text(page.content),
75
+ });
76
+ const sections = async (tagPath) => {
77
+ if (o.corpus)
78
+ return o.corpus(tagPath);
79
+ const pages = (await store.corpus())
80
+ .filter(p => !(p.tagPath === '' && p.slug === '') && (!tagPath || p.tagPath === tagPath || p.tagPath.startsWith(`${tagPath}/`)))
81
+ .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
82
+ return pages.map(p => {
83
+ const path = pathOf(p.tagPath, p.slug);
84
+ return { path, tagPath: p.tagPath, section: `## ${p.title}\n\nPath: ${path}\nURL: ${o.url(p.tagPath, p.slug)}\nUpdated: ${isoDate(p.updatedAt)}\n\n${o.text(p.content)}` };
85
+ });
86
+ };
87
+ const tools = [
88
+ {
89
+ name: 'search_wiki',
90
+ title: 'Search the wiki',
91
+ annotations: READ,
92
+ skill: { id: 'search', tags: ['search', 'wiki'] },
93
+ description: `Keyword search across ${noun}: the first call when you know what you are looking for but not where it lives. ` +
94
+ 'Matches titles and body prose, ranked in four tiers: titles starting with the term, titles containing it, literal body hits, then English full-text hits by relevance, which stem words and drop stopwords. ' +
95
+ 'Not semantic: one distinctive word beats a sentence. ' +
96
+ 'Each row carries a `path`, its size in `chars` and a snippet windowed on the match. Feed the paths to get_pages in one call rather than looping get_page. ' +
97
+ 'Narrow with tagPath when a term is common. Defaults: page 1, pageSize 20 (max 50); keep paging while `hasMore` is true.',
98
+ inputSchema: {
99
+ type: 'object',
100
+ properties: {
101
+ query: { type: 'string', description: 'Search term, matched against page titles and body text' },
102
+ tagPath: { type: 'string', description: 'Only pages at this tag path, e.g. a path from get_categories' },
103
+ page: { type: 'number', description: '1-based page number (default 1)' },
104
+ pageSize: { type: 'number', description: 'Results per page (default 20, max 50)' },
105
+ },
106
+ required: ['query'],
107
+ },
108
+ handler: async (args) => {
109
+ const r = readArgs(args);
110
+ const query = r.str('query');
111
+ const tagPath = r.str('tagPath') || undefined;
112
+ const page = r.num('page', 1, 1, 100_000);
113
+ const size = r.num('pageSize', 20, 1, 50);
114
+ if (!query)
115
+ throw new McpToolError('`query` is empty. Pass a keyword to search for, or call list_pages to enumerate a branch instead.');
116
+ const { items, total } = await searchWiki(o, query, { tagPath, page, size });
117
+ return withAdjustments(r, {
118
+ query,
119
+ ...listEnvelope(items, total, page, size, o.noMatch?.(query, tagPath)
120
+ ?? `Nothing matches "${query}"${tagPath ? ` under "${tagPath}"` : ''}. Search is keyword, not semantic: try one distinctive word${tagPath ? ', or drop the tagPath filter' : ''}, or call get_categories to see what ${noun} covers.`),
121
+ });
122
+ },
123
+ },
124
+ {
125
+ name: 'get_page',
126
+ title: 'Read a page',
127
+ annotations: READ,
128
+ skill: { id: 'read', tags: ['read', 'content', 'article'] },
129
+ description: `One page of ${noun} in full: its text, version, update date and metadata. ` +
130
+ 'Address it by `path` ("tag/path/slug", exactly the `path` or `url` a listing returns), or by `tagPath` + `slug`; a category\'s own path reads its hub article. Not a title. ' +
131
+ 'For 2 to 20 pages call get_pages, one round trip rather than N. A miss names the nearest pages, so a wrong guess is fixed in one retry. ' +
132
+ 'A page\'s URL with `.md` appended is its markdown.',
133
+ inputSchema: {
134
+ type: 'object',
135
+ properties: {
136
+ path: { type: 'string', description: 'The page\'s whole path, e.g. "developers/scrypto/blueprints". Preferred over tagPath + slug.' },
137
+ tagPath: { type: 'string', description: 'Tag path only ("" for a top-level page). Ignored when `path` is set.' },
138
+ slug: { type: 'string', description: 'Page slug. Ignored when `path` is set.' },
139
+ },
140
+ requireOneOf: ['path', 'slug'],
141
+ },
142
+ handler: async (args) => {
143
+ const r = readArgs(args);
144
+ const raw = r.str('path');
145
+ const t = raw ? target(raw) : { tagPath: r.str('tagPath'), slug: r.str('slug') };
146
+ if (!t?.slug)
147
+ throw new McpToolError('Pass either `path` ("tag/path/slug") or `slug` (with optional `tagPath`).');
148
+ const page = await read(t);
149
+ if (!page) {
150
+ const near = await store.search(t.slug.replace(/-/g, ' '), { take: 5 });
151
+ throw new McpToolError(`No page at path "${pathOf(t.tagPath, t.slug)}".`, {
152
+ didYouMean: near.items.filter(p => !off(p.tagPath)).map(p => ({ path: pathOf(p.tagPath, p.slug), title: p.title })),
153
+ hint: 'Paths are exact. Use search_wiki for a keyword, or list_pages with the tagPath to enumerate the branch.',
154
+ });
155
+ }
156
+ return withAdjustments(r, full(page));
157
+ },
158
+ },
159
+ {
160
+ name: 'get_pages',
161
+ title: `Read up to ${MAX_BATCH_PATHS} pages`,
162
+ annotations: READ,
163
+ description: `Batch read: 1 to ${MAX_BATCH_PATHS} pages of ${noun} in full in one call, which is how to read search or listing results. ` +
164
+ 'Pass `paths` as the listings return them. Paths that do not exist come back in `missing` rather than failing the call, ' +
165
+ `anything past ${MAX_BATCH_PATHS} in \`remaining\`, and pages past \`maxChars\` (default 250000) in \`omitted\` with their sizes.`,
166
+ inputSchema: {
167
+ type: 'object',
168
+ properties: {
169
+ paths: { type: 'array', items: { type: 'string' }, description: `Page paths, 1 to ${MAX_BATCH_PATHS} per call` },
170
+ maxChars: { type: 'number', description: 'Total characters across the pages returned (default 250000, max 1000000)' },
171
+ },
172
+ required: ['paths'],
173
+ },
174
+ handler: async (args) => {
175
+ const r = readArgs(args);
176
+ const maxChars = r.num('maxChars', 250_000, 1_000, 1_000_000);
177
+ const requested = [...new Set(r.list('paths').map(s => String(s).trim()).filter(Boolean))];
178
+ if (!requested.length)
179
+ throw new McpToolError('`paths` is empty. Pass at least one page path.', { hint: 'search_wiki and list_pages return a `path` for every result.' });
180
+ const taken = requested.slice(0, MAX_BATCH_PATHS);
181
+ const remaining = requested.slice(MAX_BATCH_PATHS);
182
+ if (remaining.length)
183
+ r.note({ param: 'paths', requested: requested.length, used: taken.length, reason: `max ${MAX_BATCH_PATHS} paths per call; the rest are listed in \`remaining\`` });
184
+ const found = await Promise.all(taken.map(async (input) => {
185
+ const t = target(input);
186
+ return { input, page: t && (await read(t)) };
187
+ }));
188
+ const pages = [];
189
+ const missing = [];
190
+ const omitted = [];
191
+ let used = 0;
192
+ for (const { input, page } of found) {
193
+ if (!page) {
194
+ missing.push(input);
195
+ continue;
196
+ }
197
+ const row = full(page);
198
+ // At least one page always comes back, so one oversized page can still be read.
199
+ if (pages.length && used + row.content.length > maxChars) {
200
+ omitted.push({ path: row.path, chars: row.content.length });
201
+ continue;
202
+ }
203
+ used += row.content.length;
204
+ pages.push(row);
205
+ }
206
+ return withAdjustments(r, {
207
+ requested: requested.length,
208
+ returned: pages.length,
209
+ characters: used,
210
+ maxChars,
211
+ pages,
212
+ ...(missing.length ? { missing, missingHint: 'Not found. Re-check the path with search_wiki or list_pages.' } : {}),
213
+ ...(omitted.length ? { omitted, omittedHint: 'Skipped to stay inside maxChars. Ask for these on their own or raise maxChars.' } : {}),
214
+ ...(remaining.length ? { remaining } : {}),
215
+ });
216
+ },
217
+ },
218
+ {
219
+ name: 'list_pages',
220
+ title: 'List pages',
221
+ annotations: READ,
222
+ skill: { id: 'list', tags: ['list', 'browse', 'categories'] },
223
+ description: `Browse ${noun} by tag path rather than by keyword: every page under a branch, newest first by default. ` +
224
+ '`tagPath` matches the branch and everything beneath it; omit it to walk the whole wiki. Sort by title for an A–Z pass. ' +
225
+ 'Same rows as search_wiki, with `totalPages`, `hasMore` and `nextPage`. Defaults: page 1, pageSize 20 (max 100).',
226
+ inputSchema: {
227
+ type: 'object',
228
+ properties: {
229
+ tagPath: { type: 'string', description: 'The branch to list, e.g. a path from get_categories. Includes its descendants.' },
230
+ sort: { type: 'string', enum: ['title', 'updatedAt'], description: 'Sort order (default "updatedAt", newest first)' },
231
+ page: { type: 'number', description: '1-based page number (default 1)' },
232
+ pageSize: { type: 'number', description: 'Results per page (default 20, max 100)' },
233
+ },
234
+ },
235
+ handler: async (args) => {
236
+ const r = readArgs(args);
237
+ const tagPath = r.str('tagPath') || undefined;
238
+ const page = r.num('page', 1, 1, 100_000);
239
+ const size = r.num('pageSize', 20, 1, 100);
240
+ const sort = r.str('sort') === 'title' ? 'title' : 'recent';
241
+ const { items, total } = off(tagPath) ? { items: [], total: 0 } : await store.branch({ tagPath, sort, limit: size, offset: (page - 1) * size });
242
+ return withAdjustments(r, {
243
+ ...(tagPath ? { tagPath } : {}),
244
+ ...listEnvelope(items.map(summary), total, page, size, tagPath ? `No pages at tagPath "${tagPath}". Call get_categories for the tag paths that exist.` : `${noun} has no pages yet.`),
245
+ });
246
+ },
247
+ },
248
+ {
249
+ name: 'get_categories',
250
+ title: 'List categories',
251
+ annotations: READ,
252
+ description: `The category tree of ${noun}, the cheapest way to orient before searching. ` +
253
+ 'Each node carries its `path` (a tagPath list_pages and search_wiki take), name, description, `pageCount` (pages filed at that path) and `pageCountDeep` (with every descendant). ' +
254
+ '`tagPaths` lists every tag path that holds pages, including any the tree does not name.',
255
+ inputSchema: { type: 'object', properties: {} },
256
+ handler: async () => {
257
+ const rows = await store.counts();
258
+ const exact = (path) => rows.find(c => c.tagPath === path)?.pages ?? 0;
259
+ const deep = (path) => rows.filter(c => c.tagPath === path || c.tagPath.startsWith(`${path}/`)).reduce((n, c) => n + c.pages, 0);
260
+ const build = (nodes, parent) => nodes.filter(n => !n.hidden && n.slug).map(n => {
261
+ const path = parent ? `${parent}/${n.slug}` : n.slug;
262
+ const children = n.children?.length ? build(n.children, path) : [];
263
+ return {
264
+ path,
265
+ name: o.label?.(n) ?? n.name,
266
+ ...(n.description ? { description: n.description } : {}),
267
+ pageCount: exact(path),
268
+ pageCountDeep: deep(path),
269
+ ...(children.length ? { children } : {}),
270
+ };
271
+ });
272
+ return {
273
+ totalPages: rows.reduce((n, c) => n + c.pages, 0),
274
+ categories: build(tree.nodes, ''),
275
+ tagPaths: [...rows].sort((a, b) => b.pages - a.pages).map(c => ({ path: c.tagPath, pageCount: c.pages })),
276
+ };
277
+ },
278
+ },
279
+ {
280
+ name: 'get_recent_changes',
281
+ title: 'Recent changes',
282
+ annotations: READ,
283
+ description: `Pages of ${noun} edited within the last N days, newest first: what to poll when you are watching the wiki rather than reading it. ` +
284
+ 'Same rows as list_pages. Defaults: days 7 (max 30), limit 20 (max 50). `truncated: true` means the limit cut the list; raise it, or page through list_pages.',
285
+ inputSchema: {
286
+ type: 'object',
287
+ properties: {
288
+ days: { type: 'number', description: 'Look back this many days (default 7, max 30)' },
289
+ limit: { type: 'number', description: 'Most pages returned (default 20, max 50)' },
290
+ },
291
+ },
292
+ handler: async (args) => {
293
+ const r = readArgs(args);
294
+ const days = r.num('days', 7, 1, 30);
295
+ const limit = r.num('limit', 20, 1, 50);
296
+ // N × 24h back, not N calendar days: a DST change would move the window by an hour.
297
+ const since = new Date(Date.now() - days * 86_400_000);
298
+ const { items, total } = await store.branch({ sort: 'recent', limit, since });
299
+ return withAdjustments(r, {
300
+ days,
301
+ since: since.toISOString(),
302
+ limit,
303
+ total,
304
+ count: items.length,
305
+ truncated: total > items.length,
306
+ pages: items.map(summary),
307
+ });
308
+ },
309
+ },
310
+ {
311
+ name: 'get_full_corpus',
312
+ title: 'Whole corpus',
313
+ annotations: READ,
314
+ description: `The whole of ${noun} as one text document, for bulk ingestion. ` +
315
+ 'Preflight first: sizeOnly=true returns the character count, a token estimate, the per-branch breakdown and the largest pages. ' +
316
+ `Then pull it with \`maxChars\` (default ${CORPUS_BUDGET.default}, max ${CORPUS_BUDGET.max}), or one branch at a time with \`tagPath\`. ` +
317
+ 'Truncation is page-aligned: `truncated`, `omittedPages` and `nextSkip` say where to resume (pass skip=nextSkip). For a handful of pages get_pages is cheaper.',
318
+ inputSchema: {
319
+ type: 'object',
320
+ properties: {
321
+ sizeOnly: { type: 'boolean', description: 'Sizes and breakdowns only, no document. Do this before the first real pull.' },
322
+ tagPath: { type: 'string', description: 'One branch and its descendants. Omit for everything.' },
323
+ maxChars: { type: 'number', description: `Character budget for \`document\` (default ${CORPUS_BUDGET.default}, max ${CORPUS_BUDGET.max})` },
324
+ skip: { type: 'number', description: 'Resume from this page index: the `nextSkip` of the previous truncated call (default 0)' },
325
+ },
326
+ },
327
+ handler: async (args) => {
328
+ const r = readArgs(args);
329
+ const tagPath = r.str('tagPath') || undefined;
330
+ const slice = sliceCorpus(off(tagPath) ? [] : await sections(tagPath), {
331
+ title: o.corpusTitle,
332
+ sizeOnly: r.bool('sizeOnly'),
333
+ maxChars: r.num('maxChars', CORPUS_BUDGET.default, CORPUS_BUDGET.min, CORPUS_BUDGET.max),
334
+ skip: r.num('skip', 0, 0, 1_000_000),
335
+ hint: 'Pull with maxChars, or one branch at a time with tagPath. get_pages is cheaper for a handful of pages.',
336
+ });
337
+ return withAdjustments(r, { scope: o.corpusTitle, ...(tagPath ? { tagPath } : {}), ...slice });
338
+ },
339
+ },
340
+ ];
341
+ return tools.map(t => (o.skills?.[t.name] ? { ...t, skill: o.skills[t.name] } : t));
342
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.61.0",
3
+ "version": "0.63.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",
@@ -127,6 +127,16 @@
127
127
  "import": "./dist/tree.js",
128
128
  "default": "./dist/tree.js"
129
129
  },
130
+ "./category": {
131
+ "types": "./dist/category.d.ts",
132
+ "import": "./dist/category.js",
133
+ "default": "./dist/category.js"
134
+ },
135
+ "./wiki-tools": {
136
+ "types": "./dist/wiki-tools.d.ts",
137
+ "import": "./dist/wiki-tools.js",
138
+ "default": "./dist/wiki-tools.js"
139
+ },
130
140
  "./feed": {
131
141
  "types": "./dist/feed.d.ts",
132
142
  "import": "./dist/feed.js",