openalmanac 0.2.56 → 0.2.58

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
@@ -7,6 +7,7 @@ import { registerPageTools } from "./tools/pages.js";
7
7
  import { registerResearchTools } from "./tools/research.js";
8
8
  import { registerWikiTools } from "./tools/wikis.js";
9
9
  import { registerTopicTools } from "./tools/topics.js";
10
+ import { registerUserTools } from "./tools/users.js";
10
11
  import { getApiKey } from "./auth.js";
11
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
13
  const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
@@ -122,11 +123,54 @@ export function createServer() {
122
123
  "",
123
124
  "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.",
124
125
  "",
126
+ "## Wikilink syntax",
127
+ "",
128
+ "Wikilinks are resolved server-side at publish time. There are four forms — every one uses double brackets:",
129
+ "",
130
+ "- `[[slug]]` — link to another page in the SAME wiki you're publishing to. Display text is the page's title.",
131
+ "- `[[slug|Display text]]` — same wiki, custom display text.",
132
+ "- `[[global:slug]]` / `[[global:slug|Display]]` — link to a page in the global almanac (the shared wiki with slug `global`). Use this for cross-cutting entities (people, technologies, concepts) that belong in the global knowledge base rather than a per-topic wiki.",
133
+ "- `[[wiki-slug:page-slug]]` / `[[wiki-slug:page-slug|Display]]` — link to a specific page in another wiki.",
134
+ "",
135
+ "Dead links auto-create stub pages on publish — you can link to entities that don't exist yet and the server will scaffold empty pages at those slugs so the links resolve. Use `resolve` before publish if you want to see which targets are `found` / `stub` / `not_found`.",
136
+ "",
137
+ "Inline mentions without brackets are NOT linked. If you want an entity to be linkable, wrap it in `[[...]]`.",
138
+ "",
139
+ "## Authoring an infobox",
140
+ "",
141
+ "Infoboxes have a strict pydantic schema on the backend — unknown section types, bare-string `header.links`, int values in `header.details`, and missing `primary` on timeline items are all rejected at publish time with a 400. Before authoring ANY infobox, fetch the full field-by-field reference:",
142
+ "",
143
+ " https://www.openalmanac.org/infobox-schema.md",
144
+ "",
145
+ "Follow it exactly. Do not invent new section types or field names — if the schema doesn't describe what you want, fold the information into `key_value` or `list`. The six valid section types are `timeline`, `list`, `tags`, `grid`, `table`, `key_value`.",
146
+ "",
147
+ "## Working across wikis",
148
+ "",
149
+ "OpenAlmanac is multi-wiki. Every page lives inside one wiki. Before writing or creating anything, ground yourself:",
150
+ "",
151
+ "- `whoami` → who is the user? Needed to address them and to reason about \"my wikis\".",
152
+ "- `list_wikis` → what wikis exist? Use this BEFORE `create_wiki` so you can suggest contributing to an existing wiki instead of spinning up a parallel one.",
153
+ "",
154
+ "### Creating a new wiki — collaborative flow",
155
+ "",
156
+ "When the user says \"I want to start a wiki about X\", don't just call `create_wiki` and dump content. Do this:",
157
+ "",
158
+ "1. **Check what's there.** Call `list_wikis` to see existing wikis. If something close exists, surface it: \"There's already a `<slug>` wiki on a related area — do you want to contribute there, or is this a distinct enough angle?\"",
159
+ "2. **Research the space briefly.** Use `search_web` + `read_webpage` to get real information about the topic. A few quick reads, not a deep dive — enough to brainstorm coherently.",
160
+ "3. **Brainstorm structure with the user.** Be conversational, not a form-filler. Propose a few natural topics the wiki might have (3-6 bullet points max), what the scope is, what the voice/angle is. Ask what resonates. Don't write prose yet.",
161
+ "4. **Create the wiki.** `create_wiki` with title + description. The server auto-scaffolds a `main-page` with default homepage directives.",
162
+ "5. **Edit the main page in place.** `download` the auto-created `main-page` and edit the file (don't `new` a fresh `main-page` — the server derives slug from title, so a new scaffold would get a different slug and you'd end up with two homepages).",
163
+ "6. **Seed the topic hierarchy.** `create_topics_batch` with the topics you agreed on.",
164
+ "7. **Wire navigation.** `update_wiki_settings` with a `nav` array. Each NavItem needs exactly one of `page` / `topic` / `link`. Use `auto: {enabled: true}` on topic NavItems to auto-populate children from the topic DAG.",
165
+ "8. **Seed a few stub pages or first articles.** Stubs are fine — scaffold-and-fill-later is a supported workflow.",
166
+ "",
167
+ "The conversation drives the shape of the wiki. Don't over-engineer the topic hierarchy or the nav on turn one. Ship a small coherent starting shape and grow it with the user.",
168
+ "",
125
169
  "## Technical workflow",
126
170
  "",
127
171
  "Reading and searching articles is open. Writing requires an API key (from login). Login creates a personal API key linked to your user account, so contributions are attributed to you.",
128
172
  "",
129
- "Core flow: login (once) → search_articles (check if exists) → search_web + read_webpage (research) → `new` (scaffold with wiki_slug) or `download` (batch with wiki_slug) → edit files under ~/.openalmanac/articles/{wiki_slug}/ → `publish` (slugs + wiki_slug).",
173
+ "Core flow: login (once) → `whoami` (confirm identity) → `list_wikis` or `search_articles` (what exists?) → `search_web` + `read_webpage` (research) → `new` (scaffold) or `download` (existing) → edit files under ~/.openalmanac/articles/{wiki_slug}/ → `publish`.",
130
174
  "",
131
175
  "After publishing, share the celebration URL when applicable. Use `list_articles` with `wiki_slug` to browse a wiki's pages.",
132
176
  "",
@@ -138,5 +182,6 @@ export function createServer() {
138
182
  registerResearchTools(server);
139
183
  registerWikiTools(server);
140
184
  registerTopicTools(server);
185
+ registerUserTools(server);
141
186
  return server;
142
187
  }
@@ -93,19 +93,22 @@ export function registerPageTools(server) {
93
93
  include_stubs: z.boolean().default(true).describe("Include stubs in results"),
94
94
  }),
