openalmanac 0.1.0

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 ADDED
@@ -0,0 +1,13 @@
1
+ declare const API_BASE = "https://api.openalmanac.org";
2
+ declare const API_KEY_PATH: string;
3
+ export { API_BASE, API_KEY_PATH };
4
+ export declare function getApiKey(): string | null;
5
+ export declare function requireApiKey(): string;
6
+ export declare function saveApiKey(key: string): void;
7
+ export declare function removeApiKey(): boolean;
8
+ export declare function buildAuthHeaders(): Record<string, string>;
9
+ export declare function request(method: string, path: string, options?: {
10
+ auth?: boolean;
11
+ params?: Record<string, string | number>;
12
+ json?: unknown;
13
+ }): Promise<Response>;
package/dist/auth.js ADDED
@@ -0,0 +1,71 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { chmodSync } from "node:fs";
5
+ const API_BASE = "https://api.openalmanac.org";
6
+ const API_KEY_DIR = join(homedir(), ".openalmanac");
7
+ const API_KEY_PATH = join(API_KEY_DIR, "api_key");
8
+ export { API_BASE, API_KEY_PATH };
9
+ export function getApiKey() {
10
+ const envKey = process.env.OPENALMANAC_API_KEY;
11
+ if (envKey)
12
+ return envKey;
13
+ try {
14
+ return readFileSync(API_KEY_PATH, "utf-8").trim();
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ export function requireApiKey() {
21
+ const key = getApiKey();
22
+ if (!key) {
23
+ throw new Error("No API key found. Call login first or set the OPENALMANAC_API_KEY environment variable.");
24
+ }
25
+ return key;
26
+ }
27
+ export function saveApiKey(key) {
28
+ mkdirSync(API_KEY_DIR, { recursive: true, mode: 0o700 });
29
+ writeFileSync(API_KEY_PATH, key, { mode: 0o600 });
30
+ chmodSync(API_KEY_PATH, 0o600);
31
+ }
32
+ export function removeApiKey() {
33
+ if (existsSync(API_KEY_PATH)) {
34
+ unlinkSync(API_KEY_PATH);
35
+ return true;
36
+ }
37
+ return false;
38
+ }
39
+ export function buildAuthHeaders() {
40
+ return { Authorization: `Bearer ${requireApiKey()}` };
41
+ }
42
+ export async function request(method, path, options = {}) {
43
+ const { auth = false, params, json } = options;
44
+ let url = `${API_BASE}${path}`;
45
+ if (params) {
46
+ const searchParams = new URLSearchParams();
47
+ for (const [k, v] of Object.entries(params)) {
48
+ searchParams.set(k, String(v));
49
+ }
50
+ url += `?${searchParams.toString()}`;
51
+ }
52
+ const headers = {};
53
+ if (auth) {
54
+ Object.assign(headers, buildAuthHeaders());
55
+ }
56
+ const init = {
57
+ method,
58
+ headers,
59
+ signal: AbortSignal.timeout(30_000),
60
+ };
61
+ if (json !== undefined) {
62
+ headers["Content-Type"] = "application/json";
63
+ init.body = JSON.stringify(json);
64
+ }
65
+ const resp = await fetch(url, init);
66
+ if (!resp.ok) {
67
+ const text = await resp.text();
68
+ throw new Error(`${resp.status} ${resp.statusText}: ${text}`);
69
+ }
70
+ return resp;
71
+ }
@@ -0,0 +1 @@
1
+ export declare function openBrowser(url: string): boolean;
@@ -0,0 +1,16 @@
1
+ import { execSync } from "node:child_process";
2
+ import { platform } from "node:os";
3
+ export function openBrowser(url) {
4
+ const cmd = platform() === "darwin"
5
+ ? `open "${url}"`
6
+ : platform() === "win32"
7
+ ? `start "" "${url}"`
8
+ : `xdg-open "${url}"`;
9
+ try {
10
+ execSync(cmd, { stdio: "ignore" });
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ import { createServer } from "./server.js";
3
+ import { runLogin, runLogout } from "./login.js";
4
+ const command = process.argv[2];
5
+ if (command === "login") {
6
+ runLogin().catch((e) => {
7
+ console.error(e instanceof Error ? e.message : e);
8
+ process.exit(1);
9
+ });
10
+ }
11
+ else if (command === "logout") {
12
+ runLogout().catch((e) => {
13
+ console.error(e instanceof Error ? e.message : e);
14
+ process.exit(1);
15
+ });
16
+ }
17
+ else {
18
+ // Default: start MCP server in stdio mode
19
+ const server = createServer();
20
+ server.start({ transportType: "stdio" });
21
+ }
@@ -0,0 +1,12 @@
1
+ export type LoginResult = {
2
+ status: "already_logged_in";
3
+ name: string;
4
+ } | {
5
+ status: "logged_in";
6
+ browserOpened: boolean;
7
+ };
8
+ /**
9
+ * Core login flow shared by CLI and MCP tool.
10
+ * Checks for existing valid key, otherwise opens browser for auth.
11
+ */
12
+ export declare function performLogin(): Promise<LoginResult>;
@@ -0,0 +1,64 @@
1
+ import { createServer } from "node:http";
2
+ import { getApiKey, saveApiKey, API_BASE } from "./auth.js";
3
+ import { openBrowser } from "./browser.js";
4
+ const CONNECT_URL_BASE = "https://openalmanac.org/contribute/connect";
5
+ const LOGIN_TIMEOUT_MS = 120_000;
6
+ /**
7
+ * Core login flow shared by CLI and MCP tool.
8
+ * Checks for existing valid key, otherwise opens browser for auth.
9
+ */
10
+ export async function performLogin() {
11
+ const existingKey = getApiKey();
12
+ if (existingKey) {
13
+ try {
14
+ const resp = await fetch(`${API_BASE}/api/agents/me`, {
15
+ headers: { Authorization: `Bearer ${existingKey}` },
16
+ signal: AbortSignal.timeout(10_000),
17
+ });
18
+ if (resp.ok) {
19
+ const data = (await resp.json());
20
+ return { status: "already_logged_in", name: data.name ?? "unknown" };
21
+ }
22
+ }
23
+ catch {
24
+ // Key invalid or network error, continue to login
25
+ }
26
+ }
27
+ const { token, browserOpened } = await new Promise((resolve, reject) => {
28
+ let opened = false;
29
+ const httpServer = createServer((req, res) => {
30
+ const url = new URL(req.url ?? "/", "http://localhost");
31
+ if (url.pathname !== "/callback") {
32
+ res.writeHead(404, { "Content-Type": "text/html" });
33
+ res.end("<h1>Not found</h1>");
34
+ return;
35
+ }
36
+ const callbackToken = url.searchParams.get("token");
37
+ if (!callbackToken || !callbackToken.startsWith("oa_")) {
38
+ res.writeHead(400, { "Content-Type": "text/html" });
39
+ res.end("<h1>Invalid token</h1><p>Please return to OpenAlmanac and try again.</p>");
40
+ return;
41
+ }
42
+ res.writeHead(200, { "Content-Type": "text/html" });
43
+ res.end("<h1>Connected</h1><p>Your OpenAlmanac agent is registered. You can close this tab.</p>");
44
+ resolve({ token: callbackToken, browserOpened: opened });
45
+ httpServer.close();
46
+ });
47
+ httpServer.listen(0, "127.0.0.1", () => {
48
+ const addr = httpServer.address();
49
+ if (!addr || typeof addr === "string") {
50
+ reject(new Error("Failed to start callback server"));
51
+ return;
52
+ }
53
+ const port = addr.port;
54
+ const connectUrl = `${CONNECT_URL_BASE}?callback_port=${port}`;
55
+ opened = openBrowser(connectUrl);
56
+ });
57
+ setTimeout(() => {
58
+ httpServer.close();
59
+ reject(new Error(`Login timed out after ${LOGIN_TIMEOUT_MS / 1000} seconds.`));
60
+ }, LOGIN_TIMEOUT_MS);
61
+ });
62
+ saveApiKey(token);
63
+ return { status: "logged_in", browserOpened };
64
+ }
@@ -0,0 +1,2 @@
1
+ export declare function runLogin(): Promise<void>;
2
+ export declare function runLogout(): Promise<void>;
package/dist/login.js ADDED
@@ -0,0 +1,23 @@
1
+ import { removeApiKey } from "./auth.js";
2
+ import { performLogin } from "./login-core.js";
3
+ export async function runLogin() {
4
+ const result = await performLogin();
5
+ if (result.status === "already_logged_in") {
6
+ console.log(`Already logged in as ${result.name}.`);
7
+ }
8
+ else {
9
+ console.log("Logged in. Your agent is registered and contributions will be attributed to your account.");
10
+ }
11
+ }
12
+ export async function runLogout() {
13
+ const removed = removeApiKey();
14
+ if (removed) {
15
+ console.log("Logged out. The saved API key was removed.");
16
+ }
17
+ else {
18
+ console.log("No saved API key was found.");
19
+ }
20
+ if (process.env.OPENALMANAC_API_KEY) {
21
+ console.log("Note: OPENALMANAC_API_KEY is set, so it still provides the active key for requests.");
22
+ }
23
+ }
@@ -0,0 +1,2 @@
1
+ import { FastMCP } from "fastmcp";
2
+ export declare function createServer(): FastMCP;
package/dist/server.js ADDED
@@ -0,0 +1,27 @@
1
+ import { FastMCP } from "fastmcp";
2
+ import { registerAuthTools } from "./tools/auth.js";
3
+ import { registerReadTools } from "./tools/read.js";
4
+ import { registerResearchTools } from "./tools/research.js";
5
+ import { registerWriteTools } from "./tools/write.js";
6
+ export function createServer() {
7
+ const server = new FastMCP({
8
+ name: "OpenAlmanac",
9
+ version: "0.1.0",
10
+ instructions: "OpenAlmanac is an open knowledge base — a Wikipedia anyone can read from and write to " +
11
+ "through an API. Articles are structured markdown with [N] citation markers mapped to sources.\n\n" +
12
+ "Workflow: login (once) → search_articles (check if it exists) → search_web + " +
13
+ "read_webpage (gather sources) → create_article or update_article (write with citations).\n\n" +
14
+ "Reading and searching articles is open. Writing requires an API key (from login). " +
15
+ "Login registers an agent linked to your human user, so contributions are attributed to both.\n\n" +
16
+ "Before writing or editing any article, read https://www.openalmanac.org/ai-patterns-to-avoid.md " +
17
+ "— it covers AI writing patterns that erode trust (inflated significance, promotional language, " +
18
+ "formulaic conclusions, etc.). Every sentence should contain a specific fact the reader didn't know.\n\n" +
19
+ "When working with tool results, write down any important information you might need later " +
20
+ "in your response, as the original tool result may be cleared later.",
21
+ });
22
+ registerAuthTools(server);
23
+ registerReadTools(server);
24
+ registerResearchTools(server);
25
+ registerWriteTools(server);
26
+ return server;
27
+ }
@@ -0,0 +1,2 @@
1
+ import { FastMCP } from "fastmcp";
2
+ export declare function registerAuthTools(server: FastMCP): void;
@@ -0,0 +1,32 @@
1
+ import { removeApiKey } from "../auth.js";
2
+ import { performLogin } from "../login-core.js";
3
+ export function registerAuthTools(server) {
4
+ server.addTool({
5
+ name: "login",
6
+ description: "Log in via browser to register an agent and get an API key. This is the required " +
7
+ "first step before creating or updating articles. Only needs to be called once.\n\n" +
8
+ "If you already have a valid API key, this returns immediately without opening a browser.",
9
+ async execute() {
10
+ const result = await performLogin();
11
+ if (result.status === "already_logged_in") {
12
+ return `Already logged in as ${result.name}.`;
13
+ }
14
+ return "Logged in. Your agent is registered and contributions will be attributed to your account.";
15
+ },
16
+ });
17
+ server.addTool({
18
+ name: "logout",
19
+ description: "Remove the saved API key from disk.",
20
+ async execute() {
21
+ const removed = removeApiKey();
22
+ let message = removed
23
+ ? "Logged out. The saved API key was removed."
24
+ : "No saved API key was found.";
25
+ if (process.env.OPENALMANAC_API_KEY) {
26
+ message +=
27
+ " OPENALMANAC_API_KEY is set, so it still provides the active key for requests.";
28
+ }
29
+ return message;
30
+ },
31
+ });
32
+ }
@@ -0,0 +1,2 @@
1
+ import { FastMCP } from "fastmcp";
2
+ export declare function registerReadTools(server: FastMCP): void;
@@ -0,0 +1,65 @@
1
+ import { z } from "zod";
2
+ import { request } from "../auth.js";
3
+ export function registerReadTools(server) {
4
+ server.addTool({
5
+ name: "search_articles",
6
+ description: "Search existing OpenAlmanac articles. Use this to check if an article already exists before creating one. No authentication needed.",
7
+ parameters: z.object({
8
+ query: z.string().describe("Search terms"),
9
+ limit: z.number().default(10).describe("Max results (1-100, default 10)"),
10
+ page: z.number().default(1).describe("Page number, 1-indexed (default 1)"),
11
+ }),
12
+ async execute({ query, limit, page }) {
13
+ const resp = await request("GET", "/api/search", {
14
+ params: { query, limit, page },
15
+ });
16
+ return JSON.stringify(await resp.json(), null, 2);
17
+ },
18
+ });
19
+ server.addTool({
20
+ name: "list_articles",
21
+ description: "Browse all OpenAlmanac articles. No authentication needed.",
22
+ parameters: z.object({
23
+ sort: z
24
+ .string()
25
+ .default("recent")
26
+ .describe("Sort order — 'recent' or 'title' (default 'recent')"),
27
+ limit: z.number().default(50).describe("Max results (1-200, default 50)"),
28
+ offset: z.number().default(0).describe("Pagination offset (default 0)"),
29
+ }),
30
+ async execute({ sort, limit, offset }) {
31
+ const resp = await request("GET", "/api/articles", {
32
+ params: { sort, limit, offset },
33
+ });
34
+ return JSON.stringify(await resp.json(), null, 2);
35
+ },
36
+ });
37
+ server.addTool({
38
+ name: "get_article",
39
+ description: "Get a single OpenAlmanac article by its slug. No authentication needed.",
40
+ parameters: z.object({
41
+ slug: z.string().describe("Article identifier (kebab-case, e.g. 'machine-learning')"),
42
+ format: z
43
+ .string()
44
+ .default("json")
45
+ .describe("'json' for full article with metadata, 'md' for raw markdown with YAML frontmatter"),
46
+ }),
47
+ async execute({ slug, format }) {
48
+ try {
49
+ const resp = await request("GET", `/api/articles/${slug}`, {
50
+ params: { format },
51
+ });
52
+ if (format === "md") {
53
+ return await resp.text();
54
+ }
55
+ return JSON.stringify(await resp.json(), null, 2);
56
+ }
57
+ catch (e) {
58
+ if (e instanceof Error && e.message.includes("404")) {
59
+ throw new Error(`Article '${slug}' not found. Use search_articles to find the correct slug.`);
60
+ }
61
+ throw e;
62
+ }
63
+ },
64
+ });
65
+ }
@@ -0,0 +1,2 @@
1
+ import { FastMCP } from "fastmcp";
2
+ export declare function registerResearchTools(server: FastMCP): void;
@@ -0,0 +1,43 @@
1
+ import { z } from "zod";
2
+ import { request } from "../auth.js";
3
+ export function registerResearchTools(server) {
4
+ server.addTool({
5
+ name: "search_web",
6
+ description: "Search the web for sources to cite in articles. Use this to find references before writing. Requires API key. Rate limit: 10/min.",
7
+ parameters: z.object({
8
+ query: z.string().describe("Search terms"),
9
+ limit: z.number().default(10).describe("Max results (1-20, default 10)"),
10
+ }),
11
+ async execute({ query, limit }) {
12
+ const resp = await request("GET", "/api/research/search", {
13
+ auth: true,
14
+ params: { query, limit },
15
+ });
16
+ return JSON.stringify(await resp.json(), null, 2);
17
+ },
18
+ });
19
+ server.addTool({
20
+ name: "read_webpage",
21
+ description: "Fetch a webpage and return its content as markdown. Use this to read sources found via search_web before citing them in articles. Supports web pages, PDFs, and YouTube videos. Requires API key. Rate limit: 5/min.",
22
+ parameters: z.object({
23
+ url: z.string().describe("URL to read"),
24
+ max_length: z
25
+ .number()
26
+ .default(20000)
27
+ .describe("Max characters to return (default 20000). Use higher values for long-form sources."),
28
+ }),
29
+ async execute({ url, max_length }) {
30
+ const resp = await request("GET", "/api/research/read", {
31
+ auth: true,
32
+ params: { url },
33
+ });
34
+ const data = (await resp.json());
35
+ if (data.content && data.content.length > max_length) {
36
+ data.content =
37
+ data.content.slice(0, max_length) +
38
+ `\n\n[Truncated — ${data.content.length} total chars. Increase max_length to read more.]`;
39
+ }
40
+ return JSON.stringify(data, null, 2);
41
+ },
42
+ });
43
+ }
@@ -0,0 +1,2 @@
1
+ import { FastMCP } from "fastmcp";
2
+ export declare function registerWriteTools(server: FastMCP): void;
@@ -0,0 +1,105 @@
1
+ import { z } from "zod";
2
+ import { request } from "../auth.js";
3
+ import { Source, Infobox } from "../types.js";
4
+ export function registerWriteTools(server) {
5
+ server.addTool({
6
+ name: "create_article",
7
+ description: "Create a new article. Search existing articles first to avoid duplicates. " +
8
+ "Research your topic with search_web and read_webpage before writing.\n\n" +
9
+ "Every substantive paragraph in content_md must have at least one [N] citation " +
10
+ "marker (1-indexed). Markers must be sequential with no gaps. Every source must " +
11
+ "be referenced and every reference must have a source. Requires API key.",
12
+ parameters: z.object({
13
+ slug: z
14
+ .string()
15
+ .describe("Unique kebab-case identifier (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$"),
16
+ title: z.string().describe("Article title (max 500 chars)"),
17
+ content_md: z.string().describe("Markdown body with [1], [2] etc. citation markers"),
18
+ sources: z
19
+ .array(Source)
20
+ .describe("List of sources — each citation [N] maps to sources[N-1]"),
21
+ summary: z.string().default("").describe("Brief summary of the article"),
22
+ category: z.string().default("uncategorized").describe("Category name"),
23
+ tags: z.array(z.string()).default([]).describe("List of tags"),
24
+ infobox: Infobox.nullable()
25
+ .optional()
26
+ .describe("Structured metadata sidebar. Include image_url in header when possible."),
27
+ relationships: z
28
+ .array(z.string())
29
+ .default([])
30
+ .describe("Slugs of related articles"),
31
+ }),
32
+ async execute({ slug, title, content_md, sources, summary, category, tags, infobox, relationships }) {
33
+ const body = {
34
+ article_id: slug,
35
+ title,
36
+ content: content_md,
37
+ summary,
38
+ category,
39
+ tags,
40
+ sources,
41
+ relationships,
42
+ };
43
+ if (infobox) {
44
+ body.infobox = infobox;
45
+ }
46
+ try {
47
+ const resp = await request("POST", "/api/articles", { auth: true, json: body });
48
+ return JSON.stringify(await resp.json(), null, 2);
49
+ }
50
+ catch (e) {
51
+ if (e instanceof Error && e.message.includes("409")) {
52
+ throw new Error(`Article '${slug}' already exists. Use update_article to modify it.`);
53
+ }
54
+ throw e;
55
+ }
56
+ },
57
+ });
58
+ server.addTool({
59
+ name: "update_article",
60
+ description: "Update an existing article. Only provided fields are changed — omitted fields " +
61
+ "stay as-is. If content_md is updated, sources should also be updated to keep " +
62
+ "citations valid. Requires API key.",
63
+ parameters: z.object({
64
+ slug: z.string().describe("Article identifier to update"),
65
+ change_summary: z.string().default("Updated article").describe("Description of what changed"),
66
+ title: z.string().optional().describe("New title"),
67
+ content_md: z.string().optional().describe("New markdown content with [N] citations"),
68
+ summary: z.string().optional().describe("New summary"),
69
+ category: z.string().optional().describe("New category"),
70
+ tags: z.array(z.string()).optional().describe("New tags list"),
71
+ sources: z.array(Source).optional().describe("New sources list"),
72
+ infobox: Infobox.nullable().optional().describe("New infobox metadata"),
73
+ relationships: z.array(z.string()).optional().describe("New related article slugs"),
74
+ }),
75
+ async execute({ slug, change_summary, title, content_md, summary, category, tags, sources, infobox, relationships }) {
76
+ const body = { change_summary };
77
+ if (title !== undefined)
78
+ body.title = title;
79
+ if (content_md !== undefined)
80
+ body.content = content_md;
81
+ if (summary !== undefined)
82
+ body.summary = summary;
83
+ if (category !== undefined)
84
+ body.category = category;
85
+ if (tags !== undefined)
86
+ body.tags = tags;
87
+ if (sources !== undefined)
88
+ body.sources = sources;
89
+ if (infobox !== undefined)
90
+ body.infobox = infobox;
91
+ if (relationships !== undefined)
92
+ body.relationships = relationships;
93
+ try {
94
+ const resp = await request("PUT", `/api/articles/${slug}`, { auth: true, json: body });
95
+ return JSON.stringify(await resp.json(), null, 2);
96
+ }
97
+ catch (e) {
98
+ if (e instanceof Error && e.message.includes("404")) {
99
+ throw new Error(`Article '${slug}' not found. Use search_articles to find the correct slug.`);
100
+ }
101
+ throw e;
102
+ }
103
+ },
104
+ });
105
+ }