openalmanac 0.3.5 → 0.3.6

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
@@ -32,7 +32,7 @@ export function createServer() {
32
32
  name: "OpenAlmanac",
33
33
  version: pkg.version,
34
34
  instructions: [
35
- "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
+ "OpenAlmanac is an open knowledge base — a Wikipedia anyone can read from and write to through an API. Pages are primarily researched by agents, but humans can edit the pages directly on the platform too. Pages are markdown files with YAML frontmatter, [@key] citation markers, and [[wikilinks]]. Content is organized into wikis, each with topics, pages, and navigation.",
36
36
  "",
37
37
  "## How this should feel",
38
38
  "",
@@ -100,26 +100,24 @@ export function createServer() {
100
100
  "",
101
101
  '1. **Align briefly with the user** — Talk about what the article should cover, what to focus on, what angle to take. Not a rigid outline — a quick conversation. "I\'m thinking we cover the history, the Royal Brahmins, daily worship, and the Ramakien — anything you want to add or skip?"',
102
102
  "",
103
- "2. **Propose the article** — Call `propose_article` with your summary (title, key sections, angle — 3-5 bullet points) and a detailed brief (all sources, key facts, user preferences, angle, what to avoid, related articles). Do not start writing an article without proposing first. The client environment determines what happens next — in some environments the user will see a plan card with options (write here vs. run in background), in others you'll get a text response telling you to proceed. Follow whatever the tool response says.",
103
+ "2. **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.",
104
104
  "",
105
- "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
+ "3. **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.",
106
106
  "",
107
- "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.",
108
- "",
109
- "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.",
107
+ "4. **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.",
110
108
  "",
111
109
  " Write the full article body with citation markers [@key]. No wikilinks, no `[[slug|Display Text]]` syntax, no images, no stubs. Just prose and citations. The linking and images come later from subagents who need to read the finished text.",
112
110
  "",
113
- "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.",
111
+ "5. **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.",
114
112
  "",
115
113
  " - **Review agent** → tell it to read https://www.openalmanac.org/review-guidelines.md and review the draft at `~/.openalmanac/articles/{wiki_slug}/{slug}.md`",
116
114
  " - **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`",
117
115
  " - **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`",
118
116
  " - **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)",
119
117
  "",
120
- "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.",
118
+ "6. **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.",
121
119
  "",
122
- "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.",
120
+ "7. **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.",
123
121
  "",
124
122
  "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.",
125
123
  "",
@@ -160,7 +158,7 @@ export function createServer() {
160
158
  "3. **Brainstorm structure with the user.** Be conversational, not a form-filler. Propose a few natural topics the wiki might have (3-6 bullet points max), what the scope is, what the voice/angle is. Ask what resonates. Don't write prose yet.",
161
159
  "4. **Create the wiki.** `create_wiki` with title + description. The server auto-scaffolds a `main-page` with default homepage directives.",
162
160
  "5. **Edit the main page in place.** `download` the auto-created `main-page` and edit the file (don't `new` a fresh `main-page` — the server derives slug from title, so a new scaffold would get a different slug and you'd end up with two homepages).",
163
- "6. **Seed the topic hierarchy.** `create_topics_batch` with the topics you agreed on.",
161
+ "6. **Seed the topic hierarchy.** `create_topics` with the topics you agreed on.",
164
162
  "7. **Wire navigation.** `update_wiki_settings` with a `nav` array. Each NavItem needs exactly one of `page` / `topic` / `link`. Use `auto: {enabled: true}` on topic NavItems to auto-populate children from the topic DAG.",
165
163
  "8. **Seed a few stub pages or first articles.** Stubs are fine — scaffold-and-fill-later is a supported workflow.",
166
164
  "",
@@ -175,6 +173,10 @@ export function createServer() {
175
173
  "After publishing, share the celebration URL when applicable. Use `list_articles` with `wiki_slug` to browse a wiki's pages.",
176
174
  "",
177
175
  "When working with tool results, write down any important information you might need later, as the original tool result may be cleared.",
176
+ "",
177
+ "## Batching writes",
178
+ "",
179
+ "Most write tools take arrays. Pass a single-element array for one item — there is no separate singular tool. Examples: `create_topics([{ title: \"X\" }])`, `delete_pages({ article_slugs: [\"foo\"] })`, `publish({ slugs: [\"bar\"] })`. Do not call a tool in a loop when an array argument exists.",
178
180
  ].join("\n"),
179
181
  });
180
182
  registerAuthTools(server);
package/dist/setup.js CHANGED
@@ -18,11 +18,10 @@ const TOOL_GROUPS = [
18
18
  },
19
19
  {
20
20
  name: "Research",
21
- description: "web search, read pages, register sources, find images",
21
+ description: "web search, read pages, find images",
22
22
  tools: [
23
23
  "mcp__almanac__search_web",
24
24
  "mcp__almanac__read_webpage",
25
- "mcp__almanac__register_sources",
26
25
  "mcp__almanac__search_images",
27
26
  "mcp__almanac__view_images",
28
27
  ],
@@ -3,31 +3,9 @@ import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, unlink
3
3
  import { join } from "node:path";
4
4
  import { stringify as yamlStringify } from "yaml";
5
5
  import { request, ARTICLES_DIR } from "../auth.js";
6
- import { validateArticle } from "../validate.js";
7
6
  import { openBrowser } from "../browser.js";
7
+ import { coerceJson } from "../utils.js";
8
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
9
  function resolvePageDir(wikiSlug) {
32
10
  return join(ARTICLES_DIR, wikiSlug);
33
11
  }
@@ -76,11 +54,111 @@ Page body with [@key] citation markers and [[wikilinks]]...
76
54
  - Keys must be kebab-case with at least one hyphen
77
55
  - Every source must be referenced; every reference must have a source
78
56
 
57
+ ## Quoting
58
+
59
+ For any string value with punctuation, quotes, or special characters (common in \`sources[].title\`), use YAML block-literal syntax:
60
+
61
+ \`\`\`yaml
62
+ sources:
63
+ - key: farza-yc
64
+ title: |-
65
+ "I'm joining Y Combinator, again" — Farza Majeed
66
+ url: https://...
67
+ \`\`\`
68
+
69
+ This sidesteps every YAML escaping rule. If you skip this, inner double quotes or em-dashes will break the parser.
70
+
79
71
  ## Images
80
72
 
81
73
  Use search_images to find relevant images. Syntax: \`![Caption](url "position")\`
82
74
  Positions: "right" (default), "left", "center". Every image needs a descriptive caption.
83
75
  `.trim();
76
+ function formatPublishResults(results, targetSlugs, wiki_slug, dry_run) {
77
+ const allAutoStubs = new Set();
78
+ const lines = [];
79
+ let okCount = 0;
80
+ let errorCount = 0;
81
+ for (let i = 0; i < results.length; i++) {
82
+ const r = results[i];
83
+ const slug = targetSlugs[i] ?? r.slug;
84
+ if (dry_run && r.plan) {
85
+ const plan = r.plan;
86
+ const hasError = plan.validation.status === "failed" ||
87
+ !plan.authorization.can_write ||
88
+ plan.action === "error";
89
+ if (hasError) {
90
+ errorCount++;
91
+ const reasons = [];
92
+ for (const e of plan.validation.errors) {
93
+ reasons.push(`${e.field}: ${e.message}`);
94
+ }
95
+ if (!plan.authorization.can_write && plan.authorization.reason) {
96
+ reasons.push(`auth: ${plan.authorization.reason}`);
97
+ }
98
+ lines.push(`- ${slug}: **error** — ${reasons.join("; ")}`);
99
+ }
100
+ else {
101
+ okCount++;
102
+ let line = `- ${plan.slug}: **${plan.action}**`;
103
+ if (plan.renamed_from)
104
+ line += ` (rename: ${plan.renamed_from} → ${plan.slug})`;
105
+ const details = [];
106
+ if (plan.source_keys.referenced.length > 0) {
107
+ details.push(`${plan.source_keys.referenced.length} source(s)`);
108
+ }
109
+ if (plan.wikilinks.will_auto_stub.length > 0) {
110
+ details.push(`${plan.wikilinks.will_auto_stub.length} new stub(s)`);
111
+ plan.wikilinks.will_auto_stub.forEach(s => allAutoStubs.add(s));
112
+ }
113
+ if (plan.source_keys.orphaned.length > 0) {
114
+ details.push(`missing source key(s): ${plan.source_keys.orphaned.join(", ")}`);
115
+ }
116
+ if (plan.source_keys.unreferenced.length > 0) {
117
+ details.push(`unreferenced source(s): ${plan.source_keys.unreferenced.join(", ")}`);
118
+ }
119
+ if (details.length > 0)
120
+ line += ` (${details.join(", ")})`;
121
+ lines.push(line);
122
+ }
123
+ }
124
+ else {
125
+ // Real publish result
126
+ if (r.status === "error") {
127
+ errorCount++;
128
+ lines.push(`- ${r.slug}: **error** — ${r.error}`);
129
+ }
130
+ else {
131
+ okCount++;
132
+ // Clean up local files — pre-rename slug names the file
133
+ const fileSlug = r.renamed_from ?? slug;
134
+ const { filePath, refPath } = resolvePagePaths(fileSlug, wiki_slug);
135
+ try {
136
+ unlinkSync(filePath);
137
+ }
138
+ catch { /* ok */ }
139
+ try {
140
+ unlinkSync(refPath);
141
+ }
142
+ catch { /* ok */ }
143
+ let line = `- ${r.slug}: **${r.status}**`;
144
+ if (r.renamed_from)
145
+ line += ` (renamed from ${r.renamed_from})`;
146
+ if (r.stubs_created?.length) {
147
+ r.stubs_created.forEach(s => allAutoStubs.add(s));
148
+ }
149
+ lines.push(line);
150
+ }
151
+ }
152
+ }
153
+ const verb = dry_run ? "Dry-run" : "Published";
154
+ const summary = `${verb}: ${okCount}/${targetSlugs.length} OK${errorCount > 0 ? `, ${errorCount} error(s)` : ""}.`;
155
+ const parts = [summary, "", ...lines];
156
+ if (allAutoStubs.size > 0) {
157
+ const stubVerb = dry_run ? "Stubs that will be auto-created" : "Stubs auto-created";
158
+ parts.push("", `${stubVerb}: ${[...allAutoStubs].join(", ")}`);
159
+ }
160
+ return parts.join("\n");
161
+ }
84
162
  export function registerPageTools(server) {
85
163
  server.addTool({
86
164
  name: "search_articles",
@@ -187,11 +265,19 @@ export function registerPageTools(server) {
187
265
  server.addTool({
188
266
  name: "new",
189
267
  description: "Scaffold new pages locally. Creates .md files with YAML frontmatter and empty bodies. " +
190
- "No .ref file is created (new pages). After writing content, use publish to go live.",
268
+ "No .ref file is created (new pages). After writing content, use publish to go live.\n\n" +
269
+ "Passing `slug` is an identity claim, not just a filename hint. The server will honor it " +
270
+ "at publish time instead of deriving a slug from the title. " +
271
+ "If no slug is provided, the server derives the slug from the title at publish.\n\n" +
272
+ "To edit the auto-generated main-page created by create_wiki, do NOT use `new` — " +
273
+ "use `download` with slug `main-page` to get the page and its ref token, then edit and publish. " +
274
+ "Publishing without a ref token is a create operation and will fail with a slug collision " +
275
+ "because main-page already exists.",
191
276
  parameters: z.object({
192
277
  pages: coerceJson(z.array(z.object({
193
278
  title: z.string().describe("Page title"),
194
- slug: z.string().optional().describe("Optional explicit slug"),
279
+ slug: z.string().optional().describe("Optional explicit slug (kebab-case). When provided, the server uses this slug " +
280
+ "at publish instead of deriving one from the title."),
195
281
  topics: z.array(z.string()).optional().describe("Topic slugs"),
196
282
  })).min(1).max(50)).describe("Pages to scaffold"),
197
283
  wiki_slug: z.string().describe("Wiki slug"),
@@ -202,17 +288,37 @@ export function registerPageTools(server) {
202
288
  const created = [];
203
289
  const skipped = [];
204
290
  for (const item of pages) {
205
- const slug = item.slug || slugify(item.title);
206
- if (!slug || !SLUG_RE.test(slug)) {
207
- skipped.push(`"${item.title}" invalid slug "${slug}"`);
208
- continue;
291
+ // If an explicit slug is provided, validate it and use it for the filename.
292
+ // If none is provided, derive a simple filename from the title for local
293
+ // convenience only — the server will derive the authoritative slug from
294
+ // the title at publish time.
295
+ let fileSlug;
296
+ if (item.slug) {
297
+ if (!SLUG_RE.test(item.slug)) {
298
+ skipped.push(`"${item.title}" → invalid slug "${item.slug}"`);
299
+ continue;
300
+ }
301
+ fileSlug = item.slug;
302
+ }
303
+ else {
304
+ // Local filename only — server derives from title at publish.
305
+ fileSlug = item.title
306
+ .toLowerCase()
307
+ .replace(/[^a-z0-9]+/g, "-")
308
+ .replace(/^-+|-+$/g, "")
309
+ .replace(/-{2,}/g, "-") || "untitled";
209
310
  }
210
- const filePath = join(dir, `${slug}.md`);
311
+ const filePath = join(dir, `${fileSlug}.md`);
211
312
  if (existsSync(filePath)) {
212
- skipped.push(`${slug}.md already exists`);
313
+ skipped.push(`${fileSlug}.md already exists`);
213
314
  continue;
214
315
  }
215
316
  const meta = { title: item.title, wiki: wiki_slug };
317
+ // Embed explicit slug in frontmatter so the server binds it at publish.
318
+ // Without this, changing the title would change the slug; with it, the
319
+ // server uses this slug regardless of the title's derived form.
320
+ if (item.slug)
321
+ meta.slug = item.slug;
216
322
  if (item.topics?.length)
217
323
  meta.topics = item.topics;
218
324
  meta.sources = [];
@@ -232,13 +338,19 @@ export function registerPageTools(server) {
232
338
  name: "publish",
233
339
  description: "Publish pages from your local workspace. Reads .md files and their .ref sidecars, " +
234
340
  "sends to the API. Pages with .ref are updates; pages without are new. " +
235
- "Dead wikilinks auto-create stubs. Put edit_summary in frontmatter for change descriptions. Requires login.",
341
+ "Dead wikilinks auto-create stubs. Put edit_summary in frontmatter for change descriptions. Requires login.\n\n" +
342
+ "Set dry_run=true to plan without committing: the backend validates frontmatter, checks authorization, " +
343
+ "resolves wikilinks, cross-checks citation keys, and detects renames — all read-only. " +
344
+ "Caveats: plan reflects state at time of check — permissions and slug availability may change before real publish. " +
345
+ "Rename detection shows the slug derived from the current title; subsequent title edits can change this.",
236
346
  parameters: z.object({
237
347
  slugs: coerceJson(z.array(z.string()).min(1).max(50)).optional()
238
348
  .describe("Specific page slugs to publish"),
239
349
  wiki_slug: z.string().describe("Wiki slug"),
350
+ dry_run: z.boolean().default(false).optional()
351
+ .describe("When true, plan all pages without committing any changes"),
240
352
  }),
241
- async execute({ slugs, wiki_slug }) {
353
+ async execute({ slugs, wiki_slug, dry_run }) {
242
354
  const dir = resolvePageDir(wiki_slug);
243
355
  // Determine which files to publish
244
356
  let targetSlugs;
@@ -262,95 +374,103 @@ export function registerPageTools(server) {
262
374
  throw new Error(`File not found: ${filePath}`);
263
375
  }
264
376
  const content = readFileSync(filePath, "utf-8");
265
- const errors = validateArticle(content);
266
- if (errors.length > 0) {
267
- throw new Error(`Validation failed for ${slug}:\n${errors.map(e => ` ${e.field}: ${e.message}`).join("\n")}`);
268
- }
269
377
  const ref = existsSync(refPath) ? readFileSync(refPath, "utf-8").trim() : null;
270
378
  pages.push({ content, ref });
271
379
  }
272
- const resp = await request("POST", `/api/w/${wiki_slug}/publish`, {
380
+ const endpoint = dry_run
381
+ ? `/api/w/${wiki_slug}/publish?dry_run=true`
382
+ : `/api/w/${wiki_slug}/publish`;
383
+ const resp = await request("POST", endpoint, {
273
384
  auth: true,
274
385
  json: { pages },
275
386
  });
276
387
  const results = (await resp.json());
277
- // Clean up local files for successful publishes
278
- const lines = [];
279
- let okCount = 0;
280
- for (const r of results) {
281
- if (r.status === "error") {
282
- lines.push(`FAILED ${r.slug}: ${r.error}`);
283
- continue;
284
- }
285
- okCount++;
286
- // The local file was named with the pre-rename slug. The server returns
287
- // `renamed_from` on rename so we can clean up the right file without
288
- // relying on request/response index parity.
289
- const published_slug = r.renamed_from ?? r.slug;
290
- const { filePath, refPath } = resolvePagePaths(published_slug, wiki_slug);
291
- try {
292
- unlinkSync(filePath);
293
- }
294
- catch { /* ok */ }
295
- try {
296
- unlinkSync(refPath);
297
- }
298
- catch { /* ok */ }
299
- let detail = `OK ${r.slug}: ${r.status}`;
300
- if (r.renamed_from)
301
- detail += ` (renamed from ${r.renamed_from})`;
302
- if (r.stubs_created?.length)
303
- detail += `\n Stubs created: ${r.stubs_created.join(", ")}`;
304
- lines.push(detail);
305
- // Open browser for single publish
306
- if (targetSlugs.length === 1 && process.env.OPENALMANAC_GUI !== "1") {
388
+ const summary = formatPublishResults(results, targetSlugs, wiki_slug, dry_run ?? false);
389
+ // Open browser on single-page publish success (non-GUI, non-dry-run).
390
+ if (!dry_run && targetSlugs.length === 1 && process.env.OPENALMANAC_GUI !== "1") {
391
+ const r = results[0];
392
+ if (r && r.status !== "error") {
393
+ const resultSlug = r.slug;
307
394
  const url = wiki_slug === "global"
308
- ? `https://www.openalmanac.org/page/${r.slug}?celebrate=true`
309
- : `https://www.openalmanac.org/w/${wiki_slug}/${r.slug}?celebrate=true`;
395
+ ? `https://www.openalmanac.org/page/${resultSlug}?celebrate=true`
396
+ : `https://www.openalmanac.org/w/${wiki_slug}/${resultSlug}?celebrate=true`;
310
397
  openBrowser(url);
311
398
  }
312
399
  }
313
- return `Published ${okCount}/${targetSlugs.length}.\n\n${lines.join("\n\n")}`;
400
+ return summary;
314
401
  },
315
402
  });
403
+ // propose_article — GUI-only handshake. Commented out 2026-04-23 per REV-62.
404
+ // Revive when the GUI plan-card proposal flow is in active use.
405
+ /*
316
406
  server.addTool({
317
- name: "propose_article",
318
- description: "Propose an article before writing it. Structures your proposal with a user-facing summary and a detailed brief. " +
319
- "Do not start writing without proposing first.",
407
+ name: "propose_article",
408
+ description:
409
+ "Propose an article before writing it. Structures your proposal with a user-facing summary and a detailed brief. " +
410
+ "Do not start writing without proposing first.",
411
+ parameters: z.object({
412
+ summary: z.string().describe("User-facing summary (3-5 bullet points)"),
413
+ details: z.string().describe("Full handoff brief with all sources, key facts, angle"),
414
+ title: z.string().describe("Proposed title"),
415
+ slug: z.string().describe("Proposed slug (kebab-case)"),
416
+ wiki_slug: z.string().default("global").describe("Wiki slug"),
417
+ _userChoice: z.enum(["background", "here", "expired", "already_in_progress"]).optional(),
418
+ }),
419
+ async execute({ summary, details, title, slug, wiki_slug, _userChoice }) {
420
+ if (_userChoice === "background") {
421
+ return `Article "${title}" is now being written in a background process.`;
422
+ }
423
+ if (_userChoice === "expired") {
424
+ return `Proposal expired. Continue the conversation naturally.`;
425
+ }
426
+ if (_userChoice === "already_in_progress") {
427
+ return `Article "${title}" is already being generated.`;
428
+ }
429
+ return `Article Proposal: ${title}\n\n${summary}\n\nProceed with writing this article following the writing flow in your instructions.`;
430
+ },
431
+ });
432
+ */
433
+ server.addTool({
434
+ name: "read_article",
435
+ description: "Read a single page by slug. Returns the full page JSON including content, topics, sources, and infobox. " +
436
+ "No side effects — use this to read a page without downloading it to disk or joining the wiki. " +
437
+ "For editing, use `download` instead (it writes local files and handles ref tokens). " +
438
+ "For discovery, use `search_articles` instead. No authentication needed.",
320
439
  parameters: z.object({
321
- summary: z.string().describe("User-facing summary (3-5 bullet points)"),
322
- details: z.string().describe("Full handoff brief with all sources, key facts, angle"),
323
- title: z.string().describe("Proposed title"),
324
- slug: z.string().describe("Proposed slug (kebab-case)"),
325
- wiki_slug: z.string().default("global").describe("Wiki slug"),
326
- _userChoice: z.enum(["background", "here", "expired", "already_in_progress"]).optional(),
440
+ wiki_slug: z.string().describe("Wiki slug"),
441
+ article_slug: z.string().describe("Page slug"),
327
442
  }),
328
- async execute({ summary, details, title, slug, wiki_slug, _userChoice }) {
329
- if (_userChoice === "background") {
330
- return `Article "${title}" is now being written in a background process.`;
331
- }
332
- if (_userChoice === "expired") {
333
- return `Proposal expired. Continue the conversation naturally.`;
334
- }
335
- if (_userChoice === "already_in_progress") {
336
- return `Article "${title}" is already being generated.`;
337
- }
338
- return `Article Proposal: ${title}\n\n${summary}\n\nProceed with writing this article following the writing flow in your instructions.`;
443
+ async execute({ wiki_slug, article_slug }) {
444
+ const resp = await request("GET", `/api/w/${wiki_slug}/pages/${article_slug}`);
445
+ return JSON.stringify(await resp.json(), null, 2);
339
446
  },
340
447
  });
341
448
  server.addTool({
342
- name: "resolve",
343
- description: "Check if pages exist before writing wikilinks. Returns status (found/stub/not_found) for each target. " +
344
- "Use this to verify links before publishing.",
449
+ name: "delete_pages",
450
+ description: "⚠️ Permanently deletes pages. Cannot be undone. Confirm with user before calling. " +
451
+ "Accepts multiple slugs and deletes them in sequence. Requires moderator or creator access.",
345
452
  parameters: z.object({
346
453
  wiki_slug: z.string().describe("Wiki slug"),
347
- targets: coerceJson(z.array(z.string()).min(1).max(50)).describe("Link targets to resolve"),
454
+ article_slugs: coerceJson(z.array(z.string()).min(1).max(50)).describe("Page slugs to delete (1-50)"),
348
455
  }),
349
- async execute({ wiki_slug, targets }) {
350
- const resp = await request("POST", `/api/w/${wiki_slug}/resolve`, {
351
- json: { targets },
352
- });
353
- return JSON.stringify(await resp.json(), null, 2);
456
+ async execute({ wiki_slug, article_slugs }) {
457
+ const results = [];
458
+ for (const slug of article_slugs) {
459
+ try {
460
+ // DELETE returns 204 No Content on success
461
+ await request("DELETE", `/api/w/${wiki_slug}/pages/${slug}`, { auth: true });
462
+ results.push({ slug, status: "deleted" });
463
+ }
464
+ catch (err) {
465
+ const message = err instanceof Error ? err.message : String(err);
466
+ results.push({ slug, status: "error", message });
467
+ }
468
+ }
469
+ const deleted = results.filter(r => r.status === "deleted").length;
470
+ const lines = results.map(r => r.status === "deleted"
471
+ ? `- ${r.slug}: deleted`
472
+ : `- ${r.slug}: error — ${r.message}`);
473
+ return `Deleted ${deleted}/${article_slugs.length} pages.\n\n${lines.join("\n")}`;
354
474
  },
355
475
  });
356
476
  }
@@ -1,24 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { imageContent } from "fastmcp";
3
3
  import { request } from "../auth.js";
4
- /**
5
- * Workaround for Claude Agent SDK MCP transport bug (#18260):
6
- * Array/object parameters are sometimes serialized as JSON strings
7
- * instead of native values. This preprocessor coerces them back.
8
- */
9
- function coerceJson(schema) {
10
- return z.preprocess((val) => {
11
- if (typeof val === "string") {
12
- try {
13
- return JSON.parse(val);
14
- }
15
- catch {
16
- return val;
17
- }
18
- }
19
- return val;
20
- }, schema);
21
- }
4
+ import { coerceJson } from "../utils.js";
22
5
  export function registerResearchTools(server) {
23
6
  const SearchWebInput = z.object({
24
7
  source: z.enum(["web", "reddit"]).describe("Search source. Use 'web' for Google/Serper and 'reddit' for community perspectives via Reddit."),
@@ -118,7 +101,7 @@ export function registerResearchTools(server) {
118
101
  server.addTool({
119
102
  name: "search_images",
120
103
  description: "Search for images to include in articles. Accepts multiple queries for batch lookup. Returns image URLs, titles, dimensions, and licensing info. " +
121
- "Two sources: 'wikimedia' (free, open-licensed images from Wikimedia Commons preferred) and 'google' (broader web images via Google). " +
104
+ "Three sources: 'google' (broad web images, default), 'unsplash' (high-quality stock photos), and 'wikimedia' (free, open-licensed from Wikimedia Commons). " +
122
105
  "Use descriptive search terms. After searching, call view_images on promising candidates to see what they actually show before using them. " +
123
106
  "External image URLs are automatically persisted when you publish the article — no extra steps needed.\n\n" +
124
107
  "## Using images in articles\n\n" +
@@ -145,7 +128,7 @@ export function registerResearchTools(server) {
145
128
  "Requires login. Rate limit: 10/min.",
146
129
  parameters: z.object({
147
130
  queries: coerceJson(z.array(z.string()).min(1).max(10)).describe("Image search queries (1-10)"),
148
- source: z.enum(["wikimedia", "google"]).default("wikimedia").describe("Image source: 'wikimedia' (free, open-licensed preferred) or 'google' (broader coverage)"),
131
+ source: z.enum(["wikimedia", "google", "unsplash"]).default("google").describe("Image source: 'google' (broad web images, default), 'unsplash' (high-quality stock photos), or 'wikimedia' (free, open-licensed)"),
149
132
  limit: z.number().default(5).describe("Max results per query (1-10, default 5)"),
150
133
  }),
151
134
  async execute({ queries, source, limit }) {
@@ -186,20 +169,25 @@ export function registerResearchTools(server) {
186
169
  return { content };
187
170
  },
188
171
  });
172
+ // register_sources — GUI citation-bubble handshake. Commented out 2026-04-23 per REV-62.
173
+ // Revive when the GUI citation-bubble flow is re-wired.
174
+ /*
189
175
  server.addTool({
190
- name: "register_sources",
191
- description: "Register sources you plan to cite in your response. Call this BEFORE writing your response text. " +
192
- "In GUI explore sessions this updates the source registry used for citation bubbles. " +
193
- "Use [@key] markers in your response to cite them.",
194
- parameters: z.object({
195
- sources: coerceJson(z.array(z.object({
196
- key: z.string().describe("Citation key — kebab-case, BibTeX-style: {domain}-{title-words}"),
197
- url: z.string().describe("Source URL"),
198
- title: z.string().describe("Source title — include publication name after an em dash when relevant"),
199
- })).min(1)).describe("Sources to register for citation"),
200
- }),
201
- async execute({ sources }) {
202
- return `Registered ${sources.length} source${sources.length === 1 ? "" : "s"}. Use [@key] markers in your response to cite them.`;
203
- },
176
+ name: "register_sources",
177
+ description:
178
+ "Register sources you plan to cite in your response. Call this BEFORE writing your response text. " +
179
+ "In GUI explore sessions this updates the source registry used for citation bubbles. " +
180
+ "Use [@key] markers in your response to cite them.",
181
+ parameters: z.object({
182
+ sources: coerceJson(z.array(z.object({
183
+ key: z.string().describe("Citation key — kebab-case, BibTeX-style: {domain}-{title-words}"),
184
+ url: z.string().describe("Source URL"),
185
+ title: z.string().describe("Source title include publication name after an em dash when relevant"),
186
+ })).min(1)).describe("Sources to register for citation"),
187
+ }),
188
+ async execute({ sources }) {
189
+ return `Registered ${sources.length} source${sources.length === 1 ? "" : "s"}. Use [@key] markers in your response to cite them.`;
190
+ },
204
191
  });
192
+ */
205
193
  }
@@ -1,18 +1,6 @@
1
1
  import { z } from "zod";
2
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
- }
3
+ import { coerceJson } from "../utils.js";
16
4
  export function registerTopicTools(server) {
17
5
  server.addTool({
18
6
  name: "list_topics",
@@ -28,24 +16,6 @@ export function registerTopicTools(server) {
28
16
  return JSON.stringify(await resp.json(), null, 2);
29
17
  },
30
18
  });
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
- image_url: z.string().url().max(2048).optional().describe("Topic image URL (https:// or http://)"),
39
- parent_slugs: coerceJson(z.array(z.string())).default([]).describe("Parent topic slugs"),
40
- }),
41
- async execute({ wiki_slug, title, description, image_url, parent_slugs }) {
42
- const resp = await request("POST", `/api/w/${wiki_slug}/topics`, {
43
- auth: true,
44
- json: { title, description, image_url, parent_slugs },
45
- });
46
- return JSON.stringify(await resp.json(), null, 2);
47
- },
48
- });
49
19
  server.addTool({
50
20
  name: "update_topic",
51
21
  description: "Update a topic's title, description, or image. Requires wiki moderator role.",
@@ -75,8 +45,8 @@ export function registerTopicTools(server) {
75
45
  },
76
46
  });
77
47
  server.addTool({
78
- name: "create_topics_batch",
79
- description: "Batch create topics in a wiki. Useful for bootstrapping a topic hierarchy. Requires wiki membership.",
48
+ name: "create_topics",
49
+ description: "Create one or more topics in a wiki. Topics are lightweight categories — pages can belong to multiple topics. Pass a single-element array for one topic: `[{ title: \"Security Pins\" }]`. Useful for bootstrapping a topic hierarchy. Requires wiki membership.",
80
50
  parameters: z.object({
81
51
  wiki_slug: z.string().describe("Wiki slug"),
82
52
  topics: coerceJson(z.array(z.object({
@@ -84,7 +54,7 @@ export function registerTopicTools(server) {
84
54
  description: z.string().default(""),
85
55
  image_url: z.string().url().max(2048).optional(),
86
56
  parent_slugs: z.array(z.string()).default([]),
87
- })).min(1).max(100)).describe("Topics to create"),
57
+ })).min(1).max(100)).describe("Topics to create (N=1 is fully supported)"),
88
58
  }),
89
59
  async execute({ wiki_slug, topics }) {
90
60
  const resp = await request("POST", `/api/w/${wiki_slug}/topics/batch`, {