openalmanac 0.3.4 → 0.3.5

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.
@@ -2,37 +2,10 @@ 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 } from "../auth.js";
5
+ import { request, ARTICLES_DIR, getAuthStatus } 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
- }
18
- /**
19
- * Workaround for Claude Agent SDK MCP transport bug (#18260):
20
- * Array/object parameters are sometimes serialized as JSON strings
21
- * instead of native values. This preprocessor coerces them back.
22
- */
23
- function coerceJson(schema) {
24
- return z.preprocess((val) => {
25
- if (typeof val === "string") {
26
- try {
27
- return JSON.parse(val);
28
- }
29
- catch {
30
- return val;
31
- }
32
- }
33
- return val;
34
- }, schema);
35
- }
36
9
  const WRITING_GUIDE = `
37
10
  ## Article structure
38
11
 
@@ -151,79 +124,21 @@ External image URLs are auto-persisted on publish — no extra steps needed.
151
124
  function ensureArticlesDir() {
152
125
  mkdirSync(ARTICLES_DIR, { recursive: true });
153
126
  }
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
- }
209
127
  export function registerArticleTools(server) {
210
128
  server.addTool({
211
129
  name: "search_articles",
212
130
  description: "Search existing OpenAlmanac articles and stubs. Accepts multiple queries for batch lookup. " +
213
131
  "Use this to check if articles or stubs exist before creating them, or to find entity slugs for wikilinks. " +
214
- "Results include 'stub' field (true/false) and 'entity_type' field. " +
215
- "Pass community_slug to restrict results to articles owned by that community wiki (does not include global articles). No authentication needed.",
132
+ "Results include 'stub' field (true/false) and 'entity_type' field. No authentication needed.",
216
133
  parameters: z.object({
217
- queries: coerceJson(z.array(z.string()).min(1).max(20)).describe("Search queries (1-20)"),
134
+ queries: z.array(z.string()).min(1).max(20).describe("Search queries (1-20)"),
218
135
  limit: z.number().default(5).describe("Max results per query (1-50, default 5)"),
219
136
  include_stubs: z.boolean().default(true).describe("Include stub articles in results (default true)"),
220
- community_slug: z.string().optional().describe("Optional community slug. When set, results are restricted to articles owned by that community wiki only — global articles are excluded. Omit to search all of OpenAlmanac."),
221
137
  }),
222
- async execute({ queries, limit, include_stubs, community_slug }) {
223
- const json = { queries, limit, include_stubs };
224
- if (community_slug)
225
- json.community_slug = community_slug;
226
- const resp = await request("POST", "/api/search/batch", { json });
138
+ async execute({ queries, limit, include_stubs }) {
139
+ const resp = await request("POST", "/api/search/batch", {
140
+ json: { queries, limit, include_stubs },
141
+ });
227
142
  return JSON.stringify(await resp.json(), null, 2);
228
143
  },
229
144
  });
@@ -233,247 +148,192 @@ export function registerArticleTools(server) {
233
148
  "Use this to reference or summarize existing articles in conversation. " +
234
149
  "For editing articles locally, use 'download' instead. No authentication needed.",
235
150
  parameters: z.object({
236
- slugs: coerceJson(z.array(z.string()).min(1).max(20)).describe("Article slugs to read (1-20)"),
237
- community_slug: z.string().optional().describe("Community slug for reading community-owned wiki articles. Omit for global almanac articles."),
151
+ slugs: z.array(z.string()).min(1).max(20).describe("Article slugs to read (1-20)"),
238
152
  }),
239
- async execute({ slugs, community_slug }) {
240
- const json = { slugs };
241
- if (community_slug)
242
- json.community_slug = community_slug;
243
- const resp = await request("POST", "/api/articles/batch", { json });
153
+ async execute({ slugs }) {
154
+ const resp = await request("POST", "/api/articles/batch", {
155
+ json: { slugs },
156
+ });
157
+ return JSON.stringify(await resp.json(), null, 2);
158
+ },
159
+ });
160
+ server.addTool({
161
+ name: "create_stubs",
162
+ description: "Create stub articles — placeholders for entities that don't have full articles yet. " +
163
+ "Use this for every entity (person, organization, topic, etc.) mentioned in an article. " +
164
+ "Idempotent: existing slugs return their current status. Requires login.",
165
+ parameters: z.object({
166
+ stubs: z.array(z.object({
167
+ slug: z
168
+ .string()
169
+ .min(1)
170
+ .max(500)
171
+ .describe("Unique kebab-case identifier. For people with LinkedIn: use their vanity ID (e.g. 'john-smith-4a8b2c1'). " +
172
+ "For others: descriptive kebab-case (e.g. 'reinforcement-learning', 'openai')"),
173
+ title: z.string().describe("Display title (e.g. 'John Smith', 'Reinforcement Learning')"),
174
+ entity_type: z
175
+ .enum(["person", "organization", "topic", "event", "creative_work", "place"])
176
+ .optional()
177
+ .describe("Entity type: 'person' for individuals, 'organization' for companies/institutions/nonprofits, " +
178
+ "'topic' for concepts/fields/technologies, 'event' for conferences/historical events, " +
179
+ "'creative_work' for books/papers/films/software, 'place' for cities/countries/landmarks"),
180
+ headline: z
181
+ .string()
182
+ .optional()
183
+ .describe("Short headline (e.g. 'Professor of CS at MIT', 'AI research laboratory')"),
184
+ image_url: z.string().url().optional().describe("Image URL for the entity"),
185
+ summary: z
186
+ .string()
187
+ .optional()
188
+ .describe("2-4 sentence summary of the entity. This becomes the stub page content. " +
189
+ "Be informative — include key facts, dates, and context."),
190
+ })).min(1).max(50).describe("Stubs to create (1-50)"),
191
+ }),
192
+ async execute({ stubs }) {
193
+ const resp = await request("POST", "/api/articles/stubs", {
194
+ auth: true,
195
+ json: { stubs },
196
+ });
244
197
  return JSON.stringify(await resp.json(), null, 2);
245
198
  },
246
199
  });
247
200
  server.addTool({
248
201
  name: "download",
249
- description: "Download articles to your local workspace for editing. " +
250
- "Global articles: ~/.openalmanac/articles/{slug}.md. Community wiki: ~/.openalmanac/articles/{community_slug}/{slug}.md. " +
251
- "Returns a writing guide on first call. After editing, use publish to push changes.",
202
+ description: "Download an article to your local workspace for editing. The file is saved to ~/.openalmanac/articles/{slug}.md " +
203
+ "with YAML frontmatter. Returns a writing guide covering article structure, infobox format, citations, and quality rules. " +
204
+ "After editing, use 'publish' to push your changes live.",
252
205
  parameters: z.object({
253
- slugs: coerceJson(z.array(z.string()).min(1).max(50)).describe("Article slugs to download (1-50)"),
254
- community_slug: z.string().optional().describe("Community slug for community-owned wiki articles"),
206
+ slug: z.string().describe("Article slug (e.g. 'machine-learning')"),
255
207
  }),
256
- async execute({ slugs, community_slug }) {
257
- for (const slug of slugs) {
258
- if (!SLUG_RE.test(slug)) {
259
- throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning').`);
260
- }
208
+ async execute({ slug }) {
209
+ if (!SLUG_RE.test(slug)) {
210
+ throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning').`);
261
211
  }
262
- const json = { slugs };
263
- if (community_slug)
264
- json.community_slug = community_slug;
265
- const resp = await request("POST", "/api/articles/batch-download", { json });
266
- const data = (await resp.json());
212
+ const resp = await request("GET", `/api/articles/${slug}`, {
213
+ params: { format: "md" },
214
+ });
215
+ const markdown = await resp.text();
267
216
  ensureArticlesDir();
268
- const lines = [];
269
- for (const slug of slugs) {
270
- if (data.errors[slug]) {
271
- lines.push(`FAILED ${slug}: ${data.errors[slug]}`);
272
- continue;
273
- }
274
- const markdown = data.articles[slug];
275
- if (!markdown) {
276
- lines.push(`FAILED ${slug}: missing from response`);
277
- continue;
278
- }
279
- const { dir, filePath, originalPath } = resolveArticlePaths(slug, community_slug);
280
- if (community_slug) {
281
- mkdirSync(dir, { recursive: true });
282
- }
283
- writeFileSync(filePath, markdown, "utf-8");
284
- writeFileSync(originalPath, markdown, "utf-8");
285
- const { frontmatter, content } = parseFrontmatter(markdown);
286
- const title = frontmatter.title || "(untitled)";
287
- const wordCount = content.trim().split(/\s+/).filter(Boolean).length;
288
- const isStub = frontmatter.stub === true;
289
- const stubNote = isStub
290
- ? "\n\nThis is a STUB article — a placeholder that hasn't been fully written yet. " +
291
- "Fill in the content body with a complete article, then push to publish."
292
- : "";
293
- lines.push(`Downloaded "${title}" to ${filePath}\n${wordCount} words, ${frontmatter.sources?.length ?? 0} sources.${stubNote}`);
294
- }
295
- return [lines.join("\n"), "", WRITING_GUIDE].join("\n");
217
+ const filePath = join(ARTICLES_DIR, `${slug}.md`);
218
+ writeFileSync(filePath, markdown, "utf-8");
219
+ const originalPath = join(ARTICLES_DIR, `.${slug}.original.md`);
220
+ writeFileSync(originalPath, markdown, "utf-8");
221
+ const { frontmatter, content } = parseFrontmatter(markdown);
222
+ const title = frontmatter.title || "(untitled)";
223
+ const wordCount = content.trim().split(/\s+/).filter(Boolean).length;
224
+ const isStub = frontmatter.stub === true;
225
+ const stubNote = isStub
226
+ ? "\n\nThis is a STUB article — a placeholder that hasn't been fully written yet. " +
227
+ "Fill in the content body with a complete article, then push to publish."
228
+ : "";
229
+ return `Downloaded "${title}" to ${filePath}\n${wordCount} words, ${frontmatter.sources?.length ?? 0} sources.${stubNote}\n\n${WRITING_GUIDE}`;
296
230
  },
