openalmanac 0.2.32 → 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 +171 -31
- package/dist/tools/communities.js +20 -2
- package/dist/tools/research.js +22 -4
- package/dist/validate.js +24 -0
- package/package.json +1 -1
package/dist/tools/articles.js
CHANGED
|
@@ -6,6 +6,24 @@ 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
|
+
/**
|
|
10
|
+
* Workaround for Claude Agent SDK MCP transport bug (#18260):
|
|
11
|
+
* Array/object parameters are sometimes serialized as JSON strings
|
|
12
|
+
* instead of native values. This preprocessor coerces them back.
|
|
13
|
+
*/
|
|
14
|
+
function coerceJson(schema) {
|
|
15
|
+
return z.preprocess((val) => {
|
|
16
|
+
if (typeof val === "string") {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(val);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return val;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return val;
|
|
25
|
+
}, schema);
|
|
26
|
+
}
|
|
9
27
|
const WRITING_GUIDE = `
|
|
10
28
|
## Article structure
|
|
11
29
|
|
|
@@ -124,6 +142,54 @@ External image URLs are auto-persisted on publish — no extra steps needed.
|
|
|
124
142
|
function ensureArticlesDir() {
|
|
125
143
|
mkdirSync(ARTICLES_DIR, { recursive: true });
|
|
126
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
|
+
}
|
|
127
193
|
export function registerArticleTools(server) {
|
|
128
194
|
server.addTool({
|
|
129
195
|
name: "search_articles",
|
|
@@ -131,7 +197,7 @@ export function registerArticleTools(server) {
|
|
|
131
197
|
"Use this to check if articles or stubs exist before creating them, or to find entity slugs for wikilinks. " +
|
|
132
198
|
"Results include 'stub' field (true/false) and 'entity_type' field. No authentication needed.",
|
|
133
199
|
parameters: z.object({
|
|
134
|
-
queries: z.array(z.string()).min(1).max(20).describe("Search queries (1-20)"),
|
|
200
|
+
queries: coerceJson(z.array(z.string()).min(1).max(20)).describe("Search queries (1-20)"),
|
|
135
201
|
limit: z.number().default(5).describe("Max results per query (1-50, default 5)"),
|
|
136
202
|
include_stubs: z.boolean().default(true).describe("Include stub articles in results (default true)"),
|
|
137
203
|
}),
|
|
@@ -148,9 +214,17 @@ export function registerArticleTools(server) {
|
|
|
148
214
|
"Use this to reference or summarize existing articles in conversation. " +
|
|
149
215
|
"For editing articles locally, use 'download' instead. No authentication needed.",
|
|
150
216
|
parameters: z.object({
|
|
151
|
-
slugs: z.array(z.string()).min(1).max(20).describe("Article slugs to read (1-20)"),
|
|
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."),
|
|
152
219
|
}),
|
|
153
|
-
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
|
+
}
|
|
154
228
|
const resp = await request("POST", "/api/articles/batch", {
|
|
155
229
|
json: { slugs },
|
|
156
230
|
});
|
|
@@ -161,9 +235,10 @@ export function registerArticleTools(server) {
|
|
|
161
235
|
name: "create_stubs",
|
|
162
236
|
description: "Create stub articles — placeholders for entities that don't have full articles yet. " +
|
|
163
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. " +
|
|
164
239
|
"Idempotent: existing slugs return their current status. Requires login.",
|
|
165
240
|
parameters: z.object({
|
|
166
|
-
stubs: z.array(z.object({
|
|
241
|
+
stubs: coerceJson(z.array(z.object({
|
|
167
242
|
slug: z
|
|
168
243
|
.string()
|
|
169
244
|
.min(1)
|
|
@@ -187,7 +262,9 @@ export function registerArticleTools(server) {
|
|
|
187
262
|
.optional()
|
|
188
263
|
.describe("2-4 sentence summary of the entity. This becomes the stub page content. " +
|
|
189
264
|
"Be informative — include key facts, dates, and context."),
|
|
190
|
-
|
|
265
|
+
community_slug: z.string().optional().describe("Community slug for community-owned stubs. Omit for global stubs."),
|
|
266
|
+
topics: z.array(z.string()).optional().describe("Topic slugs for community wiki stubs (e.g. ['techniques', 'tools'])."),
|
|
267
|
+
})).min(1).max(50)).describe("Stubs to create (1-50)"),
|
|
191
268
|
}),
|
|
192
269
|
async execute({ stubs }) {
|
|
193
270
|
const resp = await request("POST", "/api/articles/stubs", {
|
|
@@ -199,24 +276,33 @@ export function registerArticleTools(server) {
|
|
|
199
276
|
});
|
|
200
277
|
server.addTool({
|
|
201
278
|
name: "download",
|
|
202
|
-
description: "Download an article to your local workspace for editing.
|
|
203
|
-
"
|
|
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. " +
|
|
204
282
|
"After editing, use 'publish' to push your changes live.",
|
|
205
283
|
parameters: z.object({
|
|
206
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."),
|
|
207
287
|
}),
|
|
208
|
-
async execute({ slug }) {
|
|
288
|
+
async execute({ slug, community_slug }) {
|
|
209
289
|
if (!SLUG_RE.test(slug)) {
|
|
210
290
|
throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning').`);
|
|
211
291
|
}
|
|
212
|
-
const
|
|
292
|
+
const apiPath = community_slug
|
|
293
|
+
? `/api/communities/${community_slug}/wiki/${slug}`
|
|
294
|
+
: `/api/articles/${slug}`;
|
|
295
|
+
const resp = await request("GET", apiPath, {
|
|
213
296
|
params: { format: "md" },
|
|
214
297
|
});
|
|
215
298
|
const markdown = await resp.text();
|
|
216
299
|
ensureArticlesDir();
|
|
217
|
-
|
|
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
|
+
}
|
|
218
305
|
writeFileSync(filePath, markdown, "utf-8");
|
|
219
|
-
const originalPath = join(ARTICLES_DIR, `.${slug}.original.md`);
|
|
220
306
|
writeFileSync(originalPath, markdown, "utf-8");
|
|
221
307
|
const { frontmatter, content } = parseFrontmatter(markdown);
|
|
222
308
|
const title = frontmatter.title || "(untitled)";
|
|
@@ -232,6 +318,8 @@ export function registerArticleTools(server) {
|
|
|
232
318
|
server.addTool({
|
|
233
319
|
name: "new",
|
|
234
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. " +
|
|
235
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. " +
|
|
236
324
|
"Edit the file to add content and sources, then use publish to go live.",
|
|
237
325
|
parameters: z.object({
|
|
@@ -239,17 +327,36 @@ export function registerArticleTools(server) {
|
|
|
239
327
|
.string()
|
|
240
328
|
.describe("Unique kebab-case identifier (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$"),
|
|
241
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."),
|
|
242
334
|
}),
|
|
243
|
-
async execute({ slug, title }) {
|
|
335
|
+
async execute({ slug, title, community_slug, topics }) {
|
|
244
336
|
if (!SLUG_RE.test(slug)) {
|
|
245
337
|
throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$`);
|
|
246
338
|
}
|
|
339
|
+
if (community_slug && !SLUG_RE.test(community_slug)) {
|
|
340
|
+
throw new Error(`Invalid community_slug "${community_slug}". Must be kebab-case.`);
|
|
341
|
+
}
|
|
247
342
|
ensureArticlesDir();
|
|
248
|
-
|
|
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`);
|
|
249
350
|
if (existsSync(filePath)) {
|
|
250
351
|
throw new Error(`File already exists: ${filePath}\nUse download to refresh it, or publish to push changes.`);
|
|
251
352
|
}
|
|
252
|
-
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);
|
|
253
360
|
const scaffold = `---\n${frontmatter}---\n\n`;
|
|
254
361
|
writeFileSync(filePath, scaffold, "utf-8");
|
|
255
362
|
return `Created ${filePath}\n\n${WRITING_GUIDE}`;
|
|
@@ -257,7 +364,7 @@ export function registerArticleTools(server) {
|
|
|
257
364
|
});
|
|
258
365
|
server.addTool({
|
|
259
366
|
name: "publish",
|
|
260
|
-
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, " +
|
|
261
368
|
"validates content and sources, and publishes to OpenAlmanac. Requires login.",
|
|
262
369
|
parameters: z.object({
|
|
263
370
|
slug: z.string().describe("Article slug matching the filename (without .md)"),
|
|
@@ -268,14 +375,8 @@ export function registerArticleTools(server) {
|
|
|
268
375
|
if (!SLUG_RE.test(slug)) {
|
|
269
376
|
throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning').`);
|
|
270
377
|
}
|
|
271
|
-
const filePath =
|
|
272
|
-
|
|
273
|
-
try {
|
|
274
|
-
raw = readFileSync(filePath, "utf-8");
|
|
275
|
-
}
|
|
276
|
-
catch {
|
|
277
|
-
throw new Error(`File not found: ${filePath}\nUse download to get an existing article or new to create a scaffold.`);
|
|
278
|
-
}
|
|
378
|
+
const { communitySlug, filePath, originalPath } = resolvePublishCandidate(slug);
|
|
379
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
279
380
|
// Local validation
|
|
280
381
|
const errors = validateArticle(raw);
|
|
281
382
|
if (errors.length > 0) {
|
|
@@ -299,8 +400,17 @@ export function registerArticleTools(server) {
|
|
|
299
400
|
contentType: "text/markdown",
|
|
300
401
|
});
|
|
301
402
|
const data = (await resp.json());
|
|
302
|
-
const
|
|
303
|
-
|
|
403
|
+
const canonicalPath = typeof data.canonical_path === "string"
|
|
404
|
+
? data.canonical_path
|
|
405
|
+
: communitySlug
|
|
406
|
+
? `/communities/${communitySlug}/wiki/${slug}`
|
|
407
|
+
: `/article/${slug}`;
|
|
408
|
+
const articleUrl = `https://www.openalmanac.org${canonicalPath}?celebrate=true`;
|
|
409
|
+
const inGui = process.env.OPENALMANAC_GUI === "1";
|
|
410
|
+
// Skip browser open when running inside the GUI — it handles navigation itself
|
|
411
|
+
if (!inGui) {
|
|
412
|
+
openBrowser(articleUrl);
|
|
413
|
+
}
|
|
304
414
|
// Clean up local files after successful publish
|
|
305
415
|
let cleanupWarning = "";
|
|
306
416
|
try {
|
|
@@ -312,14 +422,17 @@ export function registerArticleTools(server) {
|
|
|
312
422
|
}
|
|
313
423
|
}
|
|
314
424
|
try {
|
|
315
|
-
unlinkSync(
|
|
425
|
+
unlinkSync(originalPath);
|
|
316
426
|
}
|
|
317
427
|
catch (e) {
|
|
318
428
|
if (e.code !== "ENOENT") {
|
|
319
429
|
cleanupWarning += `\nNote: could not remove original copy: ${e.message}`;
|
|
320
430
|
}
|
|
321
431
|
}
|
|
322
|
-
|
|
432
|
+
const urlLine = inGui
|
|
433
|
+
? "The article has been published! Let the user know it's live. Do not send them to a web URL."
|
|
434
|
+
: `Article URL (share this exact link with the user): ${articleUrl}`;
|
|
435
|
+
return `Pushed successfully.\n\n${urlLine}${cleanupWarning}\n\n${JSON.stringify(data, null, 2)}`;
|
|
323
436
|
},
|
|
324
437
|
});
|
|
325
438
|
server.addTool({
|
|
@@ -379,9 +492,6 @@ export function registerArticleTools(server) {
|
|
|
379
492
|
? `Logged in as ${auth.name}.`
|
|
380
493
|
: "Not logged in. Use login to authenticate.";
|
|
381
494
|
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
495
|
const rows = [];
|
|
386
496
|
for (const file of files) {
|
|
387
497
|
const filePath = join(ARTICLES_DIR, file);
|
|
@@ -395,7 +505,37 @@ export function registerArticleTools(server) {
|
|
|
395
505
|
const modified = stat.mtime.toISOString().slice(0, 16).replace("T", " ");
|
|
396
506
|
rows.push(` ${file} — "${title}" (${size}, modified ${modified})`);
|
|
397
507
|
}
|
|
398
|
-
|
|
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")}`;
|
|
399
539
|
},
|
|
400
540
|
});
|
|
401
541
|
}
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { request } from "../auth.js";
|
|
3
|
+
/**
|
|
4
|
+
* Workaround for Claude Agent SDK MCP transport bug (#18260):
|
|
5
|
+
* Array/object parameters are sometimes serialized as JSON strings
|
|
6
|
+
* instead of native values. This preprocessor coerces them back.
|
|
7
|
+
*/
|
|
8
|
+
function coerceJson(schema) {
|
|
9
|
+
return z.preprocess((val) => {
|
|
10
|
+
if (typeof val === "string") {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(val);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return val;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return val;
|
|
19
|
+
}, schema);
|
|
20
|
+
}
|
|
3
21
|
export function registerCommunityTools(server) {
|
|
4
22
|
server.addTool({
|
|
5
23
|
name: "search_communities",
|
|
@@ -98,10 +116,10 @@ export function registerCommunityTools(server) {
|
|
|
98
116
|
"Idempotent — already-linked articles are reported but don't cause errors. Requires login.",
|
|
99
117
|
parameters: z.object({
|
|
100
118
|
article_id: z.string().describe("Article slug/ID to link (e.g. 'machine-learning')"),
|
|
101
|
-
community_slugs: z
|
|
119
|
+
community_slugs: coerceJson(z
|
|
102
120
|
.array(z.string())
|
|
103
121
|
.min(1)
|
|
104
|
-
.max(50)
|
|
122
|
+
.max(50))
|
|
105
123
|
.describe("List of community slugs to link the article to (max 50)"),
|
|
106
124
|
}),
|
|
107
125
|
async execute({ article_id, community_slugs }) {
|
package/dist/tools/research.js
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { imageContent } from "fastmcp";
|
|
3
3
|
import { request } from "../auth.js";
|
|
4
|
+
/**
|
|
5
|
+
* Workaround for Claude Agent SDK MCP transport bug (#18260):
|
|
6
|
+
* Array/object parameters are sometimes serialized as JSON strings
|
|
7
|
+
* instead of native values. This preprocessor coerces them back.
|
|
8
|
+
*/
|
|
9
|
+
function coerceJson(schema) {
|
|
10
|
+
return z.preprocess((val) => {
|
|
11
|
+
if (typeof val === "string") {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(val);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return val;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return val;
|
|
20
|
+
}, schema);
|
|
21
|
+
}
|
|
4
22
|
export function registerResearchTools(server) {
|
|
5
23
|
server.addTool({
|
|
6
24
|
name: "search_web",
|
|
@@ -70,7 +88,7 @@ export function registerResearchTools(server) {
|
|
|
70
88
|
"- For the infobox hero image, set `infobox.header.image_url` in frontmatter instead\n\n" +
|
|
71
89
|
"Requires login. Rate limit: 10/min.",
|
|
72
90
|
parameters: z.object({
|
|
73
|
-
queries: z.array(z.string()).min(1).max(10).describe("Image search queries (1-10)"),
|
|
91
|
+
queries: coerceJson(z.array(z.string()).min(1).max(10)).describe("Image search queries (1-10)"),
|
|
74
92
|
source: z.enum(["wikimedia", "google"]).default("wikimedia").describe("Image source: 'wikimedia' (free, open-licensed — preferred) or 'google' (broader coverage)"),
|
|
75
93
|
limit: z.number().default(5).describe("Max results per query (1-10, default 5)"),
|
|
76
94
|
}),
|
|
@@ -87,7 +105,7 @@ export function registerResearchTools(server) {
|
|
|
87
105
|
description: "View images to verify they're suitable. Use after search_images to inspect candidates " +
|
|
88
106
|
"before including them. Returns each image so you can see what it shows and write accurate captions.",
|
|
89
107
|
parameters: z.object({
|
|
90
|
-
urls: z.array(z.string().url()).min(1).max(10).describe("Image URLs to view (1-10)"),
|
|
108
|
+
urls: coerceJson(z.array(z.string().url()).min(1).max(10)).describe("Image URLs to view (1-10)"),
|
|
91
109
|
}),
|
|
92
110
|
async execute({ urls }) {
|
|
93
111
|
const results = await Promise.allSettled(urls.map(async (url) => {
|
|
@@ -118,11 +136,11 @@ export function registerResearchTools(server) {
|
|
|
118
136
|
"Each source becomes a clickable citation bubble when you use [@key] markers in your text. " +
|
|
119
137
|
"Collect sources from your read_webpage calls and any subagent results, then register them all in one call.",
|
|
120
138
|
parameters: z.object({
|
|
121
|
-
sources: z.array(z.object({
|
|
139
|
+
sources: coerceJson(z.array(z.object({
|
|
122
140
|
key: z.string().describe("Citation key — kebab-case, BibTeX-style: {domain}-{title-words} (e.g. 'nytimes-climate-report')"),
|
|
123
141
|
url: z.string().describe("Source URL"),
|
|
124
142
|
title: z.string().describe("Source title — include publication name after em dash (e.g. 'Climate Report — The New York Times')"),
|
|
125
|
-
})).min(1).describe("Sources to register for citation"),
|
|
143
|
+
})).min(1)).describe("Sources to register for citation"),
|
|
126
144
|
}),
|
|
127
145
|
async execute({ sources }) {
|
|
128
146
|
return `Registered ${sources.length} source${sources.length === 1 ? "" : "s"}. Use [@key] markers in your response to cite them.`;
|
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)) {
|