wiki-formant 0.60.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 +86 -5
- package/bin/maintenance-log.mjs +27 -48
- package/bin/mark-verified.mjs +10 -13
- package/bin/prisma-models.mjs +5 -5
- package/dist/block-editor.d.ts +9 -0
- package/dist/block-editor.js +19 -0
- package/dist/block-views.d.ts +2 -1
- package/dist/block-views.js +2 -1
- package/dist/category.d.ts +64 -0
- package/dist/category.js +65 -0
- package/dist/maintenance.d.ts +0 -11
- package/dist/maintenance.js +0 -20
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +6 -3
- package/dist/store.d.ts +196 -0
- package/dist/store.js +266 -0
- package/dist/text.d.ts +6 -0
- package/dist/text.js +13 -0
- package/dist/tree.d.ts +95 -0
- package/dist/tree.js +122 -0
- package/dist/wiki-tools.d.ts +66 -0
- package/dist/wiki-tools.js +342 -0
- package/package.json +26 -5
- package/prisma/wiki.prisma +62 -0
- package/prisma/wiki.sql +68 -0
package/dist/tree.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// tree.ts – a wiki's category tree, and what a URL path means against it.
|
|
2
|
+
//
|
|
3
|
+
// Three wikis declared their tree three ways: radix-wiki's `TAG_HIERARCHY`
|
|
4
|
+
// (slug + children, keys and sort inherited down the trail), caper's
|
|
5
|
+
// `WIKI_CATEGORIES` (each node naming its full `tagPath`, with a `categoryTrail`
|
|
6
|
+
// walk of its own) and acuiq's `WIKI_SECTIONS` (one flat level). Each then
|
|
7
|
+
// re-derived the same answers by hand: is this a real category, which metadata
|
|
8
|
+
// keys apply here, which article is this page "part of a series on", what sort
|
|
9
|
+
// does a listing default to, which paths are back office.
|
|
10
|
+
//
|
|
11
|
+
// The node holds what every wiki reads; anything only one wiki reads (an XRD
|
|
12
|
+
// threshold, a band, an icon set) rides on the node as an extra field, and the
|
|
13
|
+
// app folds it from `trail`. Pure: no data access, so a client component and
|
|
14
|
+
// the server route read paths through the same parser.
|
|
15
|
+
const segmentsOf = (tagPath) => typeof tagPath === 'string' ? tagPath.split('/').filter(Boolean) : tagPath.filter(Boolean);
|
|
16
|
+
export function createTree(nodes, opts = {}) {
|
|
17
|
+
const globalKeys = [...(opts.globalKeys ?? [])];
|
|
18
|
+
const cache = new Map();
|
|
19
|
+
function resolve(tagPath) {
|
|
20
|
+
const segments = segmentsOf(tagPath);
|
|
21
|
+
const key = segments.join('/');
|
|
22
|
+
const hit = cache.get(key);
|
|
23
|
+
if (hit)
|
|
24
|
+
return hit;
|
|
25
|
+
const trail = [];
|
|
26
|
+
const metadataKeys = [];
|
|
27
|
+
let level = nodes;
|
|
28
|
+
let sort = 'title';
|
|
29
|
+
let mainArticle;
|
|
30
|
+
let hidden = false;
|
|
31
|
+
for (const segment of segments) {
|
|
32
|
+
const node = level.find(n => n.slug === segment);
|
|
33
|
+
if (!node) {
|
|
34
|
+
const missing = { node: null, trail: [], valid: false, hidden: false, sort: 'title', metadataKeys: [] };
|
|
35
|
+
cache.set(key, missing);
|
|
36
|
+
return missing;
|
|
37
|
+
}
|
|
38
|
+
trail.push(node);
|
|
39
|
+
if (node.metadataKeys)
|
|
40
|
+
metadataKeys.push(...node.metadataKeys);
|
|
41
|
+
if (node.sort)
|
|
42
|
+
sort = node.sort;
|
|
43
|
+
if (node.mainArticle)
|
|
44
|
+
mainArticle = node.mainArticle;
|
|
45
|
+
hidden ||= !!node.hidden;
|
|
46
|
+
level = node.children ?? [];
|
|
47
|
+
}
|
|
48
|
+
metadataKeys.push(...globalKeys);
|
|
49
|
+
const resolved = { node: trail.at(-1) ?? null, trail, valid: trail.length > 0, hidden, sort, mainArticle, metadataKeys };
|
|
50
|
+
cache.set(key, resolved);
|
|
51
|
+
return resolved;
|
|
52
|
+
}
|
|
53
|
+
/** Every path in the tree, depth first, parents before children. */
|
|
54
|
+
function paths(level = nodes, parent = '', inherited = false, depth = 0) {
|
|
55
|
+
return level.flatMap(node => {
|
|
56
|
+
const path = parent ? `${parent}/${node.slug}` : node.slug;
|
|
57
|
+
const hidden = inherited || !!node.hidden;
|
|
58
|
+
return [{ path, node: node, hidden, depth }, ...paths(node.children ?? [], path, hidden, depth + 1)];
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const all = paths();
|
|
62
|
+
return {
|
|
63
|
+
nodes,
|
|
64
|
+
resolve,
|
|
65
|
+
paths: () => all,
|
|
66
|
+
/** Back-office paths, which a listing leaves out unless it names one. Pass to `wikiStore({ hidden })`. */
|
|
67
|
+
hiddenPaths: all.filter(p => p.hidden && p.path).map(p => p.path),
|
|
68
|
+
isValid: (tagPath) => resolve(tagPath).valid,
|
|
69
|
+
node: (tagPath) => resolve(tagPath).node,
|
|
70
|
+
metadataKeys: (tagPath) => resolve(tagPath).metadataKeys,
|
|
71
|
+
/** A category's name, or its last segment unslugged when the tree does not know it. */
|
|
72
|
+
label: (tagPath) => resolve(tagPath).node?.name ?? (segmentsOf(tagPath).at(-1) ?? '').replace(/-/g, ' '),
|
|
73
|
+
/**
|
|
74
|
+
* One crumb per segment, so the trail can never be shorter than the URL. A
|
|
75
|
+
* segment the tree knows is a link to its category; one it does not is text.
|
|
76
|
+
*/
|
|
77
|
+
crumbs: (tagPath) => {
|
|
78
|
+
const segments = segmentsOf(tagPath);
|
|
79
|
+
return segments.map((segment, i) => {
|
|
80
|
+
const path = segments.slice(0, i + 1).join('/');
|
|
81
|
+
const node = resolve(path).node;
|
|
82
|
+
return { path, label: node?.name ?? segment.replace(/-/g, ' '), linked: !!node };
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** The views a page or a category has beside itself, each a final path segment. */
|
|
88
|
+
export const WIKI_MODES = ['edit', 'history'];
|
|
89
|
+
/**
|
|
90
|
+
* What a path means: the home page, a category (its hub article is the empty
|
|
91
|
+
* slug), a page, or nothing, each optionally in one of `modes`. A path that is
|
|
92
|
+
* itself a category wins over a mode suffix, so a category named `history` is
|
|
93
|
+
* still reachable; `/<category>/edit` edits the category's hub.
|
|
94
|
+
*
|
|
95
|
+
* Static routes are the app's: check them before calling this.
|
|
96
|
+
*/
|
|
97
|
+
export function parseWikiPath(tree, segments = [], { modes = WIKI_MODES } = {}) {
|
|
98
|
+
const parts = segments.filter(Boolean);
|
|
99
|
+
const last = parts.at(-1);
|
|
100
|
+
const mode = last && modes.includes(last) && !tree.isValid(parts) ? last : null;
|
|
101
|
+
const path = mode ? parts.slice(0, -1) : parts;
|
|
102
|
+
const invalid = { type: 'invalid', tagPath: '', slug: '', mode: null };
|
|
103
|
+
if (!path.length)
|
|
104
|
+
return { type: 'home', tagPath: '', slug: '', mode };
|
|
105
|
+
if (tree.isValid(path))
|
|
106
|
+
return { type: 'category', tagPath: path.join('/'), slug: '', mode };
|
|
107
|
+
if (path.length < 2 || !tree.isValid(path.slice(0, -1)))
|
|
108
|
+
return invalid;
|
|
109
|
+
return { type: 'page', tagPath: path.slice(0, -1).join('/'), slug: path.at(-1), mode };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A slug no page in `tagPath` can hold, because `parseWikiPath` reads that URL
|
|
113
|
+
* as something else: a mode, or a child category. Creating a page treats it as
|
|
114
|
+
* taken.
|
|
115
|
+
*/
|
|
116
|
+
export function isReservedSlug(tree, tagPath, slug, modes = WIKI_MODES) {
|
|
117
|
+
return modes.includes(slug) || tree.isValid([...segmentsOf(tagPath), slug]);
|
|
118
|
+
}
|
|
119
|
+
/** A page's path: the category for its hub, `/` for the home page. */
|
|
120
|
+
export function wikiPath(tagPath, slug) {
|
|
121
|
+
return `/${[tagPath, slug].filter(Boolean).join('/')}`;
|
|
122
|
+
}
|
|
@@ -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.
|
|
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",
|
|
@@ -117,6 +117,26 @@
|
|
|
117
117
|
"import": "./dist/search.js",
|
|
118
118
|
"default": "./dist/search.js"
|
|
119
119
|
},
|
|
120
|
+
"./store": {
|
|
121
|
+
"types": "./dist/store.d.ts",
|
|
122
|
+
"import": "./dist/store.js",
|
|
123
|
+
"default": "./dist/store.js"
|
|
124
|
+
},
|
|
125
|
+
"./tree": {
|
|
126
|
+
"types": "./dist/tree.d.ts",
|
|
127
|
+
"import": "./dist/tree.js",
|
|
128
|
+
"default": "./dist/tree.js"
|
|
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
|
+
},
|
|
120
140
|
"./feed": {
|
|
121
141
|
"types": "./dist/feed.d.ts",
|
|
122
142
|
"import": "./dist/feed.js",
|
|
@@ -296,6 +316,7 @@
|
|
|
296
316
|
"sanitize-html": "^2.17.7"
|
|
297
317
|
},
|
|
298
318
|
"devDependencies": {
|
|
319
|
+
"@electric-sql/pglite": "^0.3.9",
|
|
299
320
|
"@radixdlt/rola": "^2.1.0",
|
|
300
321
|
"@types/node": "^22.0.0",
|
|
301
322
|
"@types/react": "^19.0.0",
|
|
@@ -308,12 +329,12 @@
|
|
|
308
329
|
"typescript": "^5.9.0"
|
|
309
330
|
},
|
|
310
331
|
"peerDependencies": {
|
|
332
|
+
"@radixdlt/rola": ">=2",
|
|
333
|
+
"@vercel/blob": ">=2",
|
|
334
|
+
"jose": ">=6",
|
|
311
335
|
"pg": ">=8",
|
|
312
336
|
"react": ">=18",
|
|
313
|
-
"
|
|
314
|
-
"@radixdlt/rola": ">=2",
|
|
315
|
-
"sharp": ">=0.33",
|
|
316
|
-
"@vercel/blob": ">=2"
|
|
337
|
+
"sharp": ">=0.33"
|
|
317
338
|
},
|
|
318
339
|
"peerDependenciesMeta": {
|
|
319
340
|
"react": {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Shipped by wiki-formant (`npx prisma-models`). Edit it there, not here:
|
|
2
|
+
// `prisma-models --check` fails on a copy that has drifted.
|
|
3
|
+
//
|
|
4
|
+
// A wiki's pages and their revisions, read and written by wiki-formant/store.
|
|
5
|
+
// Its SQL names these tables and columns as written, so they carry no @@map.
|
|
6
|
+
// `siteId` is "" in an app that serves one wiki; a multi-tenant app keys every
|
|
7
|
+
// row to its site, and caper keys a DAO's own layer to the DAO.
|
|
8
|
+
//
|
|
9
|
+
// A page lives at `tagPath` + `slug`. The empty slug is the category's own hub
|
|
10
|
+
// article, and `''/''` the home page. Everything a page says beyond its blocks
|
|
11
|
+
// and title is `metadata`: facet keys, an excerpt, a banner, a subtitle. Which
|
|
12
|
+
// keys a listing may carry is the tree's answer, not the column's.
|
|
13
|
+
//
|
|
14
|
+
// Ids and timestamps default in the database, so the store's raw SQL and a
|
|
15
|
+
// Prisma client write the same rows. `authorId` is the app's own user key, as
|
|
16
|
+
// text; it has no relation because no two apps key their users alike.
|
|
17
|
+
|
|
18
|
+
model WikiPage {
|
|
19
|
+
id String @id @default(dbgenerated("(gen_random_uuid())::text"))
|
|
20
|
+
siteId String @default("")
|
|
21
|
+
tagPath String @default("")
|
|
22
|
+
slug String
|
|
23
|
+
title String
|
|
24
|
+
content Json @default("[]")
|
|
25
|
+
metadata Json @default("{}")
|
|
26
|
+
version String @default("1.0.0")
|
|
27
|
+
authorId String?
|
|
28
|
+
lastVerifiedAt DateTime? @db.Timestamptz(3)
|
|
29
|
+
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
|
30
|
+
updatedAt DateTime @default(now()) @db.Timestamptz(3)
|
|
31
|
+
// Title at weight A, block prose at B, GENERATED ALWAYS ... STORED by
|
|
32
|
+
// `npx fts-ddl '"WikiPage"'`. Declared only so `db push` leaves it alone:
|
|
33
|
+
// push must never create it, since Prisma would write a plain column that
|
|
34
|
+
// stays empty. Read only through raw SQL.
|
|
35
|
+
searchTsv Unsupported("tsvector")? @map("search_tsv")
|
|
36
|
+
revisions WikiRevision[]
|
|
37
|
+
|
|
38
|
+
@@unique([siteId, tagPath, slug])
|
|
39
|
+
@@index([siteId, tagPath, updatedAt])
|
|
40
|
+
@@index([siteId, updatedAt])
|
|
41
|
+
// Declared for the same reason: undeclared, push drops it.
|
|
42
|
+
@@index([searchTsv], type: Gin, map: "WikiPage_search_tsv_idx")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// One row per saved version, newest last. A revision holds the whole page as
|
|
46
|
+
// it was saved; what changed is diffed from consecutive rows when it is read,
|
|
47
|
+
// so a script's revision and the editor's carry the same history.
|
|
48
|
+
model WikiRevision {
|
|
49
|
+
id String @id @default(dbgenerated("(gen_random_uuid())::text"))
|
|
50
|
+
pageId String
|
|
51
|
+
title String
|
|
52
|
+
content Json
|
|
53
|
+
version String
|
|
54
|
+
changeType String
|
|
55
|
+
message String?
|
|
56
|
+
authorId String?
|
|
57
|
+
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
|
58
|
+
page WikiPage @relation(fields: [pageId], references: [id], onDelete: Cascade)
|
|
59
|
+
|
|
60
|
+
@@index([pageId, createdAt])
|
|
61
|
+
@@index([authorId, createdAt])
|
|
62
|
+
}
|