wiki-formant 0.61.0 → 0.62.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.
@@ -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
  }
@@ -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.62.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",