openalmanac 0.2.5 → 0.2.8

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/auth.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- declare const API_BASE = "https://api.openalmanac.org";
1
+ declare const API_BASE: string;
2
2
  declare const API_KEY_PATH: string;
3
3
  declare const ARTICLES_DIR: string;
4
4
  export { API_BASE, API_KEY_PATH, ARTICLES_DIR };
@@ -6,6 +6,13 @@ export declare function getApiKey(): string | null;
6
6
  export declare function requireApiKey(): string;
7
7
  export declare function saveApiKey(key: string): void;
8
8
  export declare function removeApiKey(): boolean;
9
+ export type AuthStatus = {
10
+ loggedIn: true;
11
+ name: string;
12
+ } | {
13
+ loggedIn: false;
14
+ };
15
+ export declare function getAuthStatus(): Promise<AuthStatus>;
9
16
  export declare function buildAuthHeaders(): Record<string, string>;
10
17
  export declare function request(method: string, path: string, options?: {
11
18
  auth?: boolean;
package/dist/auth.js CHANGED
@@ -2,7 +2,7 @@ import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync } from "
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { chmodSync } from "node:fs";
5
- const API_BASE = "https://api.openalmanac.org";
5
+ const API_BASE = process.env.OPENALMANAC_API_BASE || "https://openalmanac.org/api/proxy";
6
6
  const API_KEY_DIR = join(homedir(), ".openalmanac");
7
7
  const API_KEY_PATH = join(API_KEY_DIR, "api_key");
8
8
  const ARTICLES_DIR = join(homedir(), ".openalmanac", "articles");
@@ -37,6 +37,25 @@ export function removeApiKey() {
37
37
  }
38
38
  return false;
39
39
  }
40
+ export async function getAuthStatus() {
41
+ const key = getApiKey();
42
+ if (!key)
43
+ return { loggedIn: false };
44
+ try {
45
+ const resp = await fetch(`${API_BASE}/api/agents/me`, {
46
+ headers: { Authorization: `Bearer ${key}` },
47
+ signal: AbortSignal.timeout(10_000),
48
+ });
49
+ if (resp.ok) {
50
+ const data = (await resp.json());
51
+ return { loggedIn: true, name: data.name ?? "unknown" };
52
+ }
53
+ }
54
+ catch {
55
+ // Key invalid or network error
56
+ }
57
+ return { loggedIn: false };
58
+ }
40
59
  export function buildAuthHeaders() {
41
60
  return { Authorization: `Bearer ${requireApiKey()}` };
42
61
  }
package/dist/server.js CHANGED
@@ -2,7 +2,15 @@ import { FastMCP } from "fastmcp";
2
2
  import { registerAuthTools } from "./tools/auth.js";
3
3
  import { registerArticleTools } from "./tools/articles.js";
4
4
  import { registerResearchTools } from "./tools/research.js";
5
+ import { registerCommunityTools } from "./tools/communities.js";
6
+ import { getApiKey } from "./auth.js";
7
+ import { openBrowser } from "./browser.js";
8
+ const NEXT_STEPS_URL = "https://www.openalmanac.org/contribute/next-steps";
5
9
  export function createServer() {
10
+ // On first run (no API key), open the next-steps page so the user knows what to do
11
+ if (!getApiKey()) {
12
+ openBrowser(NEXT_STEPS_URL);
13
+ }
6
14
  const server = new FastMCP({
7
15
  name: "OpenAlmanac",
8
16
  version: "0.1.0",
@@ -18,11 +26,16 @@ export function createServer() {
18
26
  "formulaic conclusions, etc.). Every sentence should contain a specific fact the reader didn't know.\n\n" +
19
27
  "After creating an article, always share the exact URL from the push response with the user. " +
20
28
  "This URL includes a celebration page for the newly created article.\n\n" +
29
+ "After pushing an article, call search_communities to find relevant communities. " +
30
+ "Analyze the article content against community names and descriptions, then suggest " +
31
+ "communities to the user. If the user confirms or skips, call link_article with the " +
32
+ "suggested community slugs. If the user picks specific ones, use those instead.\n\n" +
21
33
  "When working with tool results, write down any important information you might need later " +
22
34
  "in your response, as the original tool result may be cleared later.",
23
35
  });
24
36
  registerAuthTools(server);
25
37
  registerArticleTools(server);
26
38
  registerResearchTools(server);
39
+ registerCommunityTools(server);
27
40
  return server;
28
41
  }
@@ -2,8 +2,9 @@ import { z } from "zod";
2
2
  import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync } 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
