openalmanac 0.3.3 → 0.3.4
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 +296 -186
- package/dist/tools/communities.js +10 -36
- package/dist/tools/topics.js +28 -0
- package/package.json +1 -1
package/dist/tools/articles.js
CHANGED
|
@@ -2,10 +2,37 @@ 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
|
+
}
|
|
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
|
+
}
|
|
9
36
|
const WRITING_GUIDE = `
|
|
10
37
|
## Article structure
|
|
11
38
|
|
|
@@ -124,21 +151,79 @@ External image URLs are auto-persisted on publish — no extra steps needed.
|
|
|
124
151
|
function ensureArticlesDir() {
|
|
125
152
|
mkdirSync(ARTICLES_DIR, { recursive: true });
|
|
126
153
|
}
|
|
154
|
+
function resolveArticleDir(communitySlug) {
|
|
155
|
+
return communitySlug ? join(ARTICLES_DIR, communitySlug) : ARTICLES_DIR;
|
|
156
|
+
}
|
|
157
|
+
function resolveArticlePaths(slug, communitySlug) {
|
|
158
|
+
const dir = resolveArticleDir(communitySlug);
|
|
159
|
+
return {
|
|
160
|
+
dir,
|
|
161
|
+
filePath: join(dir, `${slug}.md`),
|
|
162
|
+
originalPath: join(dir, `.${slug}.original.md`),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function findDraftCandidates(slug) {
|
|
166
|
+
const matches = [];
|
|
167
|
+
const root = resolveArticlePaths(slug);
|
|
168
|
+
if (existsSync(root.filePath)) {
|
|
169
|
+
matches.push({ communitySlug: null, filePath: root.filePath, originalPath: root.originalPath });
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const dirs = readdirSync(ARTICLES_DIR).filter((d) => {
|
|
173
|
+
try {
|
|
174
|
+
return statSync(join(ARTICLES_DIR, d)).isDirectory();
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
for (const communitySlug of dirs) {
|
|
181
|
+
const scoped = resolveArticlePaths(slug, communitySlug);
|
|
182
|
+
if (existsSync(scoped.filePath)) {
|
|
183
|
+
matches.push({ communitySlug, filePath: scoped.filePath, originalPath: scoped.originalPath });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch { /* ignore readdir errors */ }
|
|
188
|
+
return matches;
|
|
189
|
+
}
|
|
190
|
+
function resolvePublishCandidate(slug, communitySlug) {
|
|
191
|
+
if (communitySlug) {
|
|
192
|
+
const paths = resolveArticlePaths(slug, communitySlug);
|
|
193
|
+
if (!existsSync(paths.filePath)) {
|
|
194
|
+
throw new Error(`File not found: ${paths.filePath}\nUse download to get an existing article or new to create a scaffold.`);
|
|
195
|
+
}
|
|
196
|
+
return { communitySlug, ...paths };
|
|
197
|
+
}
|
|
198
|
+
const matches = findDraftCandidates(slug);
|
|
199
|
+
if (matches.length === 0) {
|
|
200
|
+
const fallback = resolveArticlePaths(slug);
|
|
201
|
+
throw new Error(`File not found: ${fallback.filePath}\nUse download to get an existing article or new to create a scaffold.`);
|
|
202
|
+
}
|
|
203
|
+
if (matches.length > 1) {
|
|
204
|
+
const paths = matches.map((match) => ` - ${match.filePath}`).join("\n");
|
|
205
|
+
throw new Error(`Multiple local drafts found for slug "${slug}". Publish is ambiguous.\n${paths}\nRemove the duplicate draft or publish with community_slug set.`);
|
|
206
|
+
}
|
|
207
|
+
return matches[0];
|
|
208
|
+
}
|
|
127
209
|
export function registerArticleTools(server) {
|
|
128
210
|
server.addTool({
|
|
129
211
|
name: "search_articles",
|
|
130
212
|
description: "Search existing OpenAlmanac articles and stubs. Accepts multiple queries for batch lookup. " +
|
|
131
213
|
"Use this to check if articles or stubs exist before creating them, or to find entity slugs for wikilinks. " +
|
|
132
|
-
"Results include 'stub' field (true/false) and 'entity_type' field.
|
|
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.",
|
|
133
216
|
parameters: z.object({
|
|
134
|
-
queries: z.array(z.string()).min(1).max(20).describe("Search queries (1-20)"),
|
|
217
|
+
queries: coerceJson(z.array(z.string()).min(1).max(20)).describe("Search queries (1-20)"),
|
|
135
218
|
limit: z.number().default(5).describe("Max results per query (1-50, default 5)"),
|
|
136
219
|
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."),
|
|
137
221
|
}),
|
|
138
|
-
async execute({ queries, limit, include_stubs }) {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
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 });
|
|
142
227
|
return JSON.stringify(await resp.json(), null, 2);
|
|
143
228
|
},
|
|
144
229
|
});
|
|
@@ -148,192 +233,247 @@ export function registerArticleTools(server) {
|
|
|
148
233
|
"Use this to reference or summarize existing articles in conversation. " +
|
|
149
234
|
"For editing articles locally, use 'download' instead. No authentication needed.",
|
|
150
235
|
parameters: z.object({
|
|
151
|
-
slugs: z.array(z.string()).min(1).max(20).describe("Article slugs to read (1-20)"),
|
|
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."),
|
|
152
238
|
}),
|
|
153
|
-
async execute({ slugs }) {
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
});
|
|
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 });
|
|
197
244
|
return JSON.stringify(await resp.json(), null, 2);
|
|
198
245
|
},
|
|
199
246
|
});
|
|
200
247
|
server.addTool({
|
|
201
248
|
name: "download",
|
|
202
|
-
description: "Download
|
|
203
|
-
"
|
|
204
|
-
"After editing, use
|
|
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.",
|
|
205
252
|
parameters: z.object({
|
|
206
|
-
|
|
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"),
|
|
207
255
|
}),
|
|
208
|
-
async execute({
|
|
209
|
-
|
|
210
|
-
|
|
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
|
+
}
|
|
211
261
|
}
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const
|
|
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());
|
|
216
267
|
ensureArticlesDir();
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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");
|
|
230
296
|
},
|
|
231
297
|
});
|
|
232
298
|
server.addTool({
|
|
233
299
|
name: "new",
|
|
234
|
-
description: "
|
|
235
|
-
"
|
|
236
|
-
"
|
|
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.",
|
|
237
303
|
parameters: z.object({
|
|
238
|
-
|
|
239
|
-
.string()
|
|
240
|
-
.describe("
|
|
241
|
-
|
|
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"),
|
|
242
310
|
}),
|
|
243
|
-
async execute({
|
|
244
|
-
if (!SLUG_RE.test(
|
|
245
|
-
throw new Error(`Invalid
|
|
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.`);
|
|
246
314
|
}
|
|
247
315
|
ensureArticlesDir();
|
|
248
|
-
|
|
249
|
-
if (
|
|
250
|
-
|
|
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);
|
|
251
348
|
}
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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");
|
|
256
355
|
},
|
|
257
356
|
});
|
|
258
357
|
server.addTool({
|
|
259
358
|
name: "publish",
|
|
260
|
-
description: "Validate and publish
|
|
261
|
-
"
|
|
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.",
|
|
262
363
|
parameters: z.object({
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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/"),
|
|
266
368
|
}),
|
|
267
|
-
async execute({
|
|
268
|
-
if (!
|
|
269
|
-
throw new Error(
|
|
369
|
+
async execute({ slugs, community_slug }) {
|
|
370
|
+
if (!slugs?.length && !community_slug) {
|
|
371
|
+
throw new Error("Provide slugs or community_slug (explicit intent required).");
|
|
270
372
|
}
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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);
|
|
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
|
+
}
|
|
308
390
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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) });
|
|
312
397
|
}
|
|
313
398
|
}
|
|
314
|
-
|
|
315
|
-
|
|
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 });
|
|
410
|
+
}
|
|
316
411
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
+
}
|
|
320
451
|
}
|
|
321
452
|
}
|
|
322
|
-
|
|
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}`;
|
|
323
459
|
},
|
|
324
460
|
});
|
|
325
461
|
server.addTool({
|
|
326
|
-
name: "
|
|
327
|
-
description: "
|
|
328
|
-
"Use this to find
|
|
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.",
|
|
329
465
|
parameters: z.object({
|
|
330
|
-
|
|
331
|
-
|
|
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)"),
|
|
332
471
|
}),
|
|
333
|
-
async execute({
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
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 });
|
|
337
477
|
return JSON.stringify(await resp.json(), null, 2);
|
|
338
478
|
},
|
|
339
479
|
});
|
|
@@ -368,34 +508,4 @@ export function registerArticleTools(server) {
|
|
|
368
508
|
return `Article Proposal: ${title}\n\n${summary}\n\nProceed with writing this article following the writing flow in your instructions.`;
|
|
369
509
|
},
|
|
370
510
|
});
|
|
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
|
-
});
|
|
401
511
|
}
|
|
@@ -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
|
|
7
|
+
"Use this to discover communities by topic. No authentication needed.",
|
|
8
8
|
parameters: z.object({
|
|
9
9
|
query: z
|
|
10
10
|
.string()
|
|
@@ -54,11 +54,18 @@ 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)"),
|
|
57
59
|
}),
|
|
58
|
-
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;
|
|
59
66
|
const resp = await request("POST", "/api/communities", {
|
|
60
67
|
auth: true,
|
|
61
|
-
json
|
|
68
|
+
json,
|
|
62
69
|
});
|
|
63
70
|
const data = (await resp.json());
|
|
64
71
|
const communityUrl = `https://www.openalmanac.org/communities/${slug}`;
|
|
@@ -91,37 +98,4 @@ export function registerCommunityTools(server) {
|
|
|
91
98
|
return `Post created!\n\nURL: ${postUrl}\n\n${JSON.stringify(data, null, 2)}`;
|
|
92
99
|
},
|
|
93
100
|
});
|
|
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
|
-
});
|
|
127
101
|
}
|
package/dist/tools/topics.js
CHANGED
|
@@ -46,6 +46,34 @@ export function registerTopicTools(server) {
|
|
|
46
46
|
return JSON.stringify(await resp.json(), null, 2);
|
|
47
47
|
},
|
|
48
48
|
});
|
|
49
|
+
server.addTool({
|
|
50
|
+
name: "update_topic",
|
|
51
|
+
description: "Update a topic's title, description, or image. Requires wiki moderator role.",
|
|
52
|
+
parameters: z.object({
|
|
53
|
+
wiki_slug: z.string().describe("Wiki slug"),
|
|
54
|
+
topic_slug: z.string().describe("Slug of the topic to update"),
|
|
55
|
+
title: z.string().optional().describe("New title (also updates slug)"),
|
|
56
|
+
description: z.string().optional().describe("New description"),
|
|
57
|
+
image_url: z
|
|
58
|
+
.string()
|
|
59
|
+
.url()
|
|
60
|
+
.max(2048)
|
|
61
|
+
.nullable()
|
|
62
|
+
.optional()
|
|
63
|
+
.describe("Topic image URL (https:// or http://). Pass null to clear."),
|
|
64
|
+
}),
|
|
65
|
+
async execute({ wiki_slug, topic_slug, title, description, image_url }) {
|
|
66
|
+
const body = {};
|
|
67
|
+
if (title !== undefined)
|
|
68
|
+
body.title = title;
|
|
69
|
+
if (description !== undefined)
|
|
70
|
+
body.description = description;
|
|
71
|
+
if (image_url !== undefined)
|
|
72
|
+
body.image_url = image_url;
|
|
73
|
+
const resp = await request("PATCH", `/api/w/${wiki_slug}/topics/${topic_slug}`, { auth: true, json: body });
|
|
74
|
+
return JSON.stringify(await resp.json(), null, 2);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
49
77
|
server.addTool({
|
|
50
78
|
name: "create_topics_batch",
|
|
51
79
|
description: "Batch create topics in a wiki. Useful for bootstrapping a topic hierarchy. Requires wiki membership.",
|