95
95
  async execute({ queries, wiki, limit, include_stubs }) {
96
- const results = {};
97
- for (const q of queries) {
98
- const params = {
99
- query: q, limit, type: "pages",
100
- };
101
- if (wiki)
102
- params.wiki = wiki;
103
- if (include_stubs)
104
- params.include_stubs = true;
105
- const resp = await request("GET", "/api/search", { params });
106
- results[q] = await resp.json();
96
+ // Single server round-trip via /api/search/batch (rate-limited 10/min).
97
+ // Backend fans out internally and returns [{query, results[], error?}, ...].
98
+ const body = {
99
+ queries,
100
+ limit,
101
+ include_stubs,
102
+ };
103
+ if (wiki)
104
+ body.wiki = wiki;
105
+ const resp = await request("POST", "/api/search/batch", { json: body });
106
+ const data = (await resp.json());
107
+ const byQuery = {};
108
+ for (const set of data.results) {
109
+ byQuery[set.query] = set.error ? { error: set.error } : set.results;
107
110
  }
108
- return JSON.stringify(results, null, 2);
111
+ return JSON.stringify(byQuery, null, 2);
109
112
  },
110
113
  });
111
114
  server.addTool({
@@ -280,8 +283,10 @@ export function registerPageTools(server) {
280
283
  continue;
281
284
  }
282
285
  okCount++;
283
- // Delete local .md and .ref
284
- const published_slug = r.renamed_from ? targetSlugs[results.indexOf(r)] : r.slug;
286
+ // The local file was named with the pre-rename slug. The server returns
287
+ // `renamed_from` on rename so we can clean up the right file without
288
+ // relying on request/response index parity.
289
+ const published_slug = r.renamed_from ?? r.slug;
285
290
  const { filePath, refPath } = resolvePagePaths(published_slug, wiki_slug);
286
291
  try {
287
292
  unlinkSync(filePath);
@@ -35,12 +35,13 @@ export function registerTopicTools(server) {
35
35
  wiki_slug: z.string().describe("Wiki slug"),
36
36
  title: z.string().describe("Topic title"),
37
37
  description: z.string().default("").describe("Topic description"),
38
+ image_url: z.string().url().max(2048).optional().describe("Topic image URL (https:// or http://)"),
38
39
  parent_slugs: coerceJson(z.array(z.string())).default([]).describe("Parent topic slugs"),
39
40
  }),
40
- async execute({ wiki_slug, title, description, parent_slugs }) {
41
+ async execute({ wiki_slug, title, description, image_url, parent_slugs }) {
41
42
  const resp = await request("POST", `/api/w/${wiki_slug}/topics`, {
42
43
  auth: true,
43
- json: { title, description, parent_slugs },
44
+ json: { title, description, image_url, parent_slugs },
44
45
  });
45
46
  return JSON.stringify(await resp.json(), null, 2);
46
47
  },
@@ -53,6 +54,7 @@ export function registerTopicTools(server) {
53
54
  topics: coerceJson(z.array(z.object({
54
55
  title: z.string(),
55
56
  description: z.string().default(""),
57
+ image_url: z.string().url().max(2048).optional(),
56
58
  parent_slugs: z.array(z.string()).default([]),
57
59
  })).min(1).max(100)).describe("Topics to create"),
58
60
  }),
@@ -0,0 +1,2 @@
1
+ import { FastMCP } from "fastmcp";
2
+ export declare function registerUserTools(server: FastMCP): void;
@@ -0,0 +1,13 @@
1
+ import { z } from "zod";
2
+ import { request } from "../auth.js";
3
+ export function registerUserTools(server) {
4
+ server.addTool({
5
+ name: "whoami",
6
+ description: "Return the current user's profile — username, display name, avatar. Use this to ground any \"my X\" decision (which wikis am I a member of, how should I address the user, etc.) before making assumptions. Requires login.",
7
+ parameters: z.object({}),
8
+ async execute() {
9
+ const resp = await request("GET", "/api/users/me", { auth: true });
10
+ return JSON.stringify(await resp.json(), null, 2);
11
+ },
12
+ });
13
+ }
@@ -13,10 +13,61 @@ function coerceJson(schema) {
13
13
  return val;
14
14
  }, schema);
15
15
  }
