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.
- package/dist/tools/articles.js +186 -296
- package/dist/tools/communities.js +36 -10
- package/dist/tools/research.js +2 -1
- package/package.json +1 -1
package/dist/tools/articles.js
CHANGED
|
@@ -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:
|
|
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
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
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:
|
|
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
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
250
|
-
"
|
|
251
|
-
"
|
|
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
|
-
|
|
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({
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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: "
|
|
301
|
-
"
|
|
302
|
-
"
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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({
|
|
312
|
-
if (
|
|
313
|
-
throw new Error(`Invalid
|
|
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
|
-
|
|
317
|
-
if (
|
|
318
|
-
|
|
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
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
|
360
|
-
"
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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({
|
|
370
|
-
if (!
|
|
371
|
-
throw new Error("
|
|
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
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
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: "
|
|
463
|
-
description: "
|
|
464
|
-
"Use this to
|
|
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
|
-
|
|
467
|
-
|
|
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({
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/tools/research.js
CHANGED
|
@@ -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
|
|
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()
|