+ import { openBrowser } from "../browser.js";
7
8
  const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
8
9
  const WRITING_GUIDE = `
9
10
  ## Article structure
@@ -15,6 +16,7 @@ title: Article Title
15
16
  sources:
16
17
  - url: https://example.com
17
18
  title: Source Title
19
+ accessed_date: "2025-01-15"
18
20
  infobox:
19
21
  header:
20
22
  image_url: https://... # optional hero image
@@ -25,34 +27,40 @@ infobox:
25
27
  - key: Occupation
26
28
  value: Scientist
27
29
  links:
28
- - label: Official site
29
- url: https://...
30
+ - https://example.com
30
31
  sections:
31
32
  - type: timeline # chronological events
32
33
  title: Career Timeline
33
34
  items:
34
- - primary: "2010"
35
- value: Started company
36
- - type: list # bullet list
35
+ - primary: "Started company"
36
+ period: "2010"
37
+ location: "San Francisco"
38
+ - type: list # key figures, works, features
37
39
  title: Known For
38
40
  items:
39
- - value: First achievement
40
- - value: Second achievement
41
+ - title: First achievement
42
+ - title: Second achievement
43
+ subtitle: Additional detail
41
44
  - type: tags # inline tags/chips
42
45
  title: Genres
43
46
  items:
44
- - value: Rock
45
- - value: Jazz
47
+ - Rock
48
+ - Jazz
46
49
  - type: grid # image grid
47
50
  title: Gallery
48
51
  items:
49
- - image_url: https://...
50
- value: Caption
51
- - type: table # rows with label+value
52
+ - title: Caption
53
+ image_url: https://...
54
+ - type: table # structured comparison
52
55
  title: Statistics
53
56
  items:
54
- - label: Height
55
- value: "6'2\\""
57
+ headers:
58
+ - Name
59
+ - Value
60
+ rows:
61
+ - cells:
62
+ - Height
63
+ - "6'2\\""
56
64
  - type: key_value # simple key-value pairs
57
65
  title: Quick Facts
58
66
  items:
@@ -189,18 +197,23 @@ export function registerArticleTools(server) {
189
197
  });
190
198
  const data = (await resp.json());
191
199
  const articleUrl = `https://www.openalmanac.org/article/${slug}?celebrate=true`;
192
- return `Pushed successfully.\n${articleUrl}\n${JSON.stringify(data, null, 2)}`;
200
+ openBrowser(articleUrl);
201
+ return `Pushed successfully.\n\nArticle URL (share this exact link with the user): ${articleUrl}\n\n${JSON.stringify(data, null, 2)}`;
193
202
  },
194
203
  });
195
204
  server.addTool({
196
205
  name: "status",
197
- description: "List all article files in your local working directory (~/.openalmanac/articles/). " +
198
- "Shows filename, title, file size, and last modified time. No API calls.",
206
+ description: "Show login status and list all article files in your local working directory (~/.openalmanac/articles/). " +
207
+ "Shows auth state, filename, title, file size, and last modified time.",
199
208
  async execute() {
200
209
  ensureArticlesDir();
210
+ const auth = await getAuthStatus();
211
+ const authLine = auth.loggedIn
212
+ ? `Logged in as ${auth.name}.`
213
+ : "Not logged in. Use login to authenticate.";
201
214
  const files = readdirSync(ARTICLES_DIR).filter((f) => f.endsWith(".md"));
202
215
  if (files.length === 0) {
203
- return `No articles in ${ARTICLES_DIR}\nUse pull to download an article or new to create one.`;
216
+ return `${authLine}\n\nLocal articles (0 files in ${ARTICLES_DIR}):\n (none use pull or new to get started)`;
204
217
  }
205
218
  const rows = [];
206
219
  for (const file of files) {
@@ -215,7 +228,7 @@ export function registerArticleTools(server) {
215
228
  const modified = stat.mtime.toISOString().slice(0, 16).replace("T", " ");
216
229
  rows.push(` ${file} — "${title}" (${size}, modified ${modified})`);
217
230
  }
218
- return `${files.length} article(s) in ${ARTICLES_DIR}:\n${rows.join("\n")}`;
231
+ return `${authLine}\n\nLocal articles (${files.length} file(s) in ${ARTICLES_DIR}):\n${rows.join("\n")}`;
219
232
  },
220
233
  });
221
234
  }
