openalmanac 0.2.34 → 0.2.36
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/cli.js +7 -1
- package/dist/server.js +7 -7
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +115 -10
- package/dist/tools/articles.js +212 -245
- package/dist/tools/communities.js +10 -54
- package/dist/tools/research.js +4 -4
- package/package.json +3 -2
- package/skills/reddit-wiki/SKILL.md +335 -0
- package/skills/reddit-wiki/scripts/ingest.js +663 -0
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
|
|
@@ -178,7 +187,14 @@ function findDraftCandidates(slug) {
|
|
|
178
187
|
catch { /* ignore readdir errors */ }
|
|
179
188
|
return matches;
|
|
180
189
|
}
|
|
181
|
-
function resolvePublishCandidate(slug) {
|
|
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
|
+
}
|
|
182
198
|
const matches = findDraftCandidates(slug);
|
|
183
199
|
if (matches.length === 0) {
|
|
184
200
|
const fallback = resolveArticlePaths(slug);
|
|
@@ -186,7 +202,7 @@ function resolvePublishCandidate(slug) {
|
|
|
186
202
|
}
|
|
187
203
|
if (matches.length > 1) {
|
|
188
204
|
const paths = matches.map((match) => ` - ${match.filePath}`).join("\n");
|
|
189
|
-
throw new Error(`Multiple local drafts found for slug "${slug}". Publish is ambiguous.\n${paths}\nRemove the duplicate draft or publish
|
|
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.`);
|
|
190
206
|
}
|
|
191
207
|
return matches[0];
|
|
192
208
|
}
|
|
@@ -218,235 +234,243 @@ export function registerArticleTools(server) {
|
|
|
218
234
|
community_slug: z.string().optional().describe("Community slug for reading community-owned wiki articles. Omit for global almanac articles."),
|
|
219
235
|
}),
|
|
220
236
|
async execute({ slugs, community_slug }) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}));
|
|
226
|
-
return JSON.stringify({ results }, null, 2);
|
|
227
|
-
}
|
|
228
|
-
const resp = await request("POST", "/api/articles/batch", {
|
|
229
|
-
json: { slugs },
|
|
230
|
-
});
|
|
231
|
-
return JSON.stringify(await resp.json(), null, 2);
|
|
232
|
-
},
|
|
233
|
-
});
|
|
234
|
-
server.addTool({
|
|
235
|
-
name: "create_stubs",
|
|
236
|
-
description: "Create stub articles — placeholders for entities that don't have full articles yet. " +
|
|
237
|
-
"Use this for every entity (person, organization, topic, etc.) mentioned in an article. " +
|
|
238
|
-
"For community wiki stubs, include community_slug on each stub. " +
|
|
239
|
-
"Idempotent: existing slugs return their current status. Requires login.",
|
|
240
|
-
parameters: z.object({
|
|
241
|
-
stubs: coerceJson(z.array(z.object({
|
|
242
|
-
slug: z
|
|
243
|
-
.string()
|
|
244
|
-
.min(1)
|
|
245
|
-
.max(500)
|
|
246
|
-
.describe("Unique kebab-case identifier. For people with LinkedIn: use their vanity ID (e.g. 'john-smith-4a8b2c1'). " +
|
|
247
|
-
"For others: descriptive kebab-case (e.g. 'reinforcement-learning', 'openai')"),
|
|
248
|
-
title: z.string().describe("Display title (e.g. 'John Smith', 'Reinforcement Learning')"),
|
|
249
|
-
entity_type: z
|
|
250
|
-
.enum(["person", "organization", "topic", "event", "creative_work", "place"])
|
|
251
|
-
.optional()
|
|
252
|
-
.describe("Entity type: 'person' for individuals, 'organization' for companies/institutions/nonprofits, " +
|
|
253
|
-
"'topic' for concepts/fields/technologies, 'event' for conferences/historical events, " +
|
|
254
|
-
"'creative_work' for books/papers/films/software, 'place' for cities/countries/landmarks"),
|
|
255
|
-
headline: z
|
|
256
|
-
.string()
|
|
257
|
-
.optional()
|
|
258
|
-
.describe("Short headline (e.g. 'Professor of CS at MIT', 'AI research laboratory')"),
|
|
259
|
-
image_url: z.string().url().optional().describe("Image URL for the entity"),
|
|
260
|
-
summary: z
|
|
261
|
-
.string()
|
|
262
|
-
.optional()
|
|
263
|
-
.describe("2-4 sentence summary of the entity. This becomes the stub page content. " +
|
|
264
|
-
"Be informative — include key facts, dates, and context."),
|
|
265
|
-
community_slug: z.string().optional().describe("Community slug for community-owned stubs. Omit for global stubs."),
|
|
266
|
-
topics: z.array(z.string()).optional().describe("Topic slugs for community wiki stubs (e.g. ['techniques', 'tools'])."),
|
|
267
|
-
})).min(1).max(50)).describe("Stubs to create (1-50)"),
|
|
268
|
-
}),
|
|
269
|
-
async execute({ stubs }) {
|
|
270
|
-
const resp = await request("POST", "/api/articles/stubs", {
|
|
271
|
-
auth: true,
|
|
272
|
-
json: { stubs },
|
|
273
|
-
});
|
|
237
|
+
const json = { slugs };
|
|
238
|
+
if (community_slug)
|
|
239
|
+
json.community_slug = community_slug;
|
|
240
|
+
const resp = await request("POST", "/api/articles/batch", { json });
|
|
274
241
|
return JSON.stringify(await resp.json(), null, 2);
|
|
275
242
|
},
|
|
276
243
|
});
|
|
277
244
|
server.addTool({
|
|
278
245
|
name: "download",
|
|
279
|
-
description: "Download
|
|
280
|
-
"
|
|
281
|
-
"Returns a writing guide
|
|
282
|
-
"After editing, use 'publish' to push your changes live.",
|
|
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.",
|
|
283
249
|
parameters: z.object({
|
|
284
|
-
|
|
285
|
-
community_slug: z.string().optional().describe("Community slug for
|
|
286
|
-
"Omit for global almanac articles."),
|
|
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"),
|
|
287
252
|
}),
|
|
288
|
-
async execute({
|
|
289
|
-
|
|
290
|
-
|
|
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
|
+
}
|
|
291
258
|
}
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const resp = await request("
|
|
296
|
-
|
|
297
|
-
});
|
|
298
|
-
const markdown = await resp.text();
|
|
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());
|
|
299
264
|
ensureArticlesDir();
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
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}`);
|
|
304
291
|
}
|
|
305
|
-
|
|
306
|
-
writeFileSync(originalPath, markdown, "utf-8");
|
|
307
|
-
const { frontmatter, content } = parseFrontmatter(markdown);
|
|
308
|
-
const title = frontmatter.title || "(untitled)";
|
|
309
|
-
const wordCount = content.trim().split(/\s+/).filter(Boolean).length;
|
|
310
|
-
const isStub = frontmatter.stub === true;
|
|
311
|
-
const stubNote = isStub
|
|
312
|
-
? "\n\nThis is a STUB article — a placeholder that hasn't been fully written yet. " +
|
|
313
|
-
"Fill in the content body with a complete article, then push to publish."
|
|
314
|
-
: "";
|
|
315
|
-
return `Downloaded "${title}" to ${filePath}\n${wordCount} words, ${frontmatter.sources?.length ?? 0} sources.${stubNote}\n\n${WRITING_GUIDE}`;
|
|
292
|
+
return [lines.join("\n"), "", WRITING_GUIDE].join("\n");
|
|
316
293
|
},
|
|
317
294
|
});
|
|
318
295
|
server.addTool({
|
|
319
296
|
name: "new",
|
|
320
|
-
description: "
|
|
321
|
-
"
|
|
322
|
-
"
|
|
323
|
-
"The file is created with YAML frontmatter and an empty body. Returns a writing guide covering article structure, infobox format, citations, and quality rules. " +
|
|
324
|
-
"Edit the file to add content and sources, then use publish to go live.",
|
|
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.",
|
|
325
300
|
parameters: z.object({
|
|
326
|
-
|
|
327
|
-
.string()
|
|
328
|
-
.describe("
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
topics: coerceJson(z.array(z.string()).default([])).describe("Topic slugs for community wiki articles (e.g. ['techniques', 'tools']). " +
|
|
333
|
-
"Topics are community-specific categories."),
|
|
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"),
|
|
334
307
|
}),
|
|
335
|
-
async execute({
|
|
336
|
-
if (!SLUG_RE.test(slug)) {
|
|
337
|
-
throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$`);
|
|
338
|
-
}
|
|
308
|
+
async execute({ articles, community_slug }) {
|
|
339
309
|
if (community_slug && !SLUG_RE.test(community_slug)) {
|
|
340
310
|
throw new Error(`Invalid community_slug "${community_slug}". Must be kebab-case.`);
|
|
341
311
|
}
|
|
342
312
|
ensureArticlesDir();
|
|
343
|
-
// Community articles are saved in a subdirectory
|
|
344
313
|
let dir = ARTICLES_DIR;
|
|
345
314
|
if (community_slug) {
|
|
346
315
|
dir = join(ARTICLES_DIR, community_slug);
|
|
347
316
|
mkdirSync(dir, { recursive: true });
|
|
348
317
|
}
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
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);
|
|
352
345
|
}
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const frontmatter = yamlStringify(meta);
|
|
360
|
-
const scaffold = `---\n${frontmatter}---\n\n`;
|
|
361
|
-
writeFileSync(filePath, scaffold, "utf-8");
|
|
362
|
-
return `Created ${filePath}\n\n${WRITING_GUIDE}`;
|
|
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");
|
|
363
352
|
},
|
|
364
353
|
});
|
|
365
354
|
server.addTool({
|
|
366
355
|
name: "publish",
|
|
367
|
-
description: "Validate and publish
|
|
368
|
-
"
|
|
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.",
|
|
369
360
|
parameters: z.object({
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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/"),
|
|
373
365
|
}),
|
|
374
|
-
async execute({
|
|
375
|
-
if (!
|
|
376
|
-
throw new Error(
|
|
377
|
-
}
|
|
378
|
-
const { communitySlug, filePath, originalPath } = resolvePublishCandidate(slug);
|
|
379
|
-
const raw = readFileSync(filePath, "utf-8");
|
|
380
|
-
// Local validation
|
|
381
|
-
const errors = validateArticle(raw);
|
|
382
|
-
if (errors.length > 0) {
|
|
383
|
-
const lines = errors.map((e) => ` ${e.field}: ${e.message}`);
|
|
384
|
-
throw new Error(`Validation failed (${errors.length} error${errors.length > 1 ? "s" : ""}):\n${lines.join("\n")}\n\nFix the file and try again.`);
|
|
385
|
-
}
|
|
386
|
-
// Inject change_title/change_description into frontmatter if provided
|
|
387
|
-
let body = raw;
|
|
388
|
-
if (change_title || change_description) {
|
|
389
|
-
const { frontmatter, content } = parseFrontmatter(raw);
|
|
390
|
-
if (change_title)
|
|
391
|
-
frontmatter.change_title = change_title;
|
|
392
|
-
if (change_description)
|
|
393
|
-
frontmatter.change_description = change_description;
|
|
394
|
-
const newFrontmatter = yamlStringify(frontmatter);
|
|
395
|
-
body = `---\n${newFrontmatter}---\n${content}`;
|
|
396
|
-
}
|
|
397
|
-
const resp = await request("PUT", `/api/articles/${slug}`, {
|
|
398
|
-
auth: true,
|
|
399
|
-
body,
|
|
400
|
-
contentType: "text/markdown",
|
|
401
|
-
});
|
|
402
|
-
const data = (await resp.json());
|
|
403
|
-
const canonicalPath = typeof data.canonical_path === "string"
|
|
404
|
-
? data.canonical_path
|
|
405
|
-
: communitySlug
|
|
406
|
-
? `/communities/${communitySlug}/wiki/${slug}`
|
|
407
|
-
: `/article/${slug}`;
|
|
408
|
-
const articleUrl = `https://www.openalmanac.org${canonicalPath}?celebrate=true`;
|
|
409
|
-
const inGui = process.env.OPENALMANAC_GUI === "1";
|
|
410
|
-
// Skip browser open when running inside the GUI — it handles navigation itself
|
|
411
|
-
if (!inGui) {
|
|
412
|
-
openBrowser(articleUrl);
|
|
366
|
+
async execute({ slugs, community_slug }) {
|
|
367
|
+
if (!slugs?.length && !community_slug) {
|
|
368
|
+
throw new Error("Provide slugs or community_slug (explicit intent required).");
|
|
413
369
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
+
}
|
|
418
387
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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) });
|
|
422
394
|
}
|
|
423
395
|
}
|
|
424
|
-
|
|
425
|
-
|
|
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
|
+
}
|
|
426
408
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
+
}
|
|
430
448
|
}
|
|
431
449
|
}
|
|
432
|
-
const
|
|
433
|
-
? "
|
|
434
|
-
:
|
|
435
|
-
|
|
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}`;
|
|
436
456
|
},
|
|
437
457
|
});
|
|
438
458
|
server.addTool({
|
|
439
|
-
name: "
|
|
440
|
-
description: "
|
|
441
|
-
"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.",
|
|
442
462
|
parameters: z.object({
|
|
443
|
-
|
|
444
|
-
|
|
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)"),
|
|
445
468
|
}),
|
|
446
|
-
async execute({
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
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 });
|
|
450
474
|
return JSON.stringify(await resp.json(), null, 2);
|
|
451
475
|
},
|
|
452
476
|
});
|
|
@@ -481,61 +505,4 @@ export function registerArticleTools(server) {
|
|
|
481
505
|
return `Article Proposal: ${title}\n\n${summary}\n\nProceed with writing this article following the writing flow in your instructions.`;
|
|
482
506
|
},
|
|
483
507
|
});
|
|
484
|
-
server.addTool({
|
|
485
|
-
name: "status",
|
|
486
|
-
description: "Show login status and list all article files in your local working directory (~/.openalmanac/articles/). " +
|
|
487
|
-
"Shows auth state, filename, title, file size, and last modified time.",
|
|
488
|
-
async execute() {
|
|
489
|
-
ensureArticlesDir();
|
|
490
|
-
const auth = await getAuthStatus();
|
|
491
|
-
const authLine = auth.loggedIn
|
|
492
|
-
? `Logged in as ${auth.name}.`
|
|
493
|
-
: "Not logged in. Use login to authenticate.";
|
|
494
|
-
const files = readdirSync(ARTICLES_DIR).filter((f) => f.endsWith(".md") && !f.startsWith("."));
|
|
495
|
-
const rows = [];
|
|
496
|
-
for (const file of files) {
|
|
497
|
-
const filePath = join(ARTICLES_DIR, file);
|
|
498
|
-
const stat = statSync(filePath);
|
|
499
|
-
const raw = readFileSync(filePath, "utf-8");
|
|
500
|
-
const { frontmatter } = parseFrontmatter(raw);
|
|
501
|
-
const title = frontmatter.title || "(untitled)";
|
|
502
|
-
const size = stat.size < 1024
|
|
503
|
-
? `${stat.size}B`
|
|
504
|
-
: `${(stat.size / 1024).toFixed(1)}KB`;
|
|
505
|
-
const modified = stat.mtime.toISOString().slice(0, 16).replace("T", " ");
|
|
506
|
-
rows.push(` ${file} — "${title}" (${size}, modified ${modified})`);
|
|
507
|
-
}
|
|
508
|
-
try {
|
|
509
|
-
const dirs = readdirSync(ARTICLES_DIR).filter((entry) => {
|
|
510
|
-
try {
|
|
511
|
-
return statSync(join(ARTICLES_DIR, entry)).isDirectory();
|
|
512
|
-
}
|
|
513
|
-
catch {
|
|
514
|
-
return false;
|
|
515
|
-
}
|
|
516
|
-
});
|
|
517
|
-
for (const communitySlug of dirs) {
|
|
518
|
-
const dir = join(ARTICLES_DIR, communitySlug);
|
|
519
|
-
const communityFiles = readdirSync(dir).filter((f) => f.endsWith(".md") && !f.startsWith("."));
|
|
520
|
-
for (const file of communityFiles) {
|
|
521
|
-
const filePath = join(dir, file);
|
|
522
|
-
const stat = statSync(filePath);
|
|
523
|
-
const raw = readFileSync(filePath, "utf-8");
|
|
524
|
-
const { frontmatter } = parseFrontmatter(raw);
|
|
525
|
-
const title = frontmatter.title || "(untitled)";
|
|
526
|
-
const size = stat.size < 1024
|
|
527
|
-
? `${stat.size}B`
|
|
528
|
-
: `${(stat.size / 1024).toFixed(1)}KB`;
|
|
529
|
-
const modified = stat.mtime.toISOString().slice(0, 16).replace("T", " ");
|
|
530
|
-
rows.push(` ${communitySlug}/${file} — "${title}" (${size}, modified ${modified})`);
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
catch { /* ignore subdir scan errors */ }
|
|
535
|
-
if (rows.length === 0) {
|
|
536
|
-
return `${authLine}\n\nLocal articles (0 files in ${ARTICLES_DIR}):\n (none — use download or new to get started)`;
|
|
537
|
-
}
|
|
538
|
-
return `${authLine}\n\nLocal articles (${rows.length} file(s) in ${ARTICLES_DIR}):\n${rows.join("\n")}`;
|
|
539
|
-
},
|
|
540
|
-
});
|
|
541
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
|
}
|