openalmanac 0.2.33 → 0.2.34

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.
@@ -142,6 +142,54 @@ External image URLs are auto-persisted on publish — no extra steps needed.
142
142
  function ensureArticlesDir() {
143
143
  mkdirSync(ARTICLES_DIR, { recursive: true });
144
144
  }
145
+ function resolveArticleDir(communitySlug) {
146
+ return communitySlug ? join(ARTICLES_DIR, communitySlug) : ARTICLES_DIR;
147
+ }
148
+ function resolveArticlePaths(slug, communitySlug) {
149
+ const dir = resolveArticleDir(communitySlug);
150
+ return {
151
+ dir,
152
+ filePath: join(dir, `${slug}.md`),
153
+ originalPath: join(dir, `.${slug}.original.md`),
154
+ };
155
+ }
156
+ function findDraftCandidates(slug) {
157
+ const matches = [];
158
+ const root = resolveArticlePaths(slug);
159
+ if (existsSync(root.filePath)) {
160
+ matches.push({ communitySlug: null, filePath: root.filePath, originalPath: root.originalPath });
161
+ }
162
+ try {
163
+ const dirs = readdirSync(ARTICLES_DIR).filter((d) => {
164
+ try {
165
+ return statSync(join(ARTICLES_DIR, d)).isDirectory();
166
+ }
167
+ catch {
168
+ return false;
169
+ }
170
+ });
171
+ for (const communitySlug of dirs) {
172
+ const scoped = resolveArticlePaths(slug, communitySlug);
173
+ if (existsSync(scoped.filePath)) {
174
+ matches.push({ communitySlug, filePath: scoped.filePath, originalPath: scoped.originalPath });
175
+ }
176
+ }
177
+ }
178
+ catch { /* ignore readdir errors */ }
179
+ return matches;
180
+ }
181
+ function resolvePublishCandidate(slug) {
182
+ const matches = findDraftCandidates(slug);
183
+ if (matches.length === 0) {
184
+ const fallback = resolveArticlePaths(slug);
185
+ throw new Error(`File not found: ${fallback.filePath}\nUse download to get an existing article or new to create a scaffold.`);
186
+ }
187
+ if (matches.length > 1) {
188
+ 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 from the exact community context.`);
190
+ }
191
+ return matches[0];
192
+ }
145
193
  export function registerArticleTools(server) {
146
194
  server.addTool({
147
195
  name: "search_articles",
@@ -167,8 +215,16 @@ export function registerArticleTools(server) {
167
215
  "For editing articles locally, use 'download' instead. No authentication needed.",
168
216
  parameters: z.object({
169
217
  slugs: coerceJson(z.array(z.string()).min(1).max(20)).describe("Article slugs to read (1-20)"),
218
+ community_slug: z.string().optional().describe("Community slug for reading community-owned wiki articles. Omit for global almanac articles."),
170
219
  }),
171
- async execute({ slugs }) {
220
+ async execute({ slugs, community_slug }) {
221
+ if (community_slug) {
222
+ const results = await Promise.all(slugs.map(async (slug) => {
223
+ const resp = await request("GET", `/api/communities/${community_slug}/wiki/${slug}`);
224
+ return await resp.json();
225
+ }));
226
+ return JSON.stringify({ results }, null, 2);
227
+ }
172
228
  const resp = await request("POST", "/api/articles/batch", {
173
229
  json: { slugs },
174
230
  });
@@ -179,6 +235,7 @@ export function registerArticleTools(server) {
179
235
  name: "create_stubs",
180
236
  description: "Create stub articles — placeholders for entities that don't have full articles yet. " +
181
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. " +
182
239
  "Idempotent: existing slugs return their current status. Requires login.",
183
240
  parameters: z.object({
184
241
  stubs: coerceJson(z.array(z.object({
@@ -205,6 +262,8 @@ export function registerArticleTools(server) {
205
262
  .optional()
206
263
  .describe("2-4 sentence summary of the entity. This becomes the stub page content. " +
207
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'])."),
208
267
  })).min(1).max(50)).describe("Stubs to create (1-50)"),
209
268
  }),
210
269
  async execute({ stubs }) {
@@ -217,24 +276,33 @@ export function registerArticleTools(server) {
217
276
  });
218
277
  server.addTool({
219
278
  name: "download",
220
- description: "Download an article to your local workspace for editing. The file is saved to ~/.openalmanac/articles/{slug}.md " +
221
- "with YAML frontmatter. Returns a writing guide covering article structure, infobox format, citations, and quality rules. " +
279
+ description: "Download an article to your local workspace for editing. Global articles are saved to ~/.openalmanac/articles/{slug}.md. " +
280
+ "Community wiki articles are saved to ~/.openalmanac/articles/{community_slug}/{slug}.md. " +
281
+ "Returns a writing guide covering article structure, infobox format, citations, and quality rules. " +
222
282
  "After editing, use 'publish' to push your changes live.",
223
283
  parameters: z.object({
224
284
  slug: z.string().describe("Article slug (e.g. 'machine-learning')"),
285
+ community_slug: z.string().optional().describe("Community slug for downloading a community-owned wiki article. " +
286
+ "Omit for global almanac articles."),
225
287
  }),
226
- async execute({ slug }) {
288
+ async execute({ slug, community_slug }) {
227
289
  if (!SLUG_RE.test(slug)) {
228
290
  throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning').`);
229
291
  }
