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 +0 -0
- package/dist/server.js +12 -10
- package/dist/setup.js +1 -2
- package/dist/tools/pages.js +220 -100
- package/dist/tools/research.js +22 -34
- package/dist/tools/topics.js +4 -34
- package/dist/tools/wikis.js +25 -27
- package/dist/utils.d.ts +7 -0
- package/dist/utils.js +19 -0
- package/package.json +1 -1
- package/dist/tools/articles.d.ts +0 -2
- package/dist/tools/articles.js +0 -401
- package/dist/tools/communities.d.ts +0 -2
- package/dist/tools/communities.js +0 -127
- package/dist/tools/people.d.ts +0 -2
- package/dist/tools/people.js +0 -20
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. **
|
|
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. **
|
|
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. **
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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.** `
|
|
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,
|
|
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
|
],
|
package/dist/tools/pages.js
CHANGED
|
@@ -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: \`\`
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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, `${
|
|
311
|
+
const filePath = join(dir, `${fileSlug}.md`);
|
|
211
312
|
if (existsSync(filePath)) {
|
|
212
|
-
skipped.push(`${
|
|
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
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
if (r.status
|
|
282
|
-
|
|
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/${
|
|
309
|
-
: `https://www.openalmanac.org/w/${wiki_slug}/${
|
|
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
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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({
|
|
329
|
-
|
|
330
|
-
|
|
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: "
|
|
343
|
-
description: "
|
|
344
|
-
"
|
|
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
|
-
|
|
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,
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
}
|
package/dist/tools/research.js
CHANGED
|
@@ -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
|
-
"
|
|
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("
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}),
|
|
201
|
-
|
|
202
|
-
|
|
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
|
}
|
package/dist/tools/topics.js
CHANGED
|
@@ -1,18 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { request } from "../auth.js";
|
|
3
|
-
|
|
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: "
|
|
79
|
-
description: "
|
|
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`, {
|