openalmanac 0.2.25 → 0.2.26

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/server.js CHANGED
@@ -38,6 +38,18 @@ export function createServer() {
38
38
  "",
39
39
  "Keep researching throughout. Do not answer from memory when you could search and give a better answer. The user will notice when you stop looking things up.",
40
40
  "",
41
+ "## Enriching your responses",
42
+ "",
43
+ "Your answers should feel like living knowledge — with linked entities and images, not plain text walls.",
44
+ "",
45
+ "**Entity links:** Before writing your response, call `search_articles` with the key entity names you plan to mention (e.g. `queries: [\"Theravada Buddhism\", \"Thailand\", \"Angkor Wat\"]`). This returns which articles/stubs exist and their slugs. Then use `[[slug|Display Text]]` wikilink syntax in your response for entities that exist. These render as orange links the user can click to navigate. Only link entities that actually exist in the knowledge base — don't guess slugs.",
46
+ "",
47
+ "**Images:** Use `search_images` to find 1-2 relevant images for your response. Include them using the figure syntax: `![Descriptive caption](image_url \"position\")` where position is `right`, `left`, or `center`. Always write a descriptive caption. Use `view_images` to verify candidates before including them.",
48
+ "",
49
+ "**Keep it efficient:** Batch your entity and image searches into single tool calls (both accept arrays). One `search_articles` call with 5-10 entity names and one `search_images` call is typical — don't make separate calls for each entity.",
50
+ "",
51
+ "**When to skip enrichment:** For short clarifying responses, follow-up questions, or casual conversation, don't search for entities or images. Enrich substantive, informational responses only.",
52
+ "",
41
53
  "### Example — bad (performing enthusiasm):",
42
54
  '> "Did you know that Bangkok\'s full name is the longest city name in the world? It\'s fascinating — here are some key findings I\'ve identified about Thailand\'s Hindu influence..."',
43
55
  "",
@@ -63,7 +75,7 @@ export function createServer() {
63
75
  "",
64
76
  "**User has no topic** → Talk to them. What are they into — a movie they just watched, a hobby, something from work, a place they visited, a news story that caught their eye? Once you have a thread, research it and come back with real information. You can also use requested_articles to find stubs with high demand (many articles link to them), research a few, and share what you find.",
65
77
  "",
66
- "**User wants to edit an existing article** → Pull it and read it. Look for what's *interesting but underdeveloped* — a one-sentence mention of a controversy probably has a whole story behind it. Share what you find and propose going deeper.",
78
+ "**User wants to edit an existing article** → Download it and read it. Look for what's *interesting but underdeveloped* — a one-sentence mention of a controversy probably has a whole story behind it. Share what you find and propose going deeper.",
67
79
  "",
68
80
  '**Articles emerge from research naturally.** As you research and talk, specific subjects will come into focus — a person with a fascinating story, a place with layers of history, a concept that deserves its own explanation. When you notice one of these has enough depth, say so: "the Erawan Shrine could be its own article" or "Wirathu is worth writing up." A single research conversation might produce one article or several. The conversation itself can go anywhere — opinions, tangents, speculation are all fine while talking. The articles that come out of it are encyclopedic: neutral, factual, sourced.',
69
81
  "",
@@ -99,7 +111,7 @@ export function createServer() {
99
111
  "",
100
112
  "6. **Integrate** — Present the review and fact-check feedback to the user. Then fix everything in one pass: review issues, fact-check corrections, add images, add wikilinks.",
101
113
  "",
102
- "7. **Push** — Validate and publish. Share the exact URL from the push response (it includes a celebration page). Then search_communities for relevant communities and suggest linking.",
114
+ "7. **Publish** — Validate and publish. Share the exact URL from the publish response (it includes a celebration page). Then search_communities for relevant communities and suggest linking.",
103
115
  "",
104
116
  "Why this order: the draft must be finished before subagents run. The linking agent needs to see what entities are actually in the text. The image agent needs to match images to specific content. The review agent needs the complete article. Everything reads the draft.",
105
117
  "",
@@ -107,7 +119,7 @@ export function createServer() {
107
119
  "",
108
120
  "Reading and searching articles is open. Writing requires an API key (from login). Login registers an agent linked to your human user, so contributions are attributed to both.",
109
121
  "",
110
- "Core flow: login (once) → search_articles (check if exists) → search_web + read_webpage (research) → new (scaffold) or pull (download existing) → edit ~/.openalmanac/articles/{slug}.md → push (validate & publish).",
122
+ "Core flow: login (once) → search_articles (check if exists) → search_web + read_webpage (research) → new (scaffold) or download (existing) → edit ~/.openalmanac/articles/{slug}.md → publish (validate & publish).",
111
123
  "",
112
124
  "After publishing, share the celebration URL. Then call search_communities, suggest relevant ones, and link_article if the user confirms.",
113
125
  "",
package/dist/setup.js CHANGED
@@ -6,10 +6,11 @@ import { getAuthStatus } from "./auth.js";
6
6
  const TOOL_GROUPS = [
7
7
  {
8
8
  name: "Search & Read",
9
- description: "search articles, pull, view status",
9
+ description: "search articles, download, view status",
10
10
  tools: [
11
11
  "mcp__almanac__search_articles",
12
- "mcp__almanac__pull",
12
+ "mcp__almanac__download",
13
+ "mcp__almanac__read",
13
14
  "mcp__almanac__status",
14
15
  "mcp__almanac__requested_articles",
15
16
  ],
@@ -21,16 +22,16 @@ const TOOL_GROUPS = [
21
22
  "mcp__almanac__search_web",
22
23
  "mcp__almanac__read_webpage",
23
24
  "mcp__almanac__search_images",
24
- "mcp__almanac__view_image",
25
+ "mcp__almanac__view_images",
25
26
  ],
26
27
  },
27
28
  {
28
29
  name: "Write & Publish",
29
- description: "create articles, push edits, stubs",
30
+ description: "create articles, publish edits, stubs",
30
31
  tools: [
31
32
  "mcp__almanac__new",
32
- "mcp__almanac__push",
33
- "mcp__almanac__create_stub",
33
+ "mcp__almanac__publish",
34
+ "mcp__almanac__create_stubs",
34
35
  ],
35
36
  },
36
37
  {
@@ -106,7 +106,7 @@ The early life of Alan Turing began...
106
106
  **Placement:** 1-3 images per major section, spread throughout. First image near the top.
107
107
  For the infobox hero image, use \`infobox.header.image_url\` in frontmatter instead.
108
108
 
109
- External image URLs are auto-persisted on push — no extra steps needed.
109
+ External image URLs are auto-persisted on publish — no extra steps needed.
110
110
 
111
111
  ## Writing quality
112
112
 
@@ -124,84 +124,81 @@ function ensureArticlesDir() {
124
124
  export function registerArticleTools(server) {
125
125
  server.addTool({
126
126
  name: "search_articles",
127
- description: "Search existing OpenAlmanac articles and stubs. Use this to check if an article or stub already exists before creating one. " +
128
- "Results include a 'stub' field (true/false) and 'entity_type' field. No authentication needed.",
127
+ description: "Search existing OpenAlmanac articles and stubs. Accepts multiple queries for batch lookup. " +
128
+ "Use this to check if articles or stubs exist before creating them, or to find entity slugs for wikilinks. " +
129
+ "Results include 'stub' field (true/false) and 'entity_type' field. No authentication needed.",
129
130
  parameters: z.object({
130
- query: z.string().describe("Search terms"),
131
- limit: z.number().default(10).describe("Max results (1-100, default 10)"),
132
- page: z.number().default(1).describe("Page number, 1-indexed (default 1)"),
131
+ queries: z.array(z.string()).min(1).max(20).describe("Search queries (1-20)"),
132
+ limit: z.number().default(5).describe("Max results per query (1-50, default 5)"),
133
133
  include_stubs: z.boolean().default(true).describe("Include stub articles in results (default true)"),
134
134
  }),
135
- async execute({ query, limit, page, include_stubs }) {
136
- const params = { query, limit, page };
137
- if (!include_stubs)
138
- params.include_stubs = "false";
139
- const resp = await request("GET", "/api/search", { params });
135
+ async execute({ queries, limit, include_stubs }) {
136
+ const resp = await request("POST", "/api/search/batch", {
137
+ json: { queries, limit, include_stubs },
138
+ });
140
139
  return JSON.stringify(await resp.json(), null, 2);
141
140
  },
142
141
  });
143
142
  server.addTool({
144
- name: "create_stub",
145
- description: "Create a stub article a placeholder for an entity that doesn't have a full article yet. " +
146
- "Use this for every entity (person, organization, topic, etc.) you mention in an article. " +
147
- "Stubs should include a meaningful summary and headline. " +
148
- "Idempotent: if the slug already exists, returns the existing article/stub. Requires login.",
143
+ name: "read",
144
+ description: "Read article content from OpenAlmanac. Returns the content, sources, and metadata for each slug. " +
145
+ "Use this to reference or summarize existing articles in conversation. " +
146
+ "For editing articles locally, use 'download' instead. No authentication needed.",
149
147
  parameters: z.object({
150
- slug: z
151
- .string()
152
- .min(1)
153
- .max(500)
154
- .describe("Unique kebab-case identifier. For people with LinkedIn: use their vanity ID (e.g. 'john-smith-4a8b2c1'). " +
155
- "For others: descriptive kebab-case (e.g. 'reinforcement-learning', 'openai')"),
156
- title: z.string().describe("Display title (e.g. 'John Smith', 'Reinforcement Learning')"),
157
- entity_type: z
158
- .enum(["person", "organization", "topic", "event", "creative_work", "place"])
159
- .optional()
160
- .describe("Entity type: 'person' for individuals, 'organization' for companies/institutions/nonprofits, " +
161
- "'topic' for concepts/fields/technologies, 'event' for conferences/historical events, " +
162
- "'creative_work' for books/papers/films/software, 'place' for cities/countries/landmarks"),
163
- headline: z
164
- .string()
165
- .optional()
166
- .describe("Short headline (e.g. 'Professor of CS at MIT', 'AI research laboratory')"),
167
- image_url: z.string().optional().describe("Image URL for the entity"),
168
- summary: z
169
- .string()
170
- .optional()
171
- .describe("2-4 sentence summary of the entity. This becomes the stub page content. " +
172
- "Be informative — include key facts, dates, and context."),
148
+ slugs: z.array(z.string()).min(1).max(20).describe("Article slugs to read (1-20)"),
173
149
  }),
174
- async execute({ slug, title, entity_type, headline, image_url, summary }) {
175
- if (!SLUG_RE.test(slug)) {
176
- throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$`);
177
- }
178
- const json = { slug, title };
179
- if (entity_type)
180
- json.entity_type = entity_type;
181
- if (headline)
182
- json.headline = headline;
183
- if (image_url)
184
- json.image_url = image_url;
185
- if (summary)
186
- json.summary = summary;
187
- const resp = await request("POST", "/api/articles/stub", {
150
+ async execute({ slugs }) {
151
+ const resp = await request("POST", "/api/articles/batch", {
152
+ json: { slugs },
153
+ });
154
+ return JSON.stringify(await resp.json(), null, 2);
155
+ },
156
+ });
157
+ server.addTool({
158
+ name: "create_stubs",
159
+ description: "Create stub articles — placeholders for entities that don't have full articles yet. " +
160
+ "Use this for every entity (person, organization, topic, etc.) mentioned in an article. " +
161
+ "Idempotent: existing slugs return their current status. Requires login.",
162
+ parameters: z.object({
163
+ stubs: z.array(z.object({
164
+ slug: z
165
+ .string()
166
+ .min(1)
167
+ .max(500)
168
+ .describe("Unique kebab-case identifier. For people with LinkedIn: use their vanity ID (e.g. 'john-smith-4a8b2c1'). " +
169
+ "For others: descriptive kebab-case (e.g. 'reinforcement-learning', 'openai')"),
170
+ title: z.string().describe("Display title (e.g. 'John Smith', 'Reinforcement Learning')"),
171
+ entity_type: z
172
+ .enum(["person", "organization", "topic", "event", "creative_work", "place"])
173
+ .optional()
174
+ .describe("Entity type: 'person' for individuals, 'organization' for companies/institutions/nonprofits, " +
175
+ "'topic' for concepts/fields/technologies, 'event' for conferences/historical events, " +
176
+ "'creative_work' for books/papers/films/software, 'place' for cities/countries/landmarks"),
177
+ headline: z
178
+ .string()
179
+ .optional()
180
+ .describe("Short headline (e.g. 'Professor of CS at MIT', 'AI research laboratory')"),
181
+ image_url: z.string().url().optional().describe("Image URL for the entity"),
182
+ summary: z
183
+ .string()
184
+ .optional()
185
+ .describe("2-4 sentence summary of the entity. This becomes the stub page content. " +
186
+ "Be informative — include key facts, dates, and context."),
187
+ })).min(1).max(50).describe("Stubs to create (1-50)"),
188
+ }),
189
+ async execute({ stubs }) {
190
+ const resp = await request("POST", "/api/articles/stubs", {
188
191
  auth: true,
189
- json,
192
+ json: { stubs },
190
193
  });
191
- const data = (await resp.json());
192
- const statusMsg = data.status === "created"
193
- ? "Stub created."
194
- : data.status === "stub_exists"
195
- ? "Stub already exists."
196
- : "Full article already exists.";
197
- return `${statusMsg} Slug: ${slug}\nUse [[${slug}|${title}]] to link to this entity.\n\n${JSON.stringify(data, null, 2)}`;
194
+ return JSON.stringify(await resp.json(), null, 2);
198
195
  },
199
196
  });
200
197
  server.addTool({
201
- name: "pull",
202
- description: "Download an article from OpenAlmanac to your local working directory (~/.openalmanac/articles/). " +
203
- "The file is saved as {slug}.md with YAML frontmatter. Returns a writing guide covering article structure, infobox format, citations, and quality rules. " +
204
- "Edit the file locally, then use push to publish changes.",
198
+ name: "download",
199
+ description: "Download an article to your local workspace for editing. The file is saved to ~/.openalmanac/articles/{slug}.md " +
200
+ "with YAML frontmatter. Returns a writing guide covering article structure, infobox format, citations, and quality rules. " +
201
+ "After editing, use 'publish' to push your changes live.",
205
202
  parameters: z.object({
206
203
  slug: z.string().describe("Article slug (e.g. 'machine-learning')"),
207
204
  }),
@@ -228,7 +225,7 @@ export function registerArticleTools(server) {
228
225
  name: "new",
229
226
  description: "Create a new article scaffold in your local working directory (~/.openalmanac/articles/). " +
230
227
  "The file is created with YAML frontmatter and an empty body. Returns a writing guide covering article structure, infobox format, citations, and quality rules. " +
231
- "Edit the file to add content and sources, then use push to publish.",
228
+ "Edit the file to add content and sources, then use publish to go live.",
232
229
  parameters: z.object({
233
230
  slug: z
234
231
  .string()
@@ -242,7 +239,7 @@ export function registerArticleTools(server) {
242
239
  ensureArticlesDir();
243
240
  const filePath = join(ARTICLES_DIR, `${slug}.md`);
244
241
  if (existsSync(filePath)) {
245
- throw new Error(`File already exists: ${filePath}\nUse pull to refresh it, or push to publish changes.`);
242
+ throw new Error(`File already exists: ${filePath}\nUse download to refresh it, or publish to push changes.`);
246
243
  }
247
244
  const frontmatter = yamlStringify({ article_id: slug, title, sources: [] });
248
245
  const scaffold = `---\n${frontmatter}---\n\n`;
@@ -251,9 +248,9 @@ export function registerArticleTools(server) {
251
248
  },
252
249
  });
253
250
  server.addTool({
254
- name: "push",
255
- description: "Validate and publish a local article to OpenAlmanac. Reads the file from ~/.openalmanac/articles/{slug}.md, " +
256
- "validates locally (frontmatter, citations, sources), then pushes via the API. Requires login.",
251
+ name: "publish",
252
+ description: "Validate and publish an article from your local workspace. Reads ~/.openalmanac/articles/{slug}.md, " +
253
+ "validates content and sources, and publishes to OpenAlmanac. Requires login.",
257
254
  parameters: z.object({
258
255
  slug: z.string().describe("Article slug matching the filename (without .md)"),
259
256
  change_title: z.string().optional().describe("Short title for the change (e.g. 'Added early life section')"),
@@ -266,7 +263,7 @@ export function registerArticleTools(server) {
266
263
  raw = readFileSync(filePath, "utf-8");
267
264
  }
268
265
  catch {
269
- throw new Error(`File not found: ${filePath}\nUse pull to download an existing article or new to create a scaffold.`);
266
+ throw new Error(`File not found: ${filePath}\nUse download to get an existing article or new to create a scaffold.`);
270
267
  }
271
268
  // Local validation
272
269
  const errors = validateArticle(raw);
@@ -323,7 +320,7 @@ export function registerArticleTools(server) {
323
320
  : "Not logged in. Use login to authenticate.";
324
321
  const files = readdirSync(ARTICLES_DIR).filter((f) => f.endsWith(".md"));
325
322
  if (files.length === 0) {
326
- return `${authLine}\n\nLocal articles (0 files in ${ARTICLES_DIR}):\n (none — use pull or new to get started)`;
323
+ return `${authLine}\n\nLocal articles (0 files in ${ARTICLES_DIR}):\n (none — use download or new to get started)`;
327
324
  }
328
325
  const rows = [];
329
326
  for (const file of files) {
@@ -43,10 +43,10 @@ export function registerResearchTools(server) {
43
43
  });
44
44
  server.addTool({
45
45
  name: "search_images",
46
- description: "Search for images to include in articles. Returns image URLs, titles, dimensions, and licensing info. " +
46
+ description: "Search for images to include in articles. Accepts multiple queries for batch lookup. Returns image URLs, titles, dimensions, and licensing info. " +
47
47
  "Two sources: 'wikimedia' (free, open-licensed images from Wikimedia Commons — preferred) and 'google' (broader web images via Google). " +
48
- "Use descriptive search terms. After searching, call view_image on promising candidates to see what they actually show before using them. " +
49
- "External image URLs are automatically persisted when you push the article — no extra steps needed.\n\n" +
48
+ "Use descriptive search terms. After searching, call view_images on promising candidates to see what they actually show before using them. " +
49
+ "External image URLs are automatically persisted when you publish the article — no extra steps needed.\n\n" +
50
50
  "## Using images in articles\n\n" +
51
51
  "Images render as figures with visible captions. The alt text becomes the caption — make it descriptive.\n\n" +
52
52
  "**Syntax:** `![Caption text](url \"position\")`\n\n" +
@@ -70,37 +70,46 @@ export function registerResearchTools(server) {
70
70
  "- For the infobox hero image, set `infobox.header.image_url` in frontmatter instead\n\n" +
71
71
  "Requires login. Rate limit: 10/min.",
72
72
  parameters: z.object({
73
- query: z.string().describe("Descriptive search terms for the image (e.g. 'Apollo 11 moon landing photograph')"),
73
+ queries: z.array(z.string()).min(1).max(10).describe("Image search queries (1-10)"),
74
74
  source: z.enum(["wikimedia", "google"]).default("wikimedia").describe("Image source: 'wikimedia' (free, open-licensed — preferred) or 'google' (broader coverage)"),
75
- limit: z.number().default(10).describe("Max results (1-30, default 10)"),
75
+ limit: z.number().default(5).describe("Max results per query (1-10, default 5)"),
76
76
  }),
77
- async execute({ query, source, limit }) {
78
- const resp = await request("GET", "/api/research/images", {
77
+ async execute({ queries, source, limit }) {
78
+ const resp = await request("POST", "/api/research/images/batch", {
79
79
  auth: true,
80
- params: { query, source, limit },
80
+ json: { queries, source, limit },
81
81
  });
82
82
  return JSON.stringify(await resp.json(), null, 2);
83
83
  },
84
84
  });
85
85
  server.addTool({
86
- name: "view_image",
87
- description: "View an image to verify it's suitable for an article. Use after search_images to inspect candidate images " +
88
- "before including them. Returns the image so you can see what it actually shows and write an accurate caption.",
86
+ name: "view_images",
87
+ description: "View images to verify they're suitable. Use after search_images to inspect candidates " +
88
+ "before including them. Returns each image so you can see what it shows and write accurate captions.",
89
89
  parameters: z.object({
90
- url: z.string().url().describe("Image URL to view (use image_url or thumbnail_url from search_images results)"),
90
+ urls: z.array(z.string().url()).min(1).max(10).describe("Image URLs to view (1-10)"),
91
91
  }),
92
- async execute({ url }) {
93
- try {
94
- return {
95
- content: [
96
- await imageContent({ url }),
97
- { type: "text", text: `Image URL: ${url}` },
98
- ],
99
- };
100
- }
101
- catch {
102
- return `Failed to fetch image from ${url}. The URL may be invalid, inaccessible, or not an image.`;
92
+ async execute({ urls }) {
93
+ const results = await Promise.allSettled(urls.map(async (url) => {
94
+ try {
95
+ return { url, image: await imageContent({ url }) };
96
+ }
97
+ catch {
98
+ return { url, error: `Failed to fetch image from ${url}` };
99
+ }
100
+ }));
101
+ const content = [];
102
+ for (const result of results) {
103
+ const val = result.status === "fulfilled" ? result.value : { url: "unknown", error: "Failed" };
104
+ if ("image" in val) {
105
+ content.push(val.image);
106
+ content.push({ type: "text", text: `Image URL: ${val.url}` });
107
+ }
108
+ else {
109
+ content.push({ type: "text", text: val.error });
110
+ }
103
111
  }
112
+ return { content };
104
113
  },
105
114
  });
106
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openalmanac",
3
- "version": "0.2.25",
3
+ "version": "0.2.26",
4
4
  "description": "OpenAlmanac — pull, edit, and push articles to the open knowledge base",
5
5
  "type": "module",
6
6
  "bin": {