openalmanac 0.2.33 → 0.2.35
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/auth.d.ts +1 -1
- package/dist/server.js +7 -7
- package/dist/setup.js +6 -8
- package/dist/tools/articles.js +268 -186
- package/dist/tools/communities.js +10 -54
- package/dist/tools/research.js +4 -4
- package/dist/validate.js +24 -0
- package/package.json +1 -1
package/dist/auth.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export declare function getAuthStatus(): Promise<AuthStatus>;
|
|
|
16
16
|
export declare function buildAuthHeaders(): Record<string, string>;
|
|
17
17
|
export declare function request(method: string, path: string, options?: {
|
|
18
18
|
auth?: boolean;
|
|
19
|
-
params?: Record<string, string | number>;
|
|
19
|
+
params?: Record<string, string | number | boolean>;
|
|
20
20
|
json?: unknown;
|
|
21
21
|
body?: string;
|
|
22
22
|
contentType?: string;
|
package/dist/server.js
CHANGED
|
@@ -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
|
|
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.",
|
|
51
51
|
"",
|
|
52
52
|
"**Images:** Use `search_images` to find 1-2 relevant images for your response. Include them using the figure syntax: `` 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
|
|
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.",
|
|
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`
|
|
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.",
|
|
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
|
"",
|
|
@@ -114,11 +114,11 @@ export function createServer() {
|
|
|
114
114
|
" - **Review agent** → tell it to read https://www.openalmanac.org/review-guidelines.md and review the draft at `~/.openalmanac/articles/{slug}.md`",
|
|
115
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
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
|
|
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)",
|
|
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. Share the exact URL from the publish response
|
|
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.",
|
|
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,9 +126,9 @@ 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 (scaffold) or download (
|
|
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).",
|
|
130
130
|
"",
|
|
131
|
-
"After publishing, share the celebration URL.
|
|
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.",
|
|
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"),
|
package/dist/setup.js
CHANGED
|
@@ -6,32 +6,31 @@ import { getAuthStatus } from "./auth.js";
|
|
|
6
6
|
const TOOL_GROUPS = [
|
|
7
7
|
{
|
|
8
8
|
name: "Search & Read",
|
|
9
|
-
description: "search
|
|
9
|
+
description: "search, read, download, and browse community wiki articles",
|
|
10
10
|
tools: [
|
|
11
11
|
"mcp__almanac__search_articles",
|
|
12
12
|
"mcp__almanac__download",
|
|
13
13
|
"mcp__almanac__read",
|
|
14
|
-
"
|
|
15
|
-
"mcp__almanac__requested_articles",
|
|
14
|
+
"mcp__almanac__list_articles",
|
|
16
15
|
],
|
|
17
16
|
},
|
|
18
17
|
{
|
|
19
18
|
name: "Research",
|
|
20
|
-
description: "web search, read pages, find images",
|
|
19
|
+
description: "web search, read pages, register sources, find images",
|
|
21
20
|
tools: [
|
|
22
21
|
"mcp__almanac__search_web",
|
|
23
22
|
"mcp__almanac__read_webpage",
|
|
23
|
+
"mcp__almanac__register_sources",
|
|
24
24
|
"mcp__almanac__search_images",
|
|
25
25
|
"mcp__almanac__view_images",
|
|
26
26
|
],
|
|
27
27
|
},
|
|
28
28
|
{
|
|
29
29
|
name: "Write & Publish",
|
|
30
|
-
description: "create
|
|
30
|
+
description: "create article drafts and publish edits",
|
|
31
31
|
tools: [
|
|
32
32
|
"mcp__almanac__new",
|
|
33
33
|
"mcp__almanac__publish",
|
|
34
|
-
"mcp__almanac__create_stubs",
|
|
35
34
|
],
|
|
36
35
|
},
|
|
37
36
|
{
|
|
@@ -41,12 +40,11 @@ const TOOL_GROUPS = [
|
|
|
41
40
|
},
|
|
42
41
|
{
|
|
43
42
|
name: "Community",
|
|
44
|
-
description: "communities
|
|
43
|
+
description: "communities and posts",
|
|
45
44
|
tools: [
|
|
46
45
|
"mcp__almanac__search_communities",
|
|
47
46
|
"mcp__almanac__create_community",
|
|
48
47
|
"mcp__almanac__create_post",
|
|
49
|
-
"mcp__almanac__link_article",
|
|
50
48
|
],
|
|
51
49
|
},
|
|
52
50
|
{
|
package/dist/tools/articles.js
CHANGED
|
@@ -2,10 +2,19 @@ import { z } from "zod";
|
|
|
2
2
|
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync, unlinkSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { stringify as yamlStringify } from "yaml";
|
|
5
|
-
import { request, ARTICLES_DIR
|
|
5
|
+
import { request, ARTICLES_DIR } from "../auth.js";
|
|
6
6
|
import { validateArticle, parseFrontmatter } from "../validate.js";
|
|
7
7
|
import { openBrowser } from "../browser.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
|
+
}
|
|
9
18
|
/**
|
|
10
19
|
* Workaround for Claude Agent SDK MCP transport bug (#18260):
|
|
11
20
|
* Array/object parameters are sometimes serialized as JSON strings
|
|
@@ -142,6 +151,61 @@ External image URLs are auto-persisted on publish — no extra steps needed.
|
|
|
142
151
|
function ensureArticlesDir() {
|
|
143
152
|
mkdirSync(ARTICLES_DIR, { recursive: true });
|
|
144
153
|
}
|
|
154
|
+
function resolveArticleDir(communitySlug) {
|
|
155
|
+
return communitySlug ? join(ARTICLES_DIR, communitySlug) : ARTICLES_DIR;
|
|
156
|
+
}
|
|
157
|
+
function resolveArticlePaths(slug, communitySlug) {
|
|
158
|
+
const dir = resolveArticleDir(communitySlug);
|
|
159
|
+
return {
|
|
160
|
+
dir,
|
|
161
|
+
filePath: join(dir, `${slug}.md`),
|
|
162
|
+
originalPath: join(dir, `.${slug}.original.md`),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function findDraftCandidates(slug) {
|
|
166
|
+
const matches = [];
|
|
167
|
+
const root = resolveArticlePaths(slug);
|
|
168
|
+
if (existsSync(root.filePath)) {
|
|
169
|
+
matches.push({ communitySlug: null, filePath: root.filePath, originalPath: root.originalPath });
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const dirs = readdirSync(ARTICLES_DIR).filter((d) => {
|
|
173
|
+
try {
|
|
174
|
+
return statSync(join(ARTICLES_DIR, d)).isDirectory();
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
for (const communitySlug of dirs) {
|
|
181
|
+
const scoped = resolveArticlePaths(slug, communitySlug);
|
|
182
|
+
if (existsSync(scoped.filePath)) {
|
|
183
|
+
matches.push({ communitySlug, filePath: scoped.filePath, originalPath: scoped.originalPath });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch { /* ignore readdir errors */ }
|
|
188
|
+
return matches;
|
|
189
|
+
}
|
|
190
|
+
function resolvePublishCandidate(slug, communitySlug) {
|
|
191
|
+
if (communitySlug) {
|
|
192
|
+
const paths = resolveArticlePaths(slug, communitySlug);
|
|
193
|
+
if (!existsSync(paths.filePath)) {
|
|
194
|
+
throw new Error(`File not found: ${paths.filePath}\nUse download to get an existing article or new to create a scaffold.`);
|
|
195
|
+
}
|
|
196
|
+
return { communitySlug, ...paths };
|
|
197
|
+
}
|
|
198
|
+
const matches = findDraftCandidates(slug);
|
|
199
|
+
if (matches.length === 0) {
|
|
200
|
+
const fallback = resolveArticlePaths(slug);
|
|
201
|
+
throw new Error(`File not found: ${fallback.filePath}\nUse download to get an existing article or new to create a scaffold.`);
|
|
202
|
+
}
|
|
203
|
+
if (matches.length > 1) {
|
|
204
|
+
const paths = matches.map((match) => ` - ${match.filePath}`).join("\n");
|
|
205
|
+
throw new Error(`Multiple local drafts found for slug "${slug}". Publish is ambiguous.\n${paths}\nRemove the duplicate draft or publish with community_slug set.`);
|
|
206
|
+
}
|
|
207
|
+
return matches[0];
|
|
208
|
+
}
|
|
145
209
|
export function registerArticleTools(server) {
|
|
146
210
|
server.addTool({
|
|
147
211
|
name: "search_articles",
|
|
@@ -167,198 +231,246 @@ export function registerArticleTools(server) {
|
|
|
167
231
|
"For editing articles locally, use 'download' instead. No authentication needed.",
|
|
168
232
|
parameters: z.object({
|
|
169
233
|
slugs: coerceJson(z.array(z.string()).min(1).max(20)).describe("Article slugs to read (1-20)"),
|
|
234
|
+
community_slug: z.string().optional().describe("Community slug for reading community-owned wiki articles. Omit for global almanac articles."),
|
|
170
235
|
}),
|
|
171
|
-
async execute({ slugs }) {
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
},
|
|
177
|
-
});
|
|
178
|
-
server.addTool({
|
|
179
|
-
name: "create_stubs",
|
|
180
|
-
description: "Create stub articles — placeholders for entities that don't have full articles yet. " +
|
|
181
|
-
"Use this for every entity (person, organization, topic, etc.) mentioned in an article. " +
|
|
182
|
-
"Idempotent: existing slugs return their current status. Requires login.",
|
|
183
|
-
parameters: z.object({
|
|
184
|
-
stubs: coerceJson(z.array(z.object({
|
|
185
|
-
slug: z
|
|
186
|
-
.string()
|
|
187
|
-
.min(1)
|
|
188
|
-
.max(500)
|
|
189
|
-
.describe("Unique kebab-case identifier. For people with LinkedIn: use their vanity ID (e.g. 'john-smith-4a8b2c1'). " +
|
|
190
|
-
"For others: descriptive kebab-case (e.g. 'reinforcement-learning', 'openai')"),
|
|
191
|
-
title: z.string().describe("Display title (e.g. 'John Smith', 'Reinforcement Learning')"),
|
|
192
|
-
entity_type: z
|
|
193
|
-
.enum(["person", "organization", "topic", "event", "creative_work", "place"])
|
|
194
|
-
.optional()
|
|
195
|
-
.describe("Entity type: 'person' for individuals, 'organization' for companies/institutions/nonprofits, " +
|
|
196
|
-
"'topic' for concepts/fields/technologies, 'event' for conferences/historical events, " +
|
|
197
|
-
"'creative_work' for books/papers/films/software, 'place' for cities/countries/landmarks"),
|
|
198
|
-
headline: z
|
|
199
|
-
.string()
|
|
200
|
-
.optional()
|
|
201
|
-
.describe("Short headline (e.g. 'Professor of CS at MIT', 'AI research laboratory')"),
|
|
202
|
-
image_url: z.string().url().optional().describe("Image URL for the entity"),
|
|
203
|
-
summary: z
|
|
204
|
-
.string()
|
|
205
|
-
.optional()
|
|
206
|
-
.describe("2-4 sentence summary of the entity. This becomes the stub page content. " +
|
|
207
|
-
"Be informative — include key facts, dates, and context."),
|
|
208
|
-
})).min(1).max(50)).describe("Stubs to create (1-50)"),
|
|
209
|
-
}),
|
|
210
|
-
async execute({ stubs }) {
|
|
211
|
-
const resp = await request("POST", "/api/articles/stubs", {
|
|
212
|
-
auth: true,
|
|
213
|
-
json: { stubs },
|
|
214
|
-
});
|
|
236
|
+
async execute({ slugs, community_slug }) {
|
|
237
|
+
const json = { slugs };
|
|
238
|
+
if (community_slug)
|
|
239
|
+
json.community_slug = community_slug;
|
|
240
|
+
const resp = await request("POST", "/api/articles/batch", { json });
|
|
215
241
|
return JSON.stringify(await resp.json(), null, 2);
|
|
216
242
|
},
|
|
217
243
|
});
|
|
218
244
|
server.addTool({
|
|
219
245
|
name: "download",
|
|
220
|
-
description: "Download
|
|
221
|
-
"
|
|
222
|
-
"After editing, use
|
|
246
|
+
description: "Download articles to your local workspace for editing. " +
|
|
247
|
+
"Global articles: ~/.openalmanac/articles/{slug}.md. Community wiki: ~/.openalmanac/articles/{community_slug}/{slug}.md. " +
|
|
248
|
+
"Returns a writing guide on first call. After editing, use publish to push changes.",
|
|
223
249
|
parameters: z.object({
|
|
224
|
-
|
|
250
|
+
slugs: coerceJson(z.array(z.string()).min(1).max(50)).describe("Article slugs to download (1-50)"),
|
|
251
|
+
community_slug: z.string().optional().describe("Community slug for community-owned wiki articles"),
|
|
225
252
|
}),
|
|
226
|
-
async execute({
|
|
227
|
-
|
|
228
|
-
|
|
253
|
+
async execute({ slugs, community_slug }) {
|
|
254
|
+
for (const slug of slugs) {
|
|
255
|
+
if (!SLUG_RE.test(slug)) {
|
|
256
|
+
throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning').`);
|
|
257
|
+
}
|
|
229
258
|
}
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const
|
|
259
|
+
const json = { slugs };
|
|
260
|
+
if (community_slug)
|
|
261
|
+
json.community_slug = community_slug;
|
|
262
|
+
const resp = await request("POST", "/api/articles/batch-download", { json });
|
|
263
|
+
const data = (await resp.json());
|
|
234
264
|
ensureArticlesDir();
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
265
|
+
const lines = [];
|
|
266
|
+
for (const slug of slugs) {
|
|
267
|
+
if (data.errors[slug]) {
|
|
268
|
+
lines.push(`FAILED ${slug}: ${data.errors[slug]}`);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const markdown = data.articles[slug];
|
|
272
|
+
if (!markdown) {
|
|
273
|
+
lines.push(`FAILED ${slug}: missing from response`);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const { dir, filePath, originalPath } = resolveArticlePaths(slug, community_slug);
|
|
277
|
+
if (community_slug) {
|
|
278
|
+
mkdirSync(dir, { recursive: true });
|
|
279
|
+
}
|
|
280
|
+
writeFileSync(filePath, markdown, "utf-8");
|
|
281
|
+
writeFileSync(originalPath, markdown, "utf-8");
|
|
282
|
+
const { frontmatter, content } = parseFrontmatter(markdown);
|
|
283
|
+
const title = frontmatter.title || "(untitled)";
|
|
284
|
+
const wordCount = content.trim().split(/\s+/).filter(Boolean).length;
|
|
285
|
+
const isStub = frontmatter.stub === true;
|
|
286
|
+
const stubNote = isStub
|
|
287
|
+
? "\n\nThis is a STUB article — a placeholder that hasn't been fully written yet. " +
|
|
288
|
+
"Fill in the content body with a complete article, then push to publish."
|
|
289
|
+
: "";
|
|
290
|
+
lines.push(`Downloaded "${title}" to ${filePath}\n${wordCount} words, ${frontmatter.sources?.length ?? 0} sources.${stubNote}`);
|
|
291
|
+
}
|
|
292
|
+
return [lines.join("\n"), "", WRITING_GUIDE].join("\n");
|
|
248
293
|
},
|
|
249
294
|
});
|
|
250
295
|
server.addTool({
|
|
251
296
|
name: "new",
|
|
252
|
-
description: "
|
|
253
|
-
"
|
|
254
|
-
"
|
|
297
|
+
description: "Scaffold new articles locally. Creates .md files with YAML frontmatter and empty bodies. " +
|
|
298
|
+
"Provide explicit slugs when you know the canonical ID; otherwise they are auto-derived from titles. For community wiki articles, provide community_slug. " +
|
|
299
|
+
"After writing content, use publish to go live.",
|
|
255
300
|
parameters: z.object({
|
|
256
|
-
|
|
257
|
-
.string()
|
|
258
|
-
.describe("
|
|
259
|
-
|
|
301
|
+
articles: coerceJson(z.array(z.object({
|
|
302
|
+
title: z.string().describe("Article title"),
|
|
303
|
+
slug: z.string().optional().describe("Optional explicit kebab-case slug. Encouraged when you know the canonical ID."),
|
|
304
|
+
topics: z.array(z.string()).optional().describe("Topic slugs for community wiki articles"),
|
|
305
|
+
})).min(1).max(50)).describe("Articles to scaffold (1-50)"),
|
|
306
|
+
community_slug: z.string().optional().describe("Community slug for community-owned wiki articles"),
|
|
260
307
|
}),
|
|
261
|
-
async execute({
|
|
262
|
-
if (!SLUG_RE.test(
|
|
263
|
-
throw new Error(`Invalid
|
|
308
|
+
async execute({ articles, community_slug }) {
|
|
309
|
+
if (community_slug && !SLUG_RE.test(community_slug)) {
|
|
310
|
+
throw new Error(`Invalid community_slug "${community_slug}". Must be kebab-case.`);
|
|
264
311
|
}
|
|
265
312
|
ensureArticlesDir();
|
|
266
|
-
|
|
267
|
-
if (
|
|
268
|
-
|
|
313
|
+
let dir = ARTICLES_DIR;
|
|
314
|
+
if (community_slug) {
|
|
315
|
+
dir = join(ARTICLES_DIR, community_slug);
|
|
316
|
+
mkdirSync(dir, { recursive: true });
|
|
269
317
|
}
|
|
270
|
-
const
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
318
|
+
const created = [];
|
|
319
|
+
const skipped = [];
|
|
320
|
+
for (const item of articles) {
|
|
321
|
+
const slug = item.slug || slugify(item.title);
|
|
322
|
+
if (!slug) {
|
|
323
|
+
skipped.push(`(empty slug from title: "${item.title}")`);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (!SLUG_RE.test(slug)) {
|
|
327
|
+
skipped.push(`"${item.title}" → invalid slug "${slug}"`);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const filePath = join(dir, `${slug}.md`);
|
|
331
|
+
if (existsSync(filePath)) {
|
|
332
|
+
skipped.push(`${slug}.md already exists — skipped`);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const meta = { article_id: slug, title: item.title };
|
|
336
|
+
if (community_slug)
|
|
337
|
+
meta.community_slug = community_slug;
|
|
338
|
+
if (item.topics && item.topics.length > 0)
|
|
339
|
+
meta.topics = item.topics;
|
|
340
|
+
meta.sources = [];
|
|
341
|
+
const frontmatter = yamlStringify(meta);
|
|
342
|
+
const scaffold = `---\n${frontmatter}---\n\n`;
|
|
343
|
+
writeFileSync(filePath, scaffold, "utf-8");
|
|
344
|
+
created.push(filePath);
|
|
345
|
+
}
|
|
346
|
+
const parts = [
|
|
347
|
+
created.length > 0 ? `Created ${created.length} file(s):\n${created.map((p) => ` - ${p}`).join("\n")}` : "No new files created.",
|
|
348
|
+
skipped.length > 0 ? `Skipped:\n${skipped.map((s) => ` - ${s}`).join("\n")}` : "",
|
|
349
|
+
WRITING_GUIDE,
|
|
350
|
+
];
|
|
351
|
+
return parts.filter(Boolean).join("\n\n");
|
|
274
352
|
},
|
|
275
353
|
});
|
|
276
354
|
server.addTool({
|
|
277
355
|
name: "publish",
|
|
278
|
-
description: "Validate and publish
|
|
279
|
-
"
|
|
356
|
+
description: "Validate and publish articles from your local workspace. " +
|
|
357
|
+
"Provide specific slugs, or a community_slug to publish all articles in that community folder. " +
|
|
358
|
+
"Empty-body files become stubs. Dead wikilinks auto-create stubs on the server. " +
|
|
359
|
+
"Put edit_summary in frontmatter for per-article change descriptions. Requires login.",
|
|
280
360
|
parameters: z.object({
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
361
|
+
slugs: coerceJson(z.array(z.string()).min(1).max(50)).optional()
|
|
362
|
+
.describe("Specific article slugs to publish"),
|
|
363
|
+
community_slug: z.string().optional()
|
|
364
|
+
.describe("Publish all .md files in this community folder under ~/.openalmanac/articles/"),
|
|
284
365
|
}),
|
|
285
|
-
async execute({
|
|
286
|
-
if (!
|
|
287
|
-
throw new Error(
|
|
288
|
-
}
|
|
289
|
-
const filePath = join(ARTICLES_DIR, `${slug}.md`);
|
|
290
|
-
let raw;
|
|
291
|
-
try {
|
|
292
|
-
raw = readFileSync(filePath, "utf-8");
|
|
293
|
-
}
|
|
294
|
-
catch {
|
|
295
|
-
throw new Error(`File not found: ${filePath}\nUse download to get an existing article or new to create a scaffold.`);
|
|
366
|
+
async execute({ slugs, community_slug }) {
|
|
367
|
+
if (!slugs?.length && !community_slug) {
|
|
368
|
+
throw new Error("Provide slugs or community_slug (explicit intent required).");
|
|
296
369
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
if (
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const resp = await request("PUT", `/api/articles/${slug}`, {
|
|
315
|
-
auth: true,
|
|
316
|
-
body,
|
|
317
|
-
contentType: "text/markdown",
|
|
318
|
-
});
|
|
319
|
-
const data = (await resp.json());
|
|
320
|
-
const articleUrl = `https://www.openalmanac.org/article/${slug}?celebrate=true`;
|
|
321
|
-
const inGui = process.env.OPENALMANAC_GUI === "1";
|
|
322
|
-
// Skip browser open when running inside the GUI — it handles navigation itself
|
|
323
|
-
if (!inGui) {
|
|
324
|
-
openBrowser(articleUrl);
|
|
325
|
-
}
|
|
326
|
-
// Clean up local files after successful publish
|
|
327
|
-
let cleanupWarning = "";
|
|
328
|
-
try {
|
|
329
|
-
unlinkSync(filePath);
|
|
370
|
+
const tasks = [];
|
|
371
|
+
if (community_slug && !slugs?.length) {
|
|
372
|
+
if (!SLUG_RE.test(community_slug)) {
|
|
373
|
+
throw new Error(`Invalid community_slug "${community_slug}". Must be kebab-case.`);
|
|
374
|
+
}
|
|
375
|
+
const dir = join(ARTICLES_DIR, community_slug);
|
|
376
|
+
if (!existsSync(dir)) {
|
|
377
|
+
throw new Error(`Community folder not found: ${dir}`);
|
|
378
|
+
}
|
|
379
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".md") && !f.startsWith("."));
|
|
380
|
+
if (files.length === 0) {
|
|
381
|
+
throw new Error(`No .md files in ${dir}`);
|
|
382
|
+
}
|
|
383
|
+
for (const f of files) {
|
|
384
|
+
const slug = f.replace(/\.md$/i, "");
|
|
385
|
+
tasks.push({ slug, communitySlug: community_slug, ...resolveArticlePaths(slug, community_slug) });
|
|
386
|
+
}
|
|
330
387
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
388
|
+
else if (slugs?.length) {
|
|
389
|
+
for (const slug of slugs) {
|
|
390
|
+
if (!SLUG_RE.test(slug)) {
|
|
391
|
+
throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning').`);
|
|
392
|
+
}
|
|
393
|
+
tasks.push({ slug, ...resolvePublishCandidate(slug, community_slug ?? undefined) });
|
|
334
394
|
}
|
|
335
395
|
}
|
|
336
|
-
|
|
337
|
-
|
|
396
|
+
const validationLines = [];
|
|
397
|
+
const validArticles = [];
|
|
398
|
+
for (const task of tasks) {
|
|
399
|
+
const raw = readFileSync(task.filePath, "utf-8");
|
|
400
|
+
const errors = validateArticle(raw);
|
|
401
|
+
if (errors.length > 0) {
|
|
402
|
+
const lines = errors.map((e) => ` ${e.field}: ${e.message}`);
|
|
403
|
+
validationLines.push(`FAILED ${task.slug}: Validation failed (${errors.length} error${errors.length > 1 ? "s" : ""}):\n${lines.join("\n")}`);
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
validArticles.push({ slug: task.slug, markdown: raw });
|
|
407
|
+
}
|
|
338
408
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
409
|
+
const inGui = process.env.OPENALMANAC_GUI === "1";
|
|
410
|
+
const resultLines = [...validationLines];
|
|
411
|
+
let okCount = 0;
|
|
412
|
+
if (validArticles.length > 0) {
|
|
413
|
+
const resp = await request("POST", "/api/articles/batch-publish", {
|
|
414
|
+
auth: true,
|
|
415
|
+
json: { articles: validArticles },
|
|
416
|
+
});
|
|
417
|
+
const data = (await resp.json());
|
|
418
|
+
for (const r of data.results) {
|
|
419
|
+
if (r.status === "failed") {
|
|
420
|
+
resultLines.push(`FAILED ${r.slug}: ${r.error ?? "unknown error"}`);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
okCount += 1;
|
|
424
|
+
const task = tasks.find((t) => t.slug === r.slug);
|
|
425
|
+
if (task) {
|
|
426
|
+
try {
|
|
427
|
+
unlinkSync(task.filePath);
|
|
428
|
+
}
|
|
429
|
+
catch (e) {
|
|
430
|
+
if (e.code !== "ENOENT") {
|
|
431
|
+
resultLines.push(`Note: could not remove local draft for ${r.slug}: ${e.message}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
try {
|
|
435
|
+
unlinkSync(task.originalPath);
|
|
436
|
+
}
|
|
437
|
+
catch (e) {
|
|
438
|
+
if (e.code !== "ENOENT") {
|
|
439
|
+
resultLines.push(`Note: could not remove original copy for ${r.slug}: ${e.message}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
resultLines.push(`OK ${r.slug}: ${r.status}\n${JSON.stringify(r, null, 2)}`);
|
|
444
|
+
if (!inGui && tasks.length === 1 && r.canonical_path) {
|
|
445
|
+
const articleUrl = `https://www.openalmanac.org${r.canonical_path}?celebrate=true`;
|
|
446
|
+
openBrowser(articleUrl);
|
|
447
|
+
}
|
|
342
448
|
}
|
|
343
449
|
}
|
|
344
|
-
const
|
|
345
|
-
? "
|
|
346
|
-
:
|
|
347
|
-
|
|
450
|
+
const urlHint = inGui
|
|
451
|
+
? "\n\nThe article(s) have been published! Let the user know they're live. Do not send them to a web URL."
|
|
452
|
+
: tasks.length > 1
|
|
453
|
+
? "\n\n(Opening browser skipped for batch publish — share URLs from results above.)"
|
|
454
|
+
: "";
|
|
455
|
+
return `Published ${okCount}/${tasks.length}.\n\n${resultLines.join("\n\n")}${urlHint}`;
|
|
348
456
|
},
|
|
349
457
|
});
|
|
350
458
|
server.addTool({
|
|
351
|
-
name: "
|
|
352
|
-
description: "
|
|
353
|
-
"Use this to find
|
|
459
|
+
name: "list_articles",
|
|
460
|
+
description: "Browse a community's wiki articles. Structured listing, not fuzzy search. " +
|
|
461
|
+
"Use this to see what exists, find stubs to fill, or discover most-referenced gaps.",
|
|
354
462
|
parameters: z.object({
|
|
355
|
-
|
|
356
|
-
|
|
463
|
+
community_slug: z.string().describe("Community slug"),
|
|
464
|
+
topic: z.string().optional().describe("Filter by topic slug"),
|
|
465
|
+
sort: z.enum(["recent", "most_referenced"]).default("recent").describe("Sort order"),
|
|
466
|
+
stubs_only: z.boolean().default(false).describe("Only return stubs"),
|
|
467
|
+
limit: z.number().min(1).max(200).default(50).describe("Max results (1-200)"),
|
|
357
468
|
}),
|
|
358
|
-
async execute({
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
469
|
+
async execute({ community_slug, topic, sort, stubs_only, limit }) {
|
|
470
|
+
const params = { sort, stubs_only, limit };
|
|
471
|
+
if (topic)
|
|
472
|
+
params.topic = topic;
|
|
473
|
+
const resp = await request("GET", `/api/communities/${community_slug}/wiki`, { params });
|
|
362
474
|
return JSON.stringify(await resp.json(), null, 2);
|
|
363
475
|
},
|
|
364
476
|
});
|
|
@@ -393,34 +505,4 @@ export function registerArticleTools(server) {
|
|
|
393
505
|
return `Article Proposal: ${title}\n\n${summary}\n\nProceed with writing this article following the writing flow in your instructions.`;
|
|
394
506
|
},
|
|
395
507
|
});
|
|
396
|
-
server.addTool({
|
|
397
|
-
name: "status",
|
|
398
|
-
description: "Show login status and list all article files in your local working directory (~/.openalmanac/articles/). " +
|
|
399
|
-
"Shows auth state, filename, title, file size, and last modified time.",
|
|
400
|
-
async execute() {
|
|
401
|
-
ensureArticlesDir();
|
|
402
|
-
const auth = await getAuthStatus();
|
|
403
|
-
const authLine = auth.loggedIn
|
|
404
|
-
? `Logged in as ${auth.name}.`
|
|
405
|
-
: "Not logged in. Use login to authenticate.";
|
|
406
|
-
const files = readdirSync(ARTICLES_DIR).filter((f) => f.endsWith(".md") && !f.startsWith("."));
|
|
407
|
-
if (files.length === 0) {
|
|
408
|
-
return `${authLine}\n\nLocal articles (0 files in ${ARTICLES_DIR}):\n (none — use download or new to get started)`;
|
|
409
|
-
}
|
|
410
|
-
const rows = [];
|
|
411
|
-
for (const file of files) {
|
|
412
|
-
const filePath = join(ARTICLES_DIR, file);
|
|
413
|
-
const stat = statSync(filePath);
|
|
414
|
-
const raw = readFileSync(filePath, "utf-8");
|
|
415
|
-
const { frontmatter } = parseFrontmatter(raw);
|
|
416
|
-
const title = frontmatter.title || "(untitled)";
|
|
417
|
-
const size = stat.size < 1024
|
|
418
|
-
? `${stat.size}B`
|
|
419
|
-
: `${(stat.size / 1024).toFixed(1)}KB`;
|
|
420
|
-
const modified = stat.mtime.toISOString().slice(0, 16).replace("T", " ");
|
|
421
|
-
rows.push(` ${file} — "${title}" (${size}, modified ${modified})`);
|
|
422
|
-
}
|
|
423
|
-
return `${authLine}\n\nLocal articles (${files.length} file(s) in ${ARTICLES_DIR}):\n${rows.join("\n")}`;
|
|
424
|
-
},
|
|
425
|
-
});
|
|
426
508
|
}
|
|
@@ -1,28 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { request } from "../auth.js";
|
|
3
|
-
/**
|
|
4
|
-
* Workaround for Claude Agent SDK MCP transport bug (#18260):
|
|
5
|
-
* Array/object parameters are sometimes serialized as JSON strings
|
|
6
|
-
* instead of native values. This preprocessor coerces them back.
|
|
7
|
-
*/
|
|
8
|
-
function coerceJson(schema) {
|
|
9
|
-
return z.preprocess((val) => {
|
|
10
|
-
if (typeof val === "string") {
|
|
11
|
-
try {
|
|
12
|
-
return JSON.parse(val);
|
|
13
|
-
}
|
|
14
|
-
catch {
|
|
15
|
-
return val;
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
return val;
|
|
19
|
-
}, schema);
|
|
20
|
-
}
|
|
21
3
|
export function registerCommunityTools(server) {
|
|
22
4
|
server.addTool({
|
|
23
5
|
name: "search_communities",
|
|
24
6
|
description: "Search or list OpenAlmanac communities. Returns community names, descriptions, and member counts. " +
|
|
25
|
-
"Use this
|
|
7
|
+
"Use this to discover communities by topic. No authentication needed.",
|
|
26
8
|
parameters: z.object({
|
|
27
9
|
query: z
|
|
28
10
|
.string()
|
|
@@ -72,11 +54,18 @@ export function registerCommunityTools(server) {
|
|
|
72
54
|
.min(1)
|
|
73
55
|
.max(2000)
|
|
74
56
|
.describe("What the community is about (1-2000 chars)"),
|
|
57
|
+
cover_image_url: z.string().url().optional().describe("Hero/banner image URL. Use search_images to find a compelling image first."),
|
|
58
|
+
cover_image_position: z.number().min(0).max(100).optional().describe("Vertical focal point of cover image (0=top, 50=center, 100=bottom)"),
|
|
75
59
|
}),
|
|
76
|
-
async execute({ name, slug, description }) {
|
|
60
|
+
async execute({ name, slug, description, cover_image_url, cover_image_position }) {
|
|
61
|
+
const json = { name, slug, description };
|
|
62
|
+
if (cover_image_url)
|
|
63
|
+
json.cover_image_url = cover_image_url;
|
|
64
|
+
if (cover_image_position !== undefined)
|
|
65
|
+
json.cover_image_position = cover_image_position;
|
|
77
66
|
const resp = await request("POST", "/api/communities", {
|
|
78
67
|
auth: true,
|
|
79
|
-
json
|
|
68
|
+
json,
|
|
80
69
|
});
|
|
81
70
|
const data = (await resp.json());
|
|
82
71
|
const communityUrl = `https://www.openalmanac.org/communities/${slug}`;
|
|
@@ -109,37 +98,4 @@ export function registerCommunityTools(server) {
|
|
|
109
98
|
return `Post created!\n\nURL: ${postUrl}\n\n${JSON.stringify(data, null, 2)}`;
|
|
110
99
|
},
|
|
111
100
|
});
|
|
112
|
-
server.addTool({
|
|
113
|
-
name: "link_article",
|
|
114
|
-
description: "Link an article to one or more communities. Use this after pushing an article to connect it " +
|
|
115
|
-
"with relevant communities. Call search_communities first to find matching communities. " +
|
|
116
|
-
"Idempotent — already-linked articles are reported but don't cause errors. Requires login.",
|
|
117
|
-
parameters: z.object({
|
|
118
|
-
article_id: z.string().describe("Article slug/ID to link (e.g. 'machine-learning')"),
|
|
119
|
-
community_slugs: coerceJson(z
|
|
120
|
-
.array(z.string())
|
|
121
|
-
.min(1)
|
|
122
|
-
.max(50))
|
|
123
|
-
.describe("List of community slugs to link the article to (max 50)"),
|
|
124
|
-
}),
|
|
125
|
-
async execute({ article_id, community_slugs }) {
|
|
126
|
-
const resp = await request("POST", `/api/articles/${article_id}/auto-link`, {
|
|
127
|
-
auth: true,
|
|
128
|
-
json: { community_slugs },
|
|
129
|
-
});
|
|
130
|
-
const data = (await resp.json());
|
|
131
|
-
const lines = [];
|
|
132
|
-
if (data.linked.length > 0) {
|
|
133
|
-
lines.push(`Linked to ${data.linked.length} communities: ${data.linked.join(", ")}`);
|
|
134
|
-
}
|
|
135
|
-
if (data.failed.length > 0) {
|
|
136
|
-
const failLines = data.failed.map((f) => ` ${f.slug}: ${f.reason}`);
|
|
137
|
-
lines.push(`Failed (${data.failed.length}):\n${failLines.join("\n")}`);
|
|
138
|
-
}
|
|
139
|
-
if (lines.length === 0) {
|
|
140
|
-
lines.push("No communities to link.");
|
|
141
|
-
}
|
|
142
|
-
return lines.join("\n\n");
|
|
143
|
-
},
|
|
144
|
-
});
|
|
145
101
|
}
|
package/dist/tools/research.js
CHANGED
|
@@ -133,13 +133,13 @@ export function registerResearchTools(server) {
|
|
|
133
133
|
server.addTool({
|
|
134
134
|
name: "register_sources",
|
|
135
135
|
description: "Register sources you plan to cite in your response. Call this BEFORE writing your response text. " +
|
|
136
|
-
"
|
|
137
|
-
"
|
|
136
|
+
"In GUI explore sessions this updates the source registry used for citation bubbles. " +
|
|
137
|
+
"Use [@key] markers in your response to cite them.",
|
|
138
138
|
parameters: z.object({
|
|
139
139
|
sources: coerceJson(z.array(z.object({
|
|
140
|
-
key: z.string().describe("Citation key — kebab-case, BibTeX-style: {domain}-{title-words}
|
|
140
|
+
key: z.string().describe("Citation key — kebab-case, BibTeX-style: {domain}-{title-words}"),
|
|
141
141
|
url: z.string().describe("Source URL"),
|
|
142
|
-
title: z.string().describe("Source title — include publication name after em dash
|
|
142
|
+
title: z.string().describe("Source title — include publication name after an em dash when relevant"),
|
|
143
143
|
})).min(1)).describe("Sources to register for citation"),
|
|
144
144
|
}),
|
|
145
145
|
async execute({ sources }) {
|
package/dist/validate.js
CHANGED
|
@@ -34,6 +34,30 @@ export function validateArticle(raw) {
|
|
|
34
34
|
message: "Must be kebab-case (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$",
|
|
35
35
|
});
|
|
36
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]+)*$",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const topics = frontmatter.topics;
|
|
45
|
+
if (topics != null) {
|
|
46
|
+
if (!Array.isArray(topics)) {
|
|
47
|
+
errors.push({ field: "topics", message: "Topics must be an array of kebab-case slugs" });
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
for (let i = 0; i < topics.length; i++) {
|
|
51
|
+
const topic = topics[i];
|
|
52
|
+
if (typeof topic !== "string" || !SLUG_RE.test(topic)) {
|
|
53
|
+
errors.push({
|
|
54
|
+
field: `topics[${i}]`,
|
|
55
|
+
message: "Must be kebab-case (e.g. 'spool-pins')",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
37
61
|
// sources
|
|
38
62
|
const sources = frontmatter.sources;
|
|
39
63
|
if (!Array.isArray(sources)) {
|