openalmanac 0.1.0 → 0.2.1
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 +4 -1
- package/dist/auth.js +7 -2
- package/dist/server.js +5 -6
- package/dist/tools/articles.d.ts +2 -0
- package/dist/tools/articles.js +140 -0
- package/dist/validate.d.ts +11 -0
- package/dist/validate.js +101 -0
- package/package.json +3 -2
- package/dist/tools/read.d.ts +0 -2
- package/dist/tools/read.js +0 -65
- package/dist/tools/write.d.ts +0 -2
- package/dist/tools/write.js +0 -105
- package/dist/types.d.ts +0 -953
- package/dist/types.js +0 -91
package/dist/auth.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
declare const API_BASE = "https://api.openalmanac.org";
|
|
2
2
|
declare const API_KEY_PATH: string;
|
|
3
|
-
|
|
3
|
+
declare const ARTICLES_DIR: string;
|
|
4
|
+
export { API_BASE, API_KEY_PATH, ARTICLES_DIR };
|
|
4
5
|
export declare function getApiKey(): string | null;
|
|
5
6
|
export declare function requireApiKey(): string;
|
|
6
7
|
export declare function saveApiKey(key: string): void;
|
|
@@ -10,4 +11,6 @@ export declare function request(method: string, path: string, options?: {
|
|
|
10
11
|
auth?: boolean;
|
|
11
12
|
params?: Record<string, string | number>;
|
|
12
13
|
json?: unknown;
|
|
14
|
+
body?: string;
|
|
15
|
+
contentType?: string;
|
|
13
16
|
}): Promise<Response>;
|
package/dist/auth.js
CHANGED
|
@@ -5,7 +5,8 @@ import { chmodSync } from "node:fs";
|
|
|
5
5
|
const API_BASE = "https://api.openalmanac.org";
|
|
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");
|
|
9
|
+
export { API_BASE, API_KEY_PATH, ARTICLES_DIR };
|
|
9
10
|
export function getApiKey() {
|
|
10
11
|
const envKey = process.env.OPENALMANAC_API_KEY;
|
|
11
12
|
if (envKey)
|
|
@@ -40,7 +41,7 @@ export function buildAuthHeaders() {
|
|
|
40
41
|
return { Authorization: `Bearer ${requireApiKey()}` };
|
|
41
42
|
}
|
|
42
43
|
export async function request(method, path, options = {}) {
|
|
43
|
-
const { auth = false, params, json } = options;
|
|
44
|
+
const { auth = false, params, json, body, contentType } = options;
|
|
44
45
|
let url = `${API_BASE}${path}`;
|
|
45
46
|
if (params) {
|
|
46
47
|
const searchParams = new URLSearchParams();
|
|
@@ -62,6 +63,10 @@ export async function request(method, path, options = {}) {
|
|
|
62
63
|
headers["Content-Type"] = "application/json";
|
|
63
64
|
init.body = JSON.stringify(json);
|
|
64
65
|
}
|
|
66
|
+
else if (body !== undefined) {
|
|
67
|
+
headers["Content-Type"] = contentType ?? "text/plain";
|
|
68
|
+
init.body = body;
|
|
69
|
+
}
|
|
65
70
|
const resp = await fetch(url, init);
|
|
66
71
|
if (!resp.ok) {
|
|
67
72
|
const text = await resp.text();
|
package/dist/server.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { FastMCP } from "fastmcp";
|
|
2
2
|
import { registerAuthTools } from "./tools/auth.js";
|
|
3
|
-
import {
|
|
3
|
+
import { registerArticleTools } from "./tools/articles.js";
|
|
4
4
|
import { registerResearchTools } from "./tools/research.js";
|
|
5
|
-
import { registerWriteTools } from "./tools/write.js";
|
|
6
5
|
export function createServer() {
|
|
7
6
|
const server = new FastMCP({
|
|
8
7
|
name: "OpenAlmanac",
|
|
9
8
|
version: "0.1.0",
|
|
10
9
|
instructions: "OpenAlmanac is an open knowledge base — a Wikipedia anyone can read from and write to " +
|
|
11
|
-
"through an API. Articles are
|
|
10
|
+
"through an API. Articles are markdown files with YAML frontmatter and [N] citation markers mapped to sources.\n\n" +
|
|
12
11
|
"Workflow: login (once) → search_articles (check if it exists) → search_web + " +
|
|
13
|
-
"read_webpage (gather sources) →
|
|
12
|
+
"read_webpage (gather sources) → new (create scaffold) or pull (download existing) → " +
|
|
13
|
+
"edit the file at ~/.openalmanac/articles/{slug}.md → push (validate & publish).\n\n" +
|
|
14
14
|
"Reading and searching articles is open. Writing requires an API key (from login). " +
|
|
15
15
|
"Login registers an agent linked to your human user, so contributions are attributed to both.\n\n" +
|
|
16
16
|
"Before writing or editing any article, read https://www.openalmanac.org/ai-patterns-to-avoid.md " +
|
|
@@ -20,8 +20,7 @@ export function createServer() {
|
|
|
20
20
|
"in your response, as the original tool result may be cleared later.",
|
|
21
21
|
});
|
|
22
22
|
registerAuthTools(server);
|
|
23
|
-
|
|
23
|
+
registerArticleTools(server);
|
|
24
24
|
registerResearchTools(server);
|
|
25
|
-
registerWriteTools(server);
|
|
26
25
|
return server;
|
|
27
26
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { stringify as yamlStringify } from "yaml";
|
|
5
|
+
import { request, ARTICLES_DIR } from "../auth.js";
|
|
6
|
+
import { validateArticle, parseFrontmatter } from "../validate.js";
|
|
7
|
+
const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
8
|
+
function ensureArticlesDir() {
|
|
9
|
+
mkdirSync(ARTICLES_DIR, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
export function registerArticleTools(server) {
|
|
12
|
+
server.addTool({
|
|
13
|
+
name: "search_articles",
|
|
14
|
+
description: "Search existing OpenAlmanac articles. Use this to check if an article already exists before creating one. No authentication needed.",
|
|
15
|
+
parameters: z.object({
|
|
16
|
+
query: z.string().describe("Search terms"),
|
|
17
|
+
limit: z.number().default(10).describe("Max results (1-100, default 10)"),
|
|
18
|
+
page: z.number().default(1).describe("Page number, 1-indexed (default 1)"),
|
|
19
|
+
}),
|
|
20
|
+
async execute({ query, limit, page }) {
|
|
21
|
+
const resp = await request("GET", "/api/search", {
|
|
22
|
+
params: { query, limit, page },
|
|
23
|
+
});
|
|
24
|
+
return JSON.stringify(await resp.json(), null, 2);
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
server.addTool({
|
|
28
|
+
name: "pull",
|
|
29
|
+
description: "Download an article from OpenAlmanac to your local working directory (~/.openalmanac/articles/). " +
|
|
30
|
+
"The file is saved as {slug}.md with YAML frontmatter. Edit the file locally, then use push to publish changes.",
|
|
31
|
+
parameters: z.object({
|
|
32
|
+
slug: z.string().describe("Article slug (e.g. 'machine-learning')"),
|
|
33
|
+
}),
|
|
34
|
+
async execute({ slug }) {
|
|
35
|
+
const resp = await request("GET", `/api/articles/${slug}`, {
|
|
36
|
+
params: { format: "md" },
|
|
37
|
+
});
|
|
38
|
+
const markdown = await resp.text();
|
|
39
|
+
ensureArticlesDir();
|
|
40
|
+
const filePath = join(ARTICLES_DIR, `${slug}.md`);
|
|
41
|
+
writeFileSync(filePath, markdown, "utf-8");
|
|
42
|
+
const { frontmatter, content } = parseFrontmatter(markdown);
|
|
43
|
+
const title = frontmatter.title || "(untitled)";
|
|
44
|
+
const wordCount = content.trim().split(/\s+/).filter(Boolean).length;
|
|
45
|
+
return `Pulled "${title}" to ${filePath}\n${wordCount} words, ${frontmatter.sources?.length ?? 0} sources.\nEdit the file, then use push to publish.`;
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
server.addTool({
|
|
49
|
+
name: "new",
|
|
50
|
+
description: "Create a new article scaffold in your local working directory (~/.openalmanac/articles/). " +
|
|
51
|
+
"The file is created with YAML frontmatter and an empty body. Edit the file to add content and sources, then use push to publish.",
|
|
52
|
+
parameters: z.object({
|
|
53
|
+
slug: z
|
|
54
|
+
.string()
|
|
55
|
+
.describe("Unique kebab-case identifier (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$"),
|
|
56
|
+
title: z.string().describe("Article title"),
|
|
57
|
+
}),
|
|
58
|
+
async execute({ slug, title }) {
|
|
59
|
+
if (!SLUG_RE.test(slug)) {
|
|
60
|
+
throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$`);
|
|
61
|
+
}
|
|
62
|
+
ensureArticlesDir();
|
|
63
|
+
const filePath = join(ARTICLES_DIR, `${slug}.md`);
|
|
64
|
+
if (existsSync(filePath)) {
|
|
65
|
+
throw new Error(`File already exists: ${filePath}\nUse pull to refresh it, or push to publish changes.`);
|
|
66
|
+
}
|
|
67
|
+
const frontmatter = yamlStringify({ article_id: slug, title, sources: [] });
|
|
68
|
+
const scaffold = `---\n${frontmatter}---\n\n`;
|
|
69
|
+
writeFileSync(filePath, scaffold, "utf-8");
|
|
70
|
+
return `Created ${filePath}\nEdit the file to add content and sources, then use push to publish.`;
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
server.addTool({
|
|
74
|
+
name: "push",
|
|
75
|
+
description: "Validate and publish a local article to OpenAlmanac. Reads the file from ~/.openalmanac/articles/{slug}.md, " +
|
|
76
|
+
"validates locally (frontmatter, citations, sources), then pushes via the API. Requires login.",
|
|
77
|
+
parameters: z.object({
|
|
78
|
+
slug: z.string().describe("Article slug matching the filename (without .md)"),
|
|
79
|
+
change_summary: z.string().optional().describe("Brief description of changes"),
|
|
80
|
+
}),
|
|
81
|
+
async execute({ slug, change_summary }) {
|
|
82
|
+
const filePath = join(ARTICLES_DIR, `${slug}.md`);
|
|
83
|
+
let raw;
|
|
84
|
+
try {
|
|
85
|
+
raw = readFileSync(filePath, "utf-8");
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
throw new Error(`File not found: ${filePath}\nUse pull to download an existing article or new to create a scaffold.`);
|
|
89
|
+
}
|
|
90
|
+
// Local validation
|
|
91
|
+
const errors = validateArticle(raw);
|
|
92
|
+
if (errors.length > 0) {
|
|
93
|
+
const lines = errors.map((e) => ` ${e.field}: ${e.message}`);
|
|
94
|
+
throw new Error(`Validation failed (${errors.length} error${errors.length > 1 ? "s" : ""}):\n${lines.join("\n")}\n\nFix the file and try again.`);
|
|
95
|
+
}
|
|
96
|
+
// Inject change_summary into frontmatter if provided
|
|
97
|
+
let body = raw;
|
|
98
|
+
if (change_summary) {
|
|
99
|
+
const { frontmatter, content } = parseFrontmatter(raw);
|
|
100
|
+
frontmatter.change_summary = change_summary;
|
|
101
|
+
const newFrontmatter = yamlStringify(frontmatter);
|
|
102
|
+
body = `---\n${newFrontmatter}---\n${content}`;
|
|
103
|
+
}
|
|
104
|
+
const resp = await request("PUT", `/api/articles/${slug}`, {
|
|
105
|
+
auth: true,
|
|
106
|
+
body,
|
|
107
|
+
contentType: "text/markdown",
|
|
108
|
+
});
|
|
109
|
+
const data = (await resp.json());
|
|
110
|
+
const articleUrl = `https://www.openalmanac.org/articles/${slug}`;
|
|
111
|
+
return `Pushed successfully.\n${articleUrl}\n${JSON.stringify(data, null, 2)}`;
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
server.addTool({
|
|
115
|
+
name: "status",
|
|
116
|
+
description: "List all article files in your local working directory (~/.openalmanac/articles/). " +
|
|
117
|
+
"Shows filename, title, file size, and last modified time. No API calls.",
|
|
118
|
+
async execute() {
|
|
119
|
+
ensureArticlesDir();
|
|
120
|
+
const files = readdirSync(ARTICLES_DIR).filter((f) => f.endsWith(".md"));
|
|
121
|
+
if (files.length === 0) {
|
|
122
|
+
return `No articles in ${ARTICLES_DIR}\nUse pull to download an article or new to create one.`;
|
|
123
|
+
}
|
|
124
|
+
const rows = [];
|
|
125
|
+
for (const file of files) {
|
|
126
|
+
const filePath = join(ARTICLES_DIR, file);
|
|
127
|
+
const stat = statSync(filePath);
|
|
128
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
129
|
+
const { frontmatter } = parseFrontmatter(raw);
|
|
130
|
+
const title = frontmatter.title || "(untitled)";
|
|
131
|
+
const size = stat.size < 1024
|
|
132
|
+
? `${stat.size}B`
|
|
133
|
+
: `${(stat.size / 1024).toFixed(1)}KB`;
|
|
134
|
+
const modified = stat.mtime.toISOString().slice(0, 16).replace("T", " ");
|
|
135
|
+
rows.push(` ${file} — "${title}" (${size}, modified ${modified})`);
|
|
136
|
+
}
|
|
137
|
+
return `${files.length} article(s) in ${ARTICLES_DIR}:\n${rows.join("\n")}`;
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ValidationError {
|
|
2
|
+
field: string;
|
|
3
|
+
message: string;
|
|
4
|
+
}
|
|
5
|
+
interface ParsedArticle {
|
|
6
|
+
frontmatter: Record<string, unknown>;
|
|
7
|
+
content: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function parseFrontmatter(raw: string): ParsedArticle;
|
|
10
|
+
export declare function validateArticle(raw: string): ValidationError[];
|
|
11
|
+
export {};
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { parse as parseYaml } from "yaml";
|
|
2
|
+
const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
3
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
4
|
+
const MARKER_RE = /\[(\d+)\]/g;
|
|
5
|
+
export function parseFrontmatter(raw) {
|
|
6
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
7
|
+
if (!match) {
|
|
8
|
+
return { frontmatter: {}, content: raw };
|
|
9
|
+
}
|
|
10
|
+
const frontmatter = parseYaml(match[1]);
|
|
11
|
+
return { frontmatter, content: match[2] };
|
|
12
|
+
}
|
|
13
|
+
export function validateArticle(raw) {
|
|
14
|
+
const errors = [];
|
|
15
|
+
const { frontmatter, content } = parseFrontmatter(raw);
|
|
16
|
+
// content
|
|
17
|
+
if (!content || content.trim().length === 0) {
|
|
18
|
+
errors.push({ field: "content", message: "Article content is required" });
|
|
19
|
+
}
|
|
20
|
+
// title
|
|
21
|
+
const title = frontmatter.title;
|
|
22
|
+
if (!title || typeof title !== "string" || title.trim().length === 0) {
|
|
23
|
+
errors.push({ field: "title", message: "Title is required" });
|
|
24
|
+
}
|
|
25
|
+
else if (title.length > 500) {
|
|
26
|
+
errors.push({ field: "title", message: "Title must be 500 characters or fewer" });
|
|
27
|
+
}
|
|
28
|
+
// article_id / slug
|
|
29
|
+
const articleId = frontmatter.article_id;
|
|
30
|
+
if (articleId && typeof articleId === "string" && !SLUG_RE.test(articleId)) {
|
|
31
|
+
errors.push({
|
|
32
|
+
field: "article_id",
|
|
33
|
+
message: "Must be kebab-case (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
// sources
|
|
37
|
+
const sources = frontmatter.sources;
|
|
38
|
+
if (!Array.isArray(sources)) {
|
|
39
|
+
errors.push({ field: "sources", message: "Sources must be an array" });
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
for (let i = 0; i < sources.length; i++) {
|
|
43
|
+
const s = sources[i];
|
|
44
|
+
if (!s.url || typeof s.url !== "string") {
|
|
45
|
+
errors.push({ field: `sources[${i}].url`, message: "URL is required" });
|
|
46
|
+
}
|
|
47
|
+
if (!s.title || typeof s.title !== "string") {
|
|
48
|
+
errors.push({ field: `sources[${i}].title`, message: "Title is required" });
|
|
49
|
+
}
|
|
50
|
+
if (!s.accessed_date || typeof s.accessed_date !== "string") {
|
|
51
|
+
errors.push({ field: `sources[${i}].accessed_date`, message: "Accessed date is required" });
|
|
52
|
+
}
|
|
53
|
+
else if (!DATE_RE.test(s.accessed_date)) {
|
|
54
|
+
errors.push({
|
|
55
|
+
field: `sources[${i}].accessed_date`,
|
|
56
|
+
message: "Must be YYYY-MM-DD format",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// citation markers
|
|
62
|
+
const markerNums = new Set();
|
|
63
|
+
let match;
|
|
64
|
+
while ((match = MARKER_RE.exec(content)) !== null) {
|
|
65
|
+
markerNums.add(parseInt(match[1], 10));
|
|
66
|
+
}
|
|
67
|
+
if (markerNums.size > 0) {
|
|
68
|
+
const sorted = [...markerNums].sort((a, b) => a - b);
|
|
69
|
+
if (sorted[0] !== 1) {
|
|
70
|
+
errors.push({ field: "citations", message: "Citation markers must start at [1]" });
|
|
71
|
+
}
|
|
72
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
73
|
+
if (sorted[i] !== sorted[i - 1] + 1) {
|
|
74
|
+
errors.push({
|
|
75
|
+
field: "citations",
|
|
76
|
+
message: `Gap in citation markers: [${sorted[i - 1]}] → [${sorted[i]}]`,
|
|
77
|
+
});
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// cross-check markers vs sources
|
|
82
|
+
if (Array.isArray(sources)) {
|
|
83
|
+
const maxMarker = sorted[sorted.length - 1];
|
|
84
|
+
if (maxMarker > sources.length) {
|
|
85
|
+
errors.push({
|
|
86
|
+
field: "citations",
|
|
87
|
+
message: `Citation [${maxMarker}] referenced but only ${sources.length} source(s) provided`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
for (let i = 0; i < sources.length; i++) {
|
|
91
|
+
if (!markerNums.has(i + 1)) {
|
|
92
|
+
errors.push({
|
|
93
|
+
field: `sources[${i}]`,
|
|
94
|
+
message: `Source ${i + 1} is never referenced with [${i + 1}] in the content`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return errors;
|
|
101
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openalmanac",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "OpenAlmanac —
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "OpenAlmanac — pull, edit, and push articles to the open knowledge base",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"openalmanac": "dist/cli.js"
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"license": "MIT",
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"fastmcp": "^1.0.0",
|
|
24
|
+
"yaml": "^2.8.2",
|
|
24
25
|
"zod": "^3.24.0"
|
|
25
26
|
},
|
|
26
27
|
"devDependencies": {
|
package/dist/tools/read.d.ts
DELETED
package/dist/tools/read.js
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
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
|
-
}
|
package/dist/tools/write.d.ts
DELETED
package/dist/tools/write.js
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
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
|
-
}
|