@@ -0,0 +1,2 @@
1
+ import { FastMCP } from "fastmcp";
2
+ export declare function registerCommunityTools(server: FastMCP): void;
@@ -0,0 +1,127 @@
1
+ import { z } from "zod";
2
+ import { request } from "../auth.js";
3
+ export function registerCommunityTools(server) {
4
+ server.addTool({
5
+ name: "search_communities",
6
+ description: "Search or list OpenAlmanac communities. Returns community names, descriptions, and member counts. " +
7
+ "Use this after pushing an article to find relevant communities for auto-linking. No authentication needed.",
8
+ parameters: z.object({
9
+ query: z
10
+ .string()
11
+ .optional()
12
+ .describe("Search term (case-insensitive match on name, slug, or description). Omit to list all."),
13
+ sort: z
14
+ .enum(["popular", "newest"])
15
+ .default("popular")
16
+ .describe("Sort order (default: popular)"),
17
+ limit: z
18
+ .number()
19
+ .min(1)
20
+ .max(100)
21
+ .default(20)
22
+ .describe("Max results (1-100, default 20)"),
23
+ }),
24
+ async execute({ query, sort, limit }) {
25
+ const params = { sort, limit };
26
+ if (query)
27
+ params.query = query;
28
+ const resp = await request("GET", "/api/communities", { params });
29
+ const data = (await resp.json());
30
+ const communities = data.communities.map((c) => ({
31
+ slug: c.slug,
32
+ name: c.name,
33
+ description: c.description,
34
+ member_count: c.member_count,
35
+ created_at: c.created_at,
36
+ }));
37
+ return `Found ${data.total} communities:\n\n${JSON.stringify(communities, null, 2)}`;
38
+ },
39
+ });
40
+ server.addTool({
41
+ name: "create_community",
42
+ description: "Create a new OpenAlmanac community. Requires login and at least 1 published article. " +
43
+ "Communities are spaces where articles can be curated and discussed around a topic.",
44
+ parameters: z.object({
45
+ name: z.string().min(1).max(100).describe("Community name (1-100 chars)"),
46
+ slug: z
47
+ .string()
48
+ .min(1)
49
+ .max(100)
50
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
51
+ .describe("Unique kebab-case identifier (e.g. 'machine-learning')"),
52
+ description: z
53
+ .string()
54
+ .min(1)
55
+ .max(2000)
56
+ .describe("What the community is about (1-2000 chars)"),
57
+ }),
58
+ async execute({ name, slug, description }) {
59
+ const resp = await request("POST", "/api/communities", {
60
+ auth: true,
61
+ json: { name, slug, description },
62
+ });
63
+ const data = (await resp.json());
64
+ const communityUrl = `https://www.openalmanac.org/communities/${slug}`;
65
+ return `Community created!\n\nURL: ${communityUrl}\n\n${JSON.stringify(data, null, 2)}`;
66
+ },
67
+ });
68
+ server.addTool({
69
+ name: "create_post",
70
+ description: "Create a post in an OpenAlmanac community. Requires login and community membership. " +
71
+ "If you get a 403 error, you need to join the community first.",
72
+ parameters: z.object({
73
+ community_slug: z.string().describe("Community slug (e.g. 'machine-learning')"),
74
+ title: z.string().min(1).max(300).describe("Post title (1-300 chars)"),
75
+ body: z.string().max(10000).default("").describe("Post body (max 10000 chars)"),
76
+ flair: z
77
+ .enum(["discussion", "article-request", "question", "announcement"])
78
+ .optional()
79
+ .describe("Post flair/category"),
80
+ }),
81
+ async execute({ community_slug, title, body, flair }) {
82
+ const json = { title, body };
83
+ if (flair)
84
+ json.flair = flair;
85
+ const resp = await request("POST", `/api/communities/${community_slug}/posts`, {
86
+ auth: true,
87
+ json,
88
+ });
89
+ const data = (await resp.json());
90
+ const postUrl = `https://www.openalmanac.org/communities/${community_slug}/post/${data.id}`;
91
+ return `Post created!\n\nURL: ${postUrl}\n\n${JSON.stringify(data, null, 2)}`;
92
+ },
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
+ });
127
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openalmanac",
3
- "version": "0.2.5",
3
+ "version": "0.2.8",
4
4
  "description": "OpenAlmanac — pull, edit, and push articles to the open knowledge base",
5
5
  "type": "module",
6
6
  "bin": {