297
231
  });
298
232
  server.addTool({
299
233
  name: "new",
300
- description: "Scaffold new articles locally. Creates .md files with YAML frontmatter and empty bodies. " +
301
- "Provide explicit slugs when you know the canonical ID; otherwise they are auto-derived from titles. For community wiki articles, provide community_slug. " +
302
- "After writing content, use publish to go live.",
234
+ description: "Create a new article scaffold in your local working directory (~/.openalmanac/articles/). " +
235
+ "The file is created with YAML frontmatter and an empty body. Returns a writing guide covering article structure, infobox format, citations, and quality rules. " +
236
+ "Edit the file to add content and sources, then use publish to go live.",
303
237
  parameters: z.object({
304
- articles: coerceJson(z.array(z.object({
305
- title: z.string().describe("Article title"),
306
- slug: z.string().optional().describe("Optional explicit kebab-case slug. Encouraged when you know the canonical ID."),
307
- topics: z.array(z.string()).optional().describe("Topic slugs for community wiki articles"),
308
- })).min(1).max(50)).describe("Articles to scaffold (1-50)"),
309
- community_slug: z.string().optional().describe("Community slug for community-owned wiki articles"),
238
+ slug: z
239
+ .string()
240
+ .describe("Unique kebab-case identifier (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$"),
241
+ title: z.string().describe("Article title"),
310
242
  }),
311
- async execute({ articles, community_slug }) {
312
- if (community_slug && !SLUG_RE.test(community_slug)) {
313
- throw new Error(`Invalid community_slug "${community_slug}". Must be kebab-case.`);
243
+ async execute({ slug, title }) {
244
+ if (!SLUG_RE.test(slug)) {
245
+ throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$`);
314
246
  }
315
247
  ensureArticlesDir();
316
- let dir = ARTICLES_DIR;
317
- if (community_slug) {
318
- dir = join(ARTICLES_DIR, community_slug);
319
- mkdirSync(dir, { recursive: true });
320
- }
321
- const created = [];
322
- const skipped = [];
323
- for (const item of articles) {
324
- const slug = item.slug || slugify(item.title);
325
- if (!slug) {
326
- skipped.push(`(empty slug from title: "${item.title}")`);
327
- continue;
328
- }
329
- if (!SLUG_RE.test(slug)) {
330
- skipped.push(`"${item.title}" → invalid slug "${slug}"`);
331
- continue;
332
- }
333
- const filePath = join(dir, `${slug}.md`);
334
- if (existsSync(filePath)) {
335
- skipped.push(`${slug}.md already exists — skipped`);
336
- continue;
337
- }
338
- const meta = { article_id: slug, title: item.title };
339
- if (community_slug)
340
- meta.community_slug = community_slug;
341
- if (item.topics && item.topics.length > 0)
342
- meta.topics = item.topics;
343
- meta.sources = [];
344
- const frontmatter = yamlStringify(meta);
345
- const scaffold = `---\n${frontmatter}---\n\n`;
346
- writeFileSync(filePath, scaffold, "utf-8");
347
- created.push(filePath);
248
+ const filePath = join(ARTICLES_DIR, `${slug}.md`);
249
+ if (existsSync(filePath)) {
250
+ throw new Error(`File already exists: ${filePath}\nUse download to refresh it, or publish to push changes.`);
348
251
  }
349
- const parts = [
350
- created.length > 0 ? `Created ${created.length} file(s):\n${created.map((p) => ` - ${p}`).join("\n")}` : "No new files created.",
351
- skipped.length > 0 ? `Skipped:\n${skipped.map((s) => ` - ${s}`).join("\n")}` : "",
352
- WRITING_GUIDE,
353
- ];
354
- return parts.filter(Boolean).join("\n\n");
252
+ const frontmatter = yamlStringify({ article_id: slug, title, sources: [] });
253
+ const scaffold = `---\n${frontmatter}---\n\n`;
254
+ writeFileSync(filePath, scaffold, "utf-8");
255
+ return `Created ${filePath}\n\n${WRITING_GUIDE}`;
355
256
  },
356
257
  });
357
258
  server.addTool({
358
259
  name: "publish",
359
- description: "Validate and publish articles from your local workspace. " +
360
- "Provide specific slugs, or a community_slug to publish all articles in that community folder. " +
361
- "Empty-body files become stubs. Dead wikilinks auto-create stubs on the server. " +
362
- "Put edit_summary in frontmatter for per-article change descriptions. Requires login.",
260
+ description: "Validate and publish an article from your local workspace. Reads ~/.openalmanac/articles/{slug}.md, " +
261
+ "validates content and sources, and publishes to OpenAlmanac. Requires login.",
363
262
  parameters: z.object({
364
- slugs: coerceJson(z.array(z.string()).min(1).max(50)).optional()
365
- .describe("Specific article slugs to publish"),
366
- community_slug: z.string().optional()
367
- .describe("Publish all .md files in this community folder under ~/.openalmanac/articles/"),
263
+ slug: z.string().describe("Article slug matching the filename (without .md)"),
264
+ change_title: z.string().optional().describe("Short title for the change (e.g. 'Added early life section')"),
265
+ change_description: z.string().optional().describe("Longer description of what changed and why"),
368
266
  }),
369
- async execute({ slugs, community_slug }) {
370
- if (!slugs?.length && !community_slug) {
371
- throw new Error("Provide slugs or community_slug (explicit intent required).");
267
+ async execute({ slug, change_title, change_description }) {
268
+ if (!SLUG_RE.test(slug)) {
269
+ throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning').`);
372
270
  }
373
- const tasks = [];
374
- if (community_slug && !slugs?.length) {
375
- if (!SLUG_RE.test(community_slug)) {
376
- throw new Error(`Invalid community_slug "${community_slug}". Must be kebab-case.`);
377
- }
378
- const dir = join(ARTICLES_DIR, community_slug);
379
- if (!existsSync(dir)) {
380
- throw new Error(`Community folder not found: ${dir}`);
381
- }
382
- const files = readdirSync(dir).filter((f) => f.endsWith(".md") && !f.startsWith("."));
383
- if (files.length === 0) {
384
- throw new Error(`No .md files in ${dir}`);
385
- }
386
- for (const f of files) {
387
- const slug = f.replace(/\.md$/i, "");
388
- tasks.push({ slug, communitySlug: community_slug, ...resolveArticlePaths(slug, community_slug) });
389
- }
271
+ const filePath = join(ARTICLES_DIR, `${slug}.md`);
272
+ let raw;
273
+ try {
274
+ raw = readFileSync(filePath, "utf-8");
390
275
  }
391
- else if (slugs?.length) {
392
- for (const slug of slugs) {
393
- if (!SLUG_RE.test(slug)) {
394
- throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning').`);
395
- }
396
- tasks.push({ slug, ...resolvePublishCandidate(slug, community_slug ?? undefined) });
397
- }
276
+ catch {
277
+ throw new Error(`File not found: ${filePath}\nUse download to get an existing article or new to create a scaffold.`);
398
278
  }
399
- const validationLines = [];
400
- const validArticles = [];
401
- for (const task of tasks) {
402
- const raw = readFileSync(task.filePath, "utf-8");
403
- const errors = validateArticle(raw);
404
- if (errors.length > 0) {
405
- const lines = errors.map((e) => ` ${e.field}: ${e.message}`);
406
- validationLines.push(`FAILED ${task.slug}: Validation failed (${errors.length} error${errors.length > 1 ? "s" : ""}):\n${lines.join("\n")}`);
407
- }
408
- else {
409
- validArticles.push({ slug: task.slug, markdown: raw });
279
+ // Local validation
280
+ const errors = validateArticle(raw);
281
+ if (errors.length > 0) {
282
+ const lines = errors.map((e) => ` ${e.field}: ${e.message}`);
283
+ throw new Error(`Validation failed (${errors.length} error${errors.length > 1 ? "s" : ""}):\n${lines.join("\n")}\n\nFix the file and try again.`);
284
+ }
285
+ // Inject change_title/change_description into frontmatter if provided
286
+ let body = raw;
287
+ if (change_title || change_description) {
288
+ const { frontmatter, content } = parseFrontmatter(raw);
289
+ if (change_title)
290
+ frontmatter.change_title = change_title;
291
+ if (change_description)
292
+ frontmatter.change_description = change_description;
293
+ const newFrontmatter = yamlStringify(frontmatter);
294
+ body = `---\n${newFrontmatter}---\n${content}`;
295
+ }
296
+ const resp = await request("PUT", `/api/articles/${slug}`, {
297
+ auth: true,
298
+ body,
299
+ contentType: "text/markdown",
300
+ });
301
+ const data = (await resp.json());
302
+ const articleUrl = `https://www.openalmanac.org/article/${slug}?celebrate=true`;
303
+ openBrowser(articleUrl);
304
+ // Clean up local files after successful publish
305
+ let cleanupWarning = "";
306
+ try {
307
+ unlinkSync(filePath);
308
+ }
309
+ catch (e) {
310
+ if (e.code !== "ENOENT") {
311
+ cleanupWarning = `\nNote: could not remove local draft: ${e.message}`;
410
312
  }
411
313
  }
412
- const inGui = process.env.OPENALMANAC_GUI === "1";
413
- const resultLines = [...validationLines];
414
- let okCount = 0;
415
- if (validArticles.length > 0) {
416
- const resp = await request("POST", "/api/articles/batch-publish", {
417
- auth: true,
418
- json: { articles: validArticles },
419
- });
420
- const data = (await resp.json());
421
- for (const r of data.results) {
422
- if (r.status === "failed") {
423
- resultLines.push(`FAILED ${r.slug}: ${r.error ?? "unknown error"}`);
424
- continue;
425
- }
426
- okCount += 1;
427
- const task = tasks.find((t) => t.slug === r.slug);
428
- if (task) {
429
- try {
430
- unlinkSync(task.filePath);
431
- }
432
- catch (e) {
433
- if (e.code !== "ENOENT") {
434
- resultLines.push(`Note: could not remove local draft for ${r.slug}: ${e.message}`);
435
- }
436
- }
437
- try {
438
- unlinkSync(task.originalPath);
439
- }
440
- catch (e) {
441
- if (e.code !== "ENOENT") {
442
- resultLines.push(`Note: could not remove original copy for ${r.slug}: ${e.message}`);
443
- }
444
- }
445
- }
446
- resultLines.push(`OK ${r.slug}: ${r.status}\n${JSON.stringify(r, null, 2)}`);
447
- if (!inGui && tasks.length === 1 && r.canonical_path) {
448
- const articleUrl = `https://www.openalmanac.org${r.canonical_path}?celebrate=true`;
449
- openBrowser(articleUrl);
450
- }
314
+ try {
315
+ unlinkSync(join(ARTICLES_DIR, `.${slug}.original.md`));
316
+ }
317
+ catch (e) {
318
+ if (e.code !== "ENOENT") {
319
+ cleanupWarning += `\nNote: could not remove original copy: ${e.message}`;
451
320
  }
452
321
  }
453
- const urlHint = inGui
454
- ? "\n\nThe article(s) have been published! Let the user know they're live. Do not send them to a web URL."
455
- : tasks.length > 1
456
- ? "\n\n(Opening browser skipped for batch publish — share URLs from results above.)"
457
- : "";
458
- return `Published ${okCount}/${tasks.length}.\n\n${resultLines.join("\n\n")}${urlHint}`;
322
+ return `Pushed successfully.\n\nArticle URL (share this exact link with the user): ${articleUrl}${cleanupWarning}\n\n${JSON.stringify(data, null, 2)}`;
459
323
  },
460
324
  });
461
325
  server.addTool({
462
- name: "list_articles",
463
- description: "Browse a community's wiki articles. Structured listing, not fuzzy search. " +
464
- "Use this to see what exists, find stubs to fill, or discover most-referenced gaps.",
326
+ name: "requested_articles",
327
+ description: "List requested articles stubs that are referenced by the most articles but haven't been fully written yet. " +
328
+ "Use this to find high-demand topics to write about. No authentication needed.",
465
329
  parameters: z.object({
466
- community_slug: z.string().describe("Community slug"),
467
- topic: z.string().optional().describe("Filter by topic slug"),
468
- sort: z.enum(["recent", "most_referenced"]).default("recent").describe("Sort order"),
469
- stubs_only: z.boolean().default(false).describe("Only return stubs"),
470
- limit: z.number().min(1).max(200).default(50).describe("Max results (1-200)"),
330
+ limit: z.number().default(20).describe("Max results (1-200, default 20)"),
331
+ offset: z.number().default(0).describe("Pagination offset (default 0)"),
471
332
  }),
472
- async execute({ community_slug, topic, sort, stubs_only, limit }) {
473
- const params = { sort, stubs_only, limit };
474
- if (topic)
475
- params.topic = topic;
476
- const resp = await request("GET", `/api/communities/${community_slug}/wiki`, { params });
333
+ async execute({ limit, offset }) {
334
+ const resp = await request("GET", "/api/articles/requested", {
335
+ params: { limit, offset },
336
+ });
477
337
  return JSON.stringify(await resp.json(), null, 2);
478
338
  },
479
339
  });
@@ -508,4 +368,34 @@ export function registerArticleTools(server) {
508
368
  return `Article Proposal: ${title}\n\n${summary}\n\nProceed with writing this article following the writing flow in your instructions.`;
509
369
  },
510
370
  });
371
+ server.addTool({
372
+ name: "status",
373
+ description: "Show login status and list all article files in your local working directory (~/.openalmanac/articles/). " +
374
+ "Shows auth state, filename, title, file size, and last modified time.",
375
+ async execute() {
376
+ ensureArticlesDir();
377
+ const auth = await getAuthStatus();
378
+ const authLine = auth.loggedIn
379
+ ? `Logged in as ${auth.name}.`
380
+ : "Not logged in. Use login to authenticate.";
381
+ const files = readdirSync(ARTICLES_DIR).filter((f) => f.endsWith(".md") && !f.startsWith("."));
382
+ if (files.length === 0) {
383
+ return `${authLine}\n\nLocal articles (0 files in ${ARTICLES_DIR}):\n (none — use download or new to get started)`;
384
+ }
385
+ const rows = [];
386
+ for (const file of files) {
387
+ const filePath = join(ARTICLES_DIR, file);
388
+ const stat = statSync(filePath);
389
+ const raw = readFileSync(filePath, "utf-8");
390
+ const { frontmatter } = parseFrontmatter(raw);
391
+ const title = frontmatter.title || "(untitled)";
392
+ const size = stat.size < 1024
393
+ ? `${stat.size}B`
394
+ : `${(stat.size / 1024).toFixed(1)}KB`;
395
+ const modified = stat.mtime.toISOString().slice(0, 16).replace("T", " ");
396
+ rows.push(` ${file} — "${title}" (${size}, modified ${modified})`);
397
+ }
398
+ return `${authLine}\n\nLocal articles (${files.length} file(s) in ${ARTICLES_DIR}):\n${rows.join("\n")}`;
399
+ },
400
+ });
511
401
  }
@@ -4,7 +4,7 @@ export function registerCommunityTools(server) {
4
4
  server.addTool({
5
5
  name: "search_communities",
6
6
  description: "Search or list OpenAlmanac communities. Returns community names, descriptions, and member counts. " +
7
- "Use this to discover communities by topic. No authentication needed.",
7
+ "Use this after pushing an article to find relevant communities for auto-linking. No authentication needed.",
8
8
  parameters: z.object({
9
9
  query: z
10
10
  .string()
@@ -54,18 +54,11 @@ export function registerCommunityTools(server) {
54
54
  .min(1)
55
55
  .max(2000)
56
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)"),
59
57
  }),
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;
58
+ async execute({ name, slug, description }) {
66
59
  const resp = await request("POST", "/api/communities", {
67
60
  auth: true,
68
- json,
61
+ json: { name, slug, description },
69
62
  });
70
63
  const data = (await resp.json());
71
64
  const communityUrl = `https://www.openalmanac.org/communities/${slug}`;
@@ -98,4 +91,37 @@ export function registerCommunityTools(server) {
98
91
  return `Post created!\n\nURL: ${postUrl}\n\n${JSON.stringify(data, null, 2)}`;
99
92
  },
100
93
  });
94
+ server.addTool({
95
+ name: "link_article",
96
+ description: "Link an article to one or more communities. Use this after pushing an article to connect it " +
97
+ "with relevant communities. Call search_communities first to find matching communities. " +
98
+ "Idempotent — already-linked articles are reported but don't cause errors. Requires login.",
99
+ parameters: z.object({
100
+ article_id: z.string().describe("Article slug/ID to link (e.g. 'machine-learning')"),
101
+ community_slugs: z
102
+ .array(z.string())
103
+ .min(1)
104
+ .max(50)
105
+ .describe("List of community slugs to link the article to (max 50)"),
106
+ }),
107
+ async execute({ article_id, community_slugs }) {
108
+ const resp = await request("POST", `/api/articles/${article_id}/auto-link`, {
109
+ auth: true,
110
+ json: { community_slugs },
111
+ });
112
+ const data = (await resp.json());
113
+ const lines = [];
114
+ if (data.linked.length > 0) {
115
+ lines.push(`Linked to ${data.linked.length} communities: ${data.linked.join(", ")}`);
116
+ }
117
+ if (data.failed.length > 0) {
118
+ const failLines = data.failed.map((f) => ` ${f.slug}: ${f.reason}`);
119
+ lines.push(`Failed (${data.failed.length}):\n${failLines.join("\n")}`);
120
+ }
121
+ if (lines.length === 0) {
122
+ lines.push("No communities to link.");
123
+ }
124
+ return lines.join("\n\n");
125
+ },
126
+ });
101
127
  }
@@ -86,13 +86,14 @@ export function registerResearchTools(server) {
86
86
  name: "read_webpage",
87
87
  description: "Fetch a URL and return its content as markdown. Routes automatically based on URL:\n" +
88
88
  "- **Reddit threads** (reddit.com/r/{sub}/comments/{id}/...) — returns the post plus top-level threaded comments with scores and authors, via a residential proxy.\n" +
89
+ "- **Reddit wiki pages** (reddit.com/r/{sub}/wiki/...) — returns the wiki page as markdown with revision metadata.\n" +
89
90
  "- **YouTube videos** — returns title, description, transcript when available.\n" +
90
91
  "- **PDFs** — extracts text.\n" +
91
92
  "- **LinkedIn posts/profiles** — uses the LinkedIn scraper.\n" +
92
93
  "- **Everything else** — generic web scrape with Firecrawl/Jina fallback.\n\n" +
93
94
  "Requires API key. Rate limit: 5/min.",
94
95
  parameters: z.object({
95
- url: z.string().url().describe("Full URL to read. For Reddit threads, use the canonical reddit.com/r/{sub}/comments/{id}/... form."),
96
+ url: z.string().url().describe("Full URL to read. For Reddit threads, use reddit.com/r/{sub}/comments/{id}/...; for Reddit wikis, use reddit.com/r/{sub}/wiki/... ."),
96
97
  max_length: z
97
98
  .number()
98
99
  .int()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openalmanac",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "OpenAlmanac — pull, edit, and push articles to the open knowledge base",
5
5
  "type": "module",
6
6
  "bin": {