openalmanac 0.2.54 → 0.2.56

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/dist/cli.js CHANGED
File without changes
package/dist/server.js CHANGED
@@ -3,10 +3,10 @@ import { join, dirname } from "path";
3
3
  import { fileURLToPath } from "url";
4
4
  import { FastMCP } from "fastmcp";
5
5
  import { registerAuthTools } from "./tools/auth.js";
6
- import { registerArticleTools } from "./tools/articles.js";
6
+ import { registerPageTools } from "./tools/pages.js";
7
7
  import { registerResearchTools } from "./tools/research.js";
8
- import { registerCommunityTools } from "./tools/communities.js";
9
- import { registerPeopleTools } from "./tools/people.js";
8
+ import { registerWikiTools } from "./tools/wikis.js";
9
+ import { registerTopicTools } from "./tools/topics.js";
10
10
  import { getApiKey } from "./auth.js";
11
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
12
  const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
@@ -31,7 +31,7 @@ export function createServer() {
31
31
  name: "OpenAlmanac",
32
32
  version: pkg.version,
33
33
  instructions: [
34
- "OpenAlmanac is an open knowledge base — a Wikipedia anyone can read from and write to through an API. Articles are markdown files with YAML frontmatter and [@key] citation markers mapped to named sources.",
34
+ "OpenAlmanac is an open knowledge base — a Wikipedia anyone can read from and write to through an API. Pages are markdown files with YAML frontmatter, [@key] citation markers, and [[wikilinks]]. Content is organized into wikis, each with topics, pages, and navigation.",
35
35
  "",
36
36
  "## How this should feel",
37
37
  "",
@@ -47,7 +47,7 @@ export function createServer() {
47
47
  "",
48
48
  "Your answers should feel like living knowledge — with linked entities and images, not plain text walls.",
49
49
  "",
50
- "**Entity links:** Before writing your response, call `search_articles` with the key entity names you plan to mention (e.g. `queries: [\"Theravada Buddhism\", \"Thailand\", \"Angkor Wat\"]`). This returns which articles/stubs exist and their slugs. Then use `[[slug|Display Text]]` wikilink syntax in your response. Published articles preserve wikilinks in storage; dead links auto-create stub articles on publish (community wikis) so round-trip editing stays faithful.",
50
+ "**Entity links:** Before writing your response, call `search_articles` with the key entity names you plan to mention (e.g. `queries: [\"Theravada Buddhism\", \"Thailand\", \"Angkor Wat\"]`). This returns which pages/stubs exist and their slugs. Then use `[[slug|Display Text]]` wikilink syntax in your response. Dead links auto-create stub pages on publish so round-trip editing stays faithful.",
51
51
  "",
52
52
  "**Images:** Use `search_images` to find 1-2 relevant images for your response. Include them using the figure syntax: `![Descriptive caption](image_url \"position\")` where position is `right`, `left`, or `center`. Always write a descriptive caption. Use `view_images` to verify candidates before including them.",
53
53
  "",
@@ -78,7 +78,7 @@ export function createServer() {
78
78
  "",
79
79
  'Example: User says "I\'m interested in UX." Don\'t say "Would you like to focus on history, applications, or companies?" Instead, research and say: "So UX was coined by Don Norman at Apple in 1993, but the practice goes back to Henry Dreyfuss in the 1950s designing telephone handsets by measuring thousands of human bodies. There\'s also the dark patterns side — Ryanair\'s checkout flow got studied in academic papers as a case study in hostile design. And there\'s the curb cut effect — features designed for disabled users that end up benefiting everyone. What pulls you?"',
80
80
  "",
81
- "**User has no topic** → Talk to them. What are they into — a movie they just watched, a hobby, something from work, a place they visited, a news story that caught their eye? Once you have a thread, research it and come back with real information. You can also use `list_articles` with `sort: \"most_referenced\"` and `stubs_only: true` on a community wiki to find high-demand stubs.",
81
+ "**User has no topic** → Talk to them. What are they into — a movie they just watched, a hobby, something from work, a place they visited, a news story that caught their eye? Once you have a thread, research it and come back with real information. You can also use `list_articles` with `wiki_slug` and `stubs_only: true` to find stubs that need writing.",
82
82
  "",
83
83
  "**User wants to edit an existing article** → Download it and read it. Look for what's *interesting but underdeveloped* — a one-sentence mention of a controversy probably has a whole story behind it. Share what you find and propose going deeper.",
84
84
  "",
@@ -103,7 +103,7 @@ export function createServer() {
103
103
  "",
104
104
  "3. **Read the writing guidelines** — Fetch https://www.openalmanac.org/writing-guidelines.md and https://www.openalmanac.org/ai-patterns-to-avoid.md before writing a single word.",
105
105
  "",
106
- "4. **Scaffold** — Use `new` with one or more `{ title, slug?, topics? }` entries. Provide `slug` when you know the canonical ID; otherwise it is auto-derived from the title. For community wikis pass `community_slug`. List ALL sources you've gathered in the frontmatter before writing any body text. Don't discard sources — if you read it during research and it's relevant, include it.",
106
+ "4. **Scaffold** — Use `new` with one or more `{ title, slug?, topics? }` entries and a `wiki_slug`. Provide `slug` when you know the canonical ID; otherwise it is auto-derived from the title. List ALL sources you've gathered in the frontmatter before writing any body text. Don't discard sources — if you read it during research and it's relevant, include it.",
107
107
  "",
108
108
  "5. **Write a pure text draft** — This whole process (writing, review, fact-check, images, linking) takes a few minutes. Let the user know in a fun way that they can step away — and that once it's ready, you're happy to discuss any edits or polishing.",
109
109
  "",
@@ -111,14 +111,14 @@ export function createServer() {
111
111
  "",
112
112
  "6. **Dispatch four subagents in parallel** — After the draft is complete, dispatch these simultaneously. Each agent has its own guidelines file — tell it to fetch and read that file as its first step. The guidelines file tells the agent what to do, what additional guidelines to fetch, and what format to return results in.",
113
113
  "",
114
- " - **Review agent** → tell it to read https://www.openalmanac.org/review-guidelines.md and review the draft at `~/.openalmanac/articles/{slug}.md`",
115
- " - **Fact-check agent** → tell it to read https://www.openalmanac.org/fact-checking-guidelines.md and fact-check the draft at `~/.openalmanac/articles/{slug}.md`",
116
- " - **Image agent** → tell it to read https://www.openalmanac.org/image-guidelines.md and find images for the draft at `~/.openalmanac/articles/{slug}.md`",
117
- " - **Linking agent** → tell it to read https://www.openalmanac.org/linking-guidelines.md and add wikilinks for the draft at `~/.openalmanac/articles/{slug}.md` (dead links become stubs on publish — no manual `create_stubs` step)",
114
+ " - **Review agent** → tell it to read https://www.openalmanac.org/review-guidelines.md and review the draft at `~/.openalmanac/articles/{wiki_slug}/{slug}.md`",
115
+ " - **Fact-check agent** → tell it to read https://www.openalmanac.org/fact-checking-guidelines.md and fact-check the draft at `~/.openalmanac/articles/{wiki_slug}/{slug}.md`",
116
+ " - **Image agent** → tell it to read https://www.openalmanac.org/image-guidelines.md and find images for the draft at `~/.openalmanac/articles/{wiki_slug}/{slug}.md`",
117
+ " - **Linking agent** → tell it to read https://www.openalmanac.org/linking-guidelines.md and add wikilinks for the draft at `~/.openalmanac/articles/{wiki_slug}/{slug}.md` (dead links become stubs on publish)",
118
118
  "",
119
119
  "7. **Integrate** — Present the review and fact-check feedback to the user. Then fix everything in one pass: review issues, fact-check corrections, add images, add wikilinks.",
120
120
  "",
121
- "8. **Publish** — Validate and publish (`publish` with `slugs` or `community_slug` to batch). Put per-article change notes in frontmatter as `edit_summary` (maps to the API). Share the exact URL from the publish response when single-article. For community wikis, use `list_articles` to verify coverage.",
121
+ "8. **Publish** — Validate and publish (`publish` with `slugs` and `wiki_slug`). Put per-page change notes in frontmatter as `edit_summary`. Share the exact URL from the publish response when single-page. Use `list_articles` to verify coverage.",
122
122
  "",
123
123
  "Why this order: the draft must be finished before subagents run. The linking agent needs to see what entities are actually in the text. The image agent needs to match images to specific content. The review agent needs the complete article. Everything reads the draft.",
124
124
  "",
@@ -126,17 +126,17 @@ export function createServer() {
126
126
  "",
127
127
  "Reading and searching articles is open. Writing requires an API key (from login). Login creates a personal API key linked to your user account, so contributions are attributed to you.",
128
128
  "",
129
- "Core flow: login (once) → search_articles (check if exists) → search_web + read_webpage (research) → `new` (batch scaffold, auto slugs) or `download` (batch) → edit files under ~/.openalmanac/articles/ → `publish` (slugs or community_slug for folder batch).",
129
+ "Core flow: login (once) → search_articles (check if exists) → search_web + read_webpage (research) → `new` (scaffold with wiki_slug) or `download` (batch with wiki_slug) → edit files under ~/.openalmanac/articles/{wiki_slug}/ → `publish` (slugs + wiki_slug).",
130
130
  "",
131
- "After publishing, share the celebration URL when applicable. Community-owned articles are created under a community path — use `list_articles` to browse a community wiki.",
131
+ "After publishing, share the celebration URL when applicable. Use `list_articles` with `wiki_slug` to browse a wiki's pages.",
132
132
  "",
133
133
  "When working with tool results, write down any important information you might need later, as the original tool result may be cleared.",
134
134
  ].join("\n"),
135
135
  });
136
136
  registerAuthTools(server);
137
- registerArticleTools(server);
137
+ registerPageTools(server);
138
138
  registerResearchTools(server);
139
- registerCommunityTools(server);
140
- registerPeopleTools(server);
139
+ registerWikiTools(server);
140
+ registerTopicTools(server);
141
141
  return server;
142
142
  }
package/dist/setup.js CHANGED
@@ -617,9 +617,6 @@ function printRedditResult(agent, loginResult, mcpChanged, toolCount) {
617
617
  w(row(` ${WHITE_BOLD}Next steps${RST}`));
618
618
  w(empty);
619
619
  w(row(` ${BLUE}1.${RST} Type ${WHITE_BOLD}claude${RST} to start Claude Code`));
620
- w(row(` ${BLUE}2.${RST} Run ${BLUE}/reddit-wiki r/<subreddit>${RST}`));
621
- w(empty);
622
- w(row(` ${DIM}Ask "how does reddit wiki work?" to learn more${RST}`));
623
620
  w(empty);
624
621
  w(` ${BLUE_DIM}\u2570${"─".repeat(innerW)}\u256f${RST}`);
625
622
  w("");
@@ -0,0 +1,2 @@
1
+ import { FastMCP } from "fastmcp";
2
+ export declare function registerPageTools(server: FastMCP): void;
@@ -0,0 +1,351 @@
1
+ import { z } from "zod";
2
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, unlinkSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { stringify as yamlStringify } from "yaml";
5
+ import { request, ARTICLES_DIR } from "../auth.js";
6
+ import { validateArticle } from "../validate.js";
7
+ import { openBrowser } from "../browser.js";
8
+ const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
9
+ function slugify(title) {
10
+ return title
11
+ .toLowerCase()
12
+ .normalize("NFD")
13
+ .replace(/[\u0300-\u036f]/g, "")
14
+ .replace(/[^a-z0-9]+/g, "-")
15
+ .replace(/^-+|-+$/g, "")
16
+ .replace(/-{2,}/g, "-");
17
+ }
18
+ function coerceJson(schema) {
19
+ return z.preprocess((val) => {
20
+ if (typeof val === "string") {
21
+ try {
22
+ return JSON.parse(val);
23
+ }
24
+ catch {
25
+ return val;
26
+ }
27
+ }
28
+ return val;
29
+ }, schema);
30
+ }
31
+ function resolvePageDir(wikiSlug) {
32
+ return join(ARTICLES_DIR, wikiSlug);
33
+ }
34
+ function resolvePagePaths(slug, wikiSlug) {
35
+ const dir = resolvePageDir(wikiSlug);
36
+ return {
37
+ dir,
38
+ filePath: join(dir, `${slug}.md`),
39
+ refPath: join(dir, `.${slug}.ref`),
40
+ };
41
+ }
42
+ const WRITING_GUIDE = `
43
+ ## Page structure
44
+
45
+ \`\`\`yaml
46
+ ---
47
+ title: Page Title
48
+ wiki: wiki-slug
49
+ topics: [topic-one, topic-two]
50
+ sources:
51
+ - key: example-source
52
+ url: https://example.com
53
+ title: Source Title
54
+ accessed_date: "2025-01-15"
55
+ infobox:
56
+ header:
57
+ image_url: https://...
58
+ subtitle: Short tagline
59
+ details:
60
+ - key: Born
61
+ value: January 1, 1990
62
+ ---
63
+
64
+ Page body with [@key] citation markers and [[wikilinks]]...
65
+ \`\`\`
66
+
67
+ ## Wikilinks
68
+
69
+ - Write natural text in double brackets: [[spool pins]], [[pin tumbler locks]]
70
+ - Display text: [[spool-pins|spool pins]]
71
+ - Cross-wiki: [[global:reddit|Reddit]], [[lockpicking:spool-pins|spool pins]]
72
+
73
+ ## Citations
74
+
75
+ - Mark claims with [@key] after punctuation
76
+ - Keys must be kebab-case with at least one hyphen
77
+ - Every source must be referenced; every reference must have a source
78
+
79
+ ## Images
80
+
81
+ Use search_images to find relevant images. Syntax: \`![Caption](url "position")\`
82
+ Positions: "right" (default), "left", "center". Every image needs a descriptive caption.
83
+ `.trim();
84
+ export function registerPageTools(server) {
85
+ server.addTool({
86
+ name: "search_articles",
87
+ description: "Search OpenAlmanac pages and stubs across all wikis. Use to check existence, find slugs for wikilinks, " +
88
+ "or discover content. Optional wiki filter to scope results. No authentication needed.",
89
+ parameters: z.object({
90
+ queries: coerceJson(z.array(z.string()).min(1).max(20)).describe("Search queries (1-20)"),
91
+ wiki: z.string().optional().describe("Filter to a specific wiki slug"),
92
+ limit: z.number().default(20).describe("Max results per query (1-50, default 20)"),
93
+ include_stubs: z.boolean().default(true).describe("Include stubs in results"),
94
+ }),
95
+ async execute({ queries, wiki, limit, include_stubs }) {
96
+ const results = {};
97
+ for (const q of queries) {
98
+ const params = {
99
+ query: q, limit, type: "pages",
100
+ };
101
+ if (wiki)
102
+ params.wiki = wiki;
103
+ if (include_stubs)
104
+ params.include_stubs = true;
105
+ const resp = await request("GET", "/api/search", { params });
106
+ results[q] = await resp.json();
107
+ }
108
+ return JSON.stringify(results, null, 2);
109
+ },
110
+ });
111
+ server.addTool({
112
+ name: "search_topics",
113
+ description: "Search OpenAlmanac topics across all wikis. Use to discover topic slugs and topic pages. " +
114
+ "Optional wiki filter to scope results. No authentication needed.",
115
+ parameters: z.object({
116
+ queries: coerceJson(z.array(z.string()).min(1).max(20)).describe("Search queries (1-20)"),
117
+ wiki: z.string().optional().describe("Filter to a specific wiki slug"),
118
+ limit: z.number().default(20).describe("Max results per query (1-50, default 20)"),
119
+ }),
120
+ async execute({ queries, wiki, limit }) {
121
+ const results = {};
122
+ for (const q of queries) {
123
+ const params = {
124
+ query: q, limit, type: "topics",
125
+ };
126
+ if (wiki)
127
+ params.wiki = wiki;
128
+ const resp = await request("GET", "/api/search", { params });
129
+ results[q] = await resp.json();
130
+ }
131
+ return JSON.stringify(results, null, 2);
132
+ },
133
+ });
134
+ server.addTool({
135
+ name: "list_articles",
136
+ description: "Browse pages in a wiki. Structured listing, not fuzzy search. " +
137
+ "Use to see what exists, find stubs, or discover pages by topic. " +
138
+ "Each returned page includes topic objects with both slug and title.",
139
+ parameters: z.object({
140
+ wiki_slug: z.string().describe("Wiki slug"),
141
+ topic: z.string().optional().describe("Filter by topic slug"),
142
+ sort: z.enum(["updated", "title"]).default("updated").describe("Sort order"),
143
+ stubs_only: z.boolean().default(false).describe("Only return stubs"),
144
+ limit: z.number().min(1).max(200).default(50).describe("Max results"),
145
+ }),
146
+ async execute({ wiki_slug, topic, sort, stubs_only, limit }) {
147
+ const params = { sort, limit };
148
+ if (topic)
149
+ params.topic = topic;
150
+ if (stubs_only)
151
+ params.stub = true;
152
+ const resp = await request("GET", `/api/w/${wiki_slug}/pages`, { params });
153
+ return JSON.stringify(await resp.json(), null, 2);
154
+ },
155
+ });
156
+ server.addTool({
157
+ name: "download",
158
+ description: "Download pages to your local workspace for editing. " +
159
+ "Files go to ~/.openalmanac/articles/{wiki_slug}/{slug}.md with a .ref sidecar. " +
160
+ "After editing, use publish to push changes. The .ref file is system-managed — don't edit it.",
161
+ parameters: z.object({
162
+ slugs: coerceJson(z.array(z.string()).min(1).max(50)).describe("Page slugs to download"),
163
+ wiki_slug: z.string().describe("Wiki slug"),
164
+ }),
165
+ async execute({ slugs, wiki_slug }) {
166
+ const resp = await request("POST", `/api/w/${wiki_slug}/download`, {
167
+ auth: true,
168
+ json: { slugs },
169
+ });
170
+ const items = (await resp.json());
171
+ const dir = resolvePageDir(wiki_slug);
172
+ mkdirSync(dir, { recursive: true });
173
+ const lines = [];
174
+ for (const item of items) {
175
+ const slug = item.filename.replace(/\.md$/, "");
176
+ const { filePath, refPath } = resolvePagePaths(slug, wiki_slug);
177
+ writeFileSync(filePath, item.content, "utf-8");
178
+ writeFileSync(refPath, item.ref, "utf-8");
179
+ lines.push(`Downloaded "${slug}" to ${filePath}`);
180
+ }
181
+ return [lines.join("\n"), "", WRITING_GUIDE].join("\n");
182
+ },
183
+ });
184
+ server.addTool({
185
+ name: "new",
186
+ description: "Scaffold new pages locally. Creates .md files with YAML frontmatter and empty bodies. " +
187
+ "No .ref file is created (new pages). After writing content, use publish to go live.",
188
+ parameters: z.object({
189
+ pages: coerceJson(z.array(z.object({
190
+ title: z.string().describe("Page title"),
191
+ slug: z.string().optional().describe("Optional explicit slug"),
192
+ topics: z.array(z.string()).optional().describe("Topic slugs"),
193
+ })).min(1).max(50)).describe("Pages to scaffold"),
194
+ wiki_slug: z.string().describe("Wiki slug"),
195
+ }),
196
+ async execute({ pages, wiki_slug }) {
197
+ const dir = resolvePageDir(wiki_slug);
198
+ mkdirSync(dir, { recursive: true });
199
+ const created = [];
200
+ const skipped = [];
201
+ for (const item of pages) {
202
+ const slug = item.slug || slugify(item.title);
203
+ if (!slug || !SLUG_RE.test(slug)) {
204
+ skipped.push(`"${item.title}" → invalid slug "${slug}"`);
205
+ continue;
206
+ }
207
+ const filePath = join(dir, `${slug}.md`);
208
+ if (existsSync(filePath)) {
209
+ skipped.push(`${slug}.md already exists`);
210
+ continue;
211
+ }
212
+ const meta = { title: item.title, wiki: wiki_slug };
213
+ if (item.topics?.length)
214
+ meta.topics = item.topics;
215
+ meta.sources = [];
216
+ const frontmatter = yamlStringify(meta);
217
+ writeFileSync(filePath, `---\n${frontmatter}---\n\n`, "utf-8");
218
+ created.push(filePath);
219
+ }
220
+ const parts = [
221
+ created.length > 0 ? `Created ${created.length} file(s):\n${created.map(p => ` - ${p}`).join("\n")}` : "No new files created.",
222
+ skipped.length > 0 ? `Skipped:\n${skipped.map(s => ` - ${s}`).join("\n")}` : "",
223
+ WRITING_GUIDE,
224
+ ];
225
+ return parts.filter(Boolean).join("\n\n");
226
+ },
227
+ });
228
+ server.addTool({
229
+ name: "publish",
230
+ description: "Publish pages from your local workspace. Reads .md files and their .ref sidecars, " +
231
+ "sends to the API. Pages with .ref are updates; pages without are new. " +
232
+ "Dead wikilinks auto-create stubs. Put edit_summary in frontmatter for change descriptions. Requires login.",
233
+ parameters: z.object({
234
+ slugs: coerceJson(z.array(z.string()).min(1).max(50)).optional()
235
+ .describe("Specific page slugs to publish"),
236
+ wiki_slug: z.string().describe("Wiki slug"),
237
+ }),
238
+ async execute({ slugs, wiki_slug }) {
239
+ const dir = resolvePageDir(wiki_slug);
240
+ // Determine which files to publish
241
+ let targetSlugs;
242
+ if (slugs?.length) {
243
+ targetSlugs = slugs;
244
+ }
245
+ else {
246
+ if (!existsSync(dir))
247
+ throw new Error(`Wiki folder not found: ${dir}`);
248
+ targetSlugs = readdirSync(dir)
249
+ .filter(f => f.endsWith(".md") && !f.startsWith("."))
250
+ .map(f => f.replace(/\.md$/, ""));
251
+ if (targetSlugs.length === 0)
252
+ throw new Error(`No .md files in ${dir}`);
253
+ }
254
+ // Build publish payload
255
+ const pages = [];
256
+ for (const slug of targetSlugs) {
257
+ const { filePath, refPath } = resolvePagePaths(slug, wiki_slug);
258
+ if (!existsSync(filePath)) {
259
+ throw new Error(`File not found: ${filePath}`);
260
+ }
261
+ const content = readFileSync(filePath, "utf-8");
262
+ const errors = validateArticle(content);
263
+ if (errors.length > 0) {
264
+ throw new Error(`Validation failed for ${slug}:\n${errors.map(e => ` ${e.field}: ${e.message}`).join("\n")}`);
265
+ }
266
+ const ref = existsSync(refPath) ? readFileSync(refPath, "utf-8").trim() : null;
267
+ pages.push({ content, ref });
268
+ }
269
+ const resp = await request("POST", `/api/w/${wiki_slug}/publish`, {
270
+ auth: true,
271
+ json: { pages },
272
+ });
273
+ const results = (await resp.json());
274
+ // Clean up local files for successful publishes
275
+ const lines = [];
276
+ let okCount = 0;
277
+ for (const r of results) {
278
+ if (r.status === "error") {
279
+ lines.push(`FAILED ${r.slug}: ${r.error}`);
280
+ continue;
281
+ }
282
+ okCount++;
283
+ // Delete local .md and .ref
284
+ const published_slug = r.renamed_from ? targetSlugs[results.indexOf(r)] : r.slug;
285
+ const { filePath, refPath } = resolvePagePaths(published_slug, wiki_slug);
286
+ try {
287
+ unlinkSync(filePath);
288
+ }
289
+ catch { /* ok */ }
290
+ try {
291
+ unlinkSync(refPath);
292
+ }
293
+ catch { /* ok */ }
294
+ let detail = `OK ${r.slug}: ${r.status}`;
295
+ if (r.renamed_from)
296
+ detail += ` (renamed from ${r.renamed_from})`;
297
+ if (r.stubs_created?.length)
298
+ detail += `\n Stubs created: ${r.stubs_created.join(", ")}`;
299
+ lines.push(detail);
300
+ // Open browser for single publish
301
+ if (targetSlugs.length === 1 && process.env.OPENALMANAC_GUI !== "1") {
302
+ const url = wiki_slug === "global"
303
+ ? `https://www.openalmanac.org/page/${r.slug}?celebrate=true`
304
+ : `https://www.openalmanac.org/w/${wiki_slug}/${r.slug}?celebrate=true`;
305
+ openBrowser(url);
306
+ }
307
+ }
308
+ return `Published ${okCount}/${targetSlugs.length}.\n\n${lines.join("\n\n")}`;
309
+ },
310
+ });
311
+ server.addTool({
312
+ name: "propose_article",
313
+ description: "Propose an article before writing it. Structures your proposal with a user-facing summary and a detailed brief. " +
314
+ "Do not start writing without proposing first.",
315
+ parameters: z.object({
316
+ summary: z.string().describe("User-facing summary (3-5 bullet points)"),
317
+ details: z.string().describe("Full handoff brief with all sources, key facts, angle"),
318
+ title: z.string().describe("Proposed title"),
319
+ slug: z.string().describe("Proposed slug (kebab-case)"),
320
+ wiki_slug: z.string().default("global").describe("Wiki slug"),
321
+ _userChoice: z.enum(["background", "here", "expired", "already_in_progress"]).optional(),
322
+ }),
323
+ async execute({ summary, details, title, slug, wiki_slug, _userChoice }) {
324
+ if (_userChoice === "background") {
325
+ return `Article "${title}" is now being written in a background process.`;
326
+ }
327
+ if (_userChoice === "expired") {
328
+ return `Proposal expired. Continue the conversation naturally.`;
329
+ }
330
+ if (_userChoice === "already_in_progress") {
331
+ return `Article "${title}" is already being generated.`;
332
+ }
333
+ return `Article Proposal: ${title}\n\n${summary}\n\nProceed with writing this article following the writing flow in your instructions.`;
334
+ },
335
+ });
336
+ server.addTool({
337
+ name: "resolve",
338
+ description: "Check if pages exist before writing wikilinks. Returns status (found/stub/not_found) for each target. " +
339
+ "Use this to verify links before publishing.",
340
+ parameters: z.object({
341
+ wiki_slug: z.string().describe("Wiki slug"),
342
+ targets: coerceJson(z.array(z.string()).min(1).max(50)).describe("Link targets to resolve"),
343
+ }),
344
+ async execute({ wiki_slug, targets }) {
345
+ const resp = await request("POST", `/api/w/${wiki_slug}/resolve`, {
346
+ json: { targets },
347
+ });
348
+ return JSON.stringify(await resp.json(), null, 2);
349
+ },
350
+ });
351
+ }
@@ -0,0 +1,2 @@
1
+ import { FastMCP } from "fastmcp";
2
+ export declare function registerTopicTools(server: FastMCP): void;
@@ -0,0 +1,67 @@
1
+ import { z } from "zod";
2
+ import { request } from "../auth.js";
3
+ function coerceJson(schema) {
4
+ return z.preprocess((val) => {
5
+ if (typeof val === "string") {
6
+ try {
7
+ return JSON.parse(val);
8
+ }
9
+ catch {
10
+ return val;
11
+ }
12
+ }
13
+ return val;
14
+ }, schema);
15
+ }
16
+ export function registerTopicTools(server) {
17
+ server.addTool({
18
+ name: "list_topics",
19
+ description: "List topics in a wiki. Returns flat list or graph (nodes + edges). No authentication needed.",
20
+ parameters: z.object({
21
+ wiki_slug: z.string().describe("Wiki slug"),
22
+ format: z.enum(["flat", "graph"]).default("flat").describe("Response format"),
23
+ }),
24
+ async execute({ wiki_slug, format }) {
25
+ const resp = await request("GET", `/api/w/${wiki_slug}/topics`, {
26
+ params: { format },
27
+ });
28
+ return JSON.stringify(await resp.json(), null, 2);
29
+ },
30
+ });
31
+ server.addTool({
32
+ name: "create_topic",
33
+ description: "Create a topic in a wiki. Topics are lightweight categories — pages can belong to multiple topics. Requires wiki membership.",
34
+ parameters: z.object({
35
+ wiki_slug: z.string().describe("Wiki slug"),
36
+ title: z.string().describe("Topic title"),
37
+ description: z.string().default("").describe("Topic description"),
38
+ parent_slugs: coerceJson(z.array(z.string())).default([]).describe("Parent topic slugs"),
39
+ }),
40
+ async execute({ wiki_slug, title, description, parent_slugs }) {
41
+ const resp = await request("POST", `/api/w/${wiki_slug}/topics`, {
42
+ auth: true,
43
+ json: { title, description, parent_slugs },
44
+ });
45
+ return JSON.stringify(await resp.json(), null, 2);
46
+ },
47
+ });
48
+ server.addTool({
49
+ name: "create_topics_batch",
50
+ description: "Batch create topics in a wiki. Useful for bootstrapping a topic hierarchy. Requires wiki membership.",
51
+ parameters: z.object({
52
+ wiki_slug: z.string().describe("Wiki slug"),
53
+ topics: coerceJson(z.array(z.object({
54
+ title: z.string(),
55
+ description: z.string().default(""),
56
+ parent_slugs: z.array(z.string()).default([]),
57
+ })).min(1).max(100)).describe("Topics to create"),
58
+ }),
59
+ async execute({ wiki_slug, topics }) {
60
+ const resp = await request("POST", `/api/w/${wiki_slug}/topics/batch`, {
61
+ auth: true,
62
+ json: { topics },
63
+ });
64
+ return JSON.stringify(await resp.json(), null, 2);
65
+ },
66
+ });
67
+ }
@@ -0,0 +1,2 @@
1
+ import { FastMCP } from "fastmcp";
2
+ export declare function registerWikiTools(server: FastMCP): void;
@@ -0,0 +1,94 @@
1
+ import { z } from "zod";
2
+ import { request } from "../auth.js";
3
+ function coerceJson(schema) {
4
+ return z.preprocess((val) => {
5
+ if (typeof val === "string") {
6
+ try {
7
+ return JSON.parse(val);
8
+ }
9
+ catch {
10
+ return val;
11
+ }
12
+ }
13
+ return val;
14
+ }, schema);
15
+ }
16
+ export function registerWikiTools(server) {
17
+ server.addTool({
18
+ name: "create_wiki",
19
+ description: "Create a new wiki. Returns the wiki slug and URL. Requires login.",
20
+ parameters: z.object({
21
+ title: z.string().describe("Wiki title"),
22
+ description: z.string().default("").describe("Wiki description"),
23
+ }),
24
+ async execute({ title, description }) {
25
+ const resp = await request("POST", "/api/wikis", {
26
+ auth: true,
27
+ json: { title, description },
28
+ });
29
+ const wiki = (await resp.json());
30
+ return `Created wiki "${wiki.title}" at /w/${wiki.slug}`;
31
+ },
32
+ });
33
+ server.addTool({
34
+ name: "get_wiki_settings",
35
+ description: "Read a wiki's details and settings (nav, cover, theme). No authentication needed.",
36
+ parameters: z.object({
37
+ wiki_slug: z.string().describe("Wiki slug"),
38
+ }),
39
+ async execute({ wiki_slug }) {
40
+ const resp = await request("GET", `/api/w/${wiki_slug}`);
41
+ return JSON.stringify(await resp.json(), null, 2);
42
+ },
43
+ });
44
+ server.addTool({
45
+ name: "update_wiki_settings",
46
+ description: "Update a wiki's settings (nav, cover_image_url, theme). Requires moderator access.",
47
+ parameters: z.object({
48
+ wiki_slug: z.string().describe("Wiki slug"),
49
+ settings: coerceJson(z.object({
50
+ nav: z.array(z.object({
51
+ label: z.string(),
52
+ page: z.string().optional(),
53
+ topic: z.string().optional(),
54
+ link: z.string().optional(),
55
+ children: z.array(z.any()).optional(),
56
+ })).optional(),
57
+ cover_image_url: z.string().optional(),
58
+ theme: z.record(z.unknown()).optional(),
59
+ })).describe("Settings to update"),
60
+ }),
61
+ async execute({ wiki_slug, settings }) {
62
+ const resp = await request("PATCH", `/api/w/${wiki_slug}/settings`, {
63
+ auth: true,
64
+ json: settings,
65
+ });
66
+ return JSON.stringify(await resp.json(), null, 2);
67
+ },
68
+ });
69
+ server.addTool({
70
+ name: "update_nav",
71
+ description: "Update just the navigation tree for a wiki. Shorthand for updating settings.nav. Requires moderator access.",
72
+ parameters: z.object({
73
+ wiki_slug: z.string().describe("Wiki slug"),
74
+ nav: coerceJson(z.array(z.object({
75
+ label: z.string(),
76
+ page: z.string().optional(),
77
+ topic: z.string().optional(),
78
+ link: z.string().optional(),
79
+ children: z.array(z.any()).optional(),
80
+ }))).describe("Nav items"),
81
+ }),
82
+ async execute({ wiki_slug, nav }) {
83
+ // Get current settings, merge nav
84
+ const getResp = await request("GET", `/api/w/${wiki_slug}`);
85
+ const wiki = (await getResp.json());
86
+ const settings = { ...wiki.settings, nav };
87
+ const resp = await request("PATCH", `/api/w/${wiki_slug}/settings`, {
88
+ auth: true,
89
+ json: settings,
90
+ });
91
+ return JSON.stringify(await resp.json(), null, 2);
92
+ },
93
+ });
94
+ }
package/dist/validate.js CHANGED
@@ -26,19 +26,12 @@ export function validateArticle(raw) {
26
26
  else if (title.length > 500) {
27
27
  errors.push({ field: "title", message: "Title must be 500 characters or fewer" });
28
28
  }
29
- // article_id / slug
30
- const articleId = frontmatter.article_id;
31
- if (articleId && typeof articleId === "string" && !SLUG_RE.test(articleId)) {
29
+ // wiki (informational, not identity)
30
+ const wiki = frontmatter.wiki;
31
+ if (wiki != null && (typeof wiki !== "string" || !SLUG_RE.test(wiki))) {
32
32
  errors.push({
33
- field: "article_id",
34
- message: "Must be kebab-case (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$",
35
- });
36
- }
37
- const communitySlug = frontmatter.community_slug;
38
- if (communitySlug != null && (typeof communitySlug !== "string" || !SLUG_RE.test(communitySlug))) {
39
- errors.push({
40
- field: "community_slug",
41
- message: "Must be kebab-case (e.g. 'lockpicking'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$",
33
+ field: "wiki",
34
+ message: "Must be kebab-case (e.g. 'lockpicking')",
42
35
  });
43
36
  }
44
37
  const topics = frontmatter.topics;