16
+ // Mirrors backend `NavItem` in src/schemas/wiki_settings_schemas.py. The
17
+ // refinement matches the `exactly_one_target` @model_validator there —
18
+ // agents get a clear error pre-flight instead of a 422 round-trip.
19
+ const navItemSchema = z.lazy(() => z.object({
20
+ label: z.string(),
21
+ page: z.string().optional(),
22
+ topic: z.string().optional(),
23
+ link: z.string().optional(),
24
+ children: z.array(navItemSchema).optional(),
25
+ auto: z.object({
26
+ enabled: z.boolean(),
27
+ include_subtopics: z.boolean().optional(),
28
+ include_pages: z.boolean().optional(),
29
+ subtopic_limit: z.number().int().positive().optional(),
30
+ page_limit: z.number().int().positive().optional(),
31
+ show_view_all: z.boolean().optional(),
32
+ hidden_topic_slugs: z.array(z.string()).optional(),
33
+ hidden_page_slugs: z.array(z.string()).optional(),
34
+ pinned: z.array(z.object({
35
+ kind: z.enum(["topic", "page"]),
36
+ slug: z.string(),
37
+ })).optional(),
38
+ }).optional(),
39
+ }).refine((item) => {
40
+ const targets = [item.page, item.topic, item.link].filter((t) => typeof t === "string" && t.trim().length > 0);
41
+ return targets.length === 1;
42
+ }, { message: "NavItem must have exactly one of page, topic, or link" }).refine((item) => !(item.auto && !item.topic), { message: "NavItem auto mode requires a topic target" }).refine((item) => !(item.auto && item.children && item.children.length > 0), { message: "NavItem cannot define children while auto mode is enabled" }));
43
+ // Mirrors backend `WikiTheme`. All fields are optional from the MCP side —
44
+ // the backend fills defaults. Extra keys are dropped (backend uses
45
+ // model_config extra="ignore").
46
+ const themeSchema = z.object({
47
+ accent_color: z.string().optional(),
48
+ name_font: z.string().optional(),
49
+ logo_url: z.string().optional(),
50
+ cover_tint_intensity: z.number().optional(),
51
+ logo_tint_intensity: z.number().optional(),
52
+ cover_y_offset: z.number().optional(),
53
+ }).passthrough();
16
54
  export function registerWikiTools(server) {
55
+ server.addTool({
56
+ name: "list_wikis",
57
+ description: "List every wiki on OpenAlmanac. Use before creating a new wiki so you can suggest contributing to an existing one. The global almanac has slug `global` and is excluded by default — pass `include_global: true` to include it. No authentication needed.",
58
+ parameters: z.object({
59
+ include_global: z.boolean().default(false).describe("Include the global almanac wiki in results"),
60
+ }),
61
+ async execute({ include_global }) {
62
+ const resp = await request("GET", "/api/wikis", {
63
+ params: { include_global },
64
+ });
65
+ return JSON.stringify(await resp.json(), null, 2);
66
+ },
67
+ });
17
68
  server.addTool({
18
69
  name: "create_wiki",
19
- description: "Create a new wiki. Returns the wiki slug and URL. Requires login.",
70
+ description: "Create a new wiki. Returns the wiki slug and URL. A welcome `main-page` is auto-created with default homepage directives — download it and edit in place rather than scaffolding a fresh `main-page` (the server derives slug from title, so a new scaffold would get a different slug). Requires login.",
20
71
  parameters: z.object({
21
72
  title: z.string().describe("Wiki title"),
22
73
  description: z.string().default("").describe("Wiki description"),
@@ -27,7 +78,7 @@ export function registerWikiTools(server) {
27
78
  json: { title, description },
28
79
  });
29
80
  const wiki = (await resp.json());
30
- return `Created wiki "${wiki.title}" at /w/${wiki.slug}`;
81
+ return `Created wiki "${wiki.title}" at /w/${wiki.slug}. The homepage was auto-scaffolded at slug "main-page" — download it to edit.`;
31
82
  },
32
83
  });
33
84
  server.addTool({
@@ -43,19 +94,13 @@ export function registerWikiTools(server) {
43
94
  });
44
95
  server.addTool({
45
96
  name: "update_wiki_settings",
46
- description: "Update a wiki's settings (nav, cover_image_url, theme). Requires moderator access.",
97
+ description: "Update a wiki's settings (nav, cover_image_url, theme). Each NavItem must have exactly one of `page`, `topic`, or `link`. Use `auto` (only on topic items) to auto-populate children from the topic DAG. Requires moderator access.",
47
98
  parameters: z.object({
48
99
  wiki_slug: z.string().describe("Wiki slug"),
49
100
  settings: coerceJson(z.object({
50
- nav: z.array(z.object({
51
- label: z.string(),
52
- page: z.string().optional(),
53
- topic: z.string().optional(),
54
- link: z.string().optional(),
55
- children: z.array(z.any()).optional(),
56
- })).optional(),
101
+ nav: z.array(navItemSchema).optional(),
57
102
  cover_image_url: z.string().optional(),
58
- theme: z.record(z.unknown()).optional(),
103
+ theme: themeSchema.optional(),
59
104
  })).describe("Settings to update"),
60
105
  }),
61
106
  async execute({ wiki_slug, settings }) {
@@ -71,22 +116,16 @@ export function registerWikiTools(server) {
71
116
  description: "Update just the navigation tree for a wiki. Shorthand for updating settings.nav. Requires moderator access.",
72
117
  parameters: z.object({
73
118
  wiki_slug: z.string().describe("Wiki slug"),
74
- nav: coerceJson(z.array(z.object({
75
- label: z.string(),
76
- page: z.string().optional(),
77
- topic: z.string().optional(),
78
- link: z.string().optional(),
79
- children: z.array(z.any()).optional(),
80
- }))).describe("Nav items"),
119
+ nav: coerceJson(z.array(navItemSchema)).describe("Nav items"),
81
120
  }),
82
121
  async execute({ wiki_slug, nav }) {
83
- // Get current settings, merge nav
84
- const getResp = await request("GET", `/api/w/${wiki_slug}`);
85
- const wiki = (await getResp.json());
86
- const settings = { ...wiki.settings, nav };
122
+ // Single atomic PATCH the backend's update_settings service uses
123
+ // model_dump(exclude_unset=True) to merge partial bodies, so sending
124
+ // only {nav} doesn't touch theme or cover_image_url. Previously this
125
+ // did a GET-then-PATCH, which raced with concurrent settings updates.
87
126
  const resp = await request("PATCH", `/api/w/${wiki_slug}/settings`, {
88
127
  auth: true,
89
- json: settings,
128
+ json: { nav },
90
129
  });
91
130
  return JSON.stringify(await resp.json(), null, 2);
92
131
  },