230
- const resp = await request("GET", `/api/articles/${slug}`, {
292
+ const apiPath = community_slug
293
+ ? `/api/communities/${community_slug}/wiki/${slug}`
294
+ : `/api/articles/${slug}`;
295
+ const resp = await request("GET", apiPath, {
231
296
  params: { format: "md" },
232
297
  });
233
298
  const markdown = await resp.text();
234
299
  ensureArticlesDir();
235
- const filePath = join(ARTICLES_DIR, `${slug}.md`);
300
+ // Community articles go in a subdirectory
301
+ const { dir, filePath, originalPath } = resolveArticlePaths(slug, community_slug);
302
+ if (community_slug) {
303
+ mkdirSync(dir, { recursive: true });
304
+ }
236
305
  writeFileSync(filePath, markdown, "utf-8");
237
- const originalPath = join(ARTICLES_DIR, `.${slug}.original.md`);
238
306
  writeFileSync(originalPath, markdown, "utf-8");
239
307
  const { frontmatter, content } = parseFrontmatter(markdown);
240
308
  const title = frontmatter.title || "(untitled)";
@@ -250,6 +318,8 @@ export function registerArticleTools(server) {
250
318
  server.addTool({
251
319
  name: "new",
252
320
  description: "Create a new article scaffold in your local working directory (~/.openalmanac/articles/). " +
321
+ "For community wiki articles, provide community_slug and optional topics — the file is saved under " +
322
+ "~/.openalmanac/articles/{community_slug}/{slug}.md. " +
253
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. " +
254
324
  "Edit the file to add content and sources, then use publish to go live.",
255
325
  parameters: z.object({
@@ -257,17 +327,36 @@ export function registerArticleTools(server) {
257
327
  .string()
258
328
  .describe("Unique kebab-case identifier (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$"),
259
329
  title: z.string().describe("Article title"),
330
+ community_slug: z.string().optional().describe("Community slug for community-owned wiki articles (e.g. 'lockpicking'). " +
331
+ "Omit for global almanac articles."),
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."),
260
334
  }),
261
- async execute({ slug, title }) {
335
+ async execute({ slug, title, community_slug, topics }) {
262
336
  if (!SLUG_RE.test(slug)) {
263
337
  throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$`);
264
338
  }
339
+ if (community_slug && !SLUG_RE.test(community_slug)) {
340
+ throw new Error(`Invalid community_slug "${community_slug}". Must be kebab-case.`);
341
+ }
265
342
  ensureArticlesDir();
266
- const filePath = join(ARTICLES_DIR, `${slug}.md`);
343
+ // Community articles are saved in a subdirectory
344
+ let dir = ARTICLES_DIR;
345
+ if (community_slug) {
346
+ dir = join(ARTICLES_DIR, community_slug);
347
+ mkdirSync(dir, { recursive: true });
348
+ }
349
+ const filePath = join(dir, `${slug}.md`);
267
350
  if (existsSync(filePath)) {
268
351
  throw new Error(`File already exists: ${filePath}\nUse download to refresh it, or publish to push changes.`);
269
352
  }
270
- const frontmatter = yamlStringify({ article_id: slug, title, sources: [] });
353
+ const meta = { article_id: slug, title };
354
+ if (community_slug)
355
+ meta.community_slug = community_slug;
356
+ if (topics && topics.length > 0)
357
+ meta.topics = topics;
358
+ meta.sources = [];
359
+ const frontmatter = yamlStringify(meta);
271
360
  const scaffold = `---\n${frontmatter}---\n\n`;
272
361
  writeFileSync(filePath, scaffold, "utf-8");
273
362
  return `Created ${filePath}\n\n${WRITING_GUIDE}`;
@@ -275,7 +364,7 @@ export function registerArticleTools(server) {
275
364
  });
276
365
  server.addTool({
277
366
  name: "publish",
278
- description: "Validate and publish an article from your local workspace. Reads ~/.openalmanac/articles/{slug}.md, " +
367
+ description: "Validate and publish an article from your local workspace. Reads ~/.openalmanac/articles/{slug}.md or the matching community draft, " +
279
368
  "validates content and sources, and publishes to OpenAlmanac. Requires login.",
280
369
  parameters: z.object({
281
370
  slug: z.string().describe("Article slug matching the filename (without .md)"),
@@ -286,14 +375,8 @@ export function registerArticleTools(server) {
286
375
  if (!SLUG_RE.test(slug)) {
287
376
  throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning').`);
288
377
  }
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.`);
296
- }
378
+ const { communitySlug, filePath, originalPath } = resolvePublishCandidate(slug);
379
+ const raw = readFileSync(filePath, "utf-8");
297
380
  // Local validation
298
381
  const errors = validateArticle(raw);
299
382
  if (errors.length > 0) {
@@ -317,7 +400,12 @@ export function registerArticleTools(server) {
317
400
  contentType: "text/markdown",
318
401
  });
319
402
  const data = (await resp.json());
320
- const articleUrl = `https://www.openalmanac.org/article/${slug}?celebrate=true`;
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`;
321
409
  const inGui = process.env.OPENALMANAC_GUI === "1";
322
410
  // Skip browser open when running inside the GUI — it handles navigation itself
323
411
  if (!inGui) {
@@ -334,7 +422,7 @@ export function registerArticleTools(server) {
334
422
  }
335
423
  }
336
424
  try {
337
- unlinkSync(join(ARTICLES_DIR, `.${slug}.original.md`));
425
+ unlinkSync(originalPath);
338
426
  }
339
427
  catch (e) {
340
428
  if (e.code !== "ENOENT") {
@@ -404,9 +492,6 @@ export function registerArticleTools(server) {
404
492
  ? `Logged in as ${auth.name}.`
405
493
  : "Not logged in. Use login to authenticate.";
406
494
  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
495
  const rows = [];
411
496
  for (const file of files) {
412
497
  const filePath = join(ARTICLES_DIR, file);
@@ -420,7 +505,37 @@ export function registerArticleTools(server) {
420
505
  const modified = stat.mtime.toISOString().slice(0, 16).replace("T", " ");
421
506
  rows.push(` ${file} — "${title}" (${size}, modified ${modified})`);
422
507
  }
423
- return `${authLine}\n\nLocal articles (${files.length} file(s) in ${ARTICLES_DIR}):\n${rows.join("\n")}`;
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")}`;
424
539
  },
425
540
  });
426
541
  }
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)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openalmanac",
3
- "version": "0.2.33",
3
+ "version": "0.2.34",
4
4
  "description": "OpenAlmanac — pull, edit, and push articles to the open knowledge base",
5
5
  "type": "module",
6
6
  "bin": {