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.
- package/dist/tools/articles.js +140 -25
- package/dist/validate.js +24 -0
- package/package.json +1 -1
package/dist/tools/articles.js
CHANGED
|
@@ -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.
|
|
221
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
290
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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)) {
|