openalmanac 0.2.59 → 0.3.2

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/cli.js CHANGED
File without changes
package/dist/setup.js CHANGED
@@ -166,6 +166,7 @@ const SUPPORTED_CLIENTS = {
166
166
  "claude-code": {
167
167
  id: "claude-code",
168
168
  name: "Claude Code",
169
+ selectionLabel: "Claude Code",
169
170
  detect: () => hasCommand("claude") || existsSync(CLAUDE_JSON) || existsSync(CLAUDE_DIR),
170
171
  configure: (mode) => {
171
172
  const snippets = [
@@ -195,6 +196,7 @@ const SUPPORTED_CLIENTS = {
195
196
  "claude-desktop": {
196
197
  id: "claude-desktop",
197
198
  name: "Claude Desktop",
199
+ selectionLabel: "Claude Desktop",
198
200
  detect: () => {
199
201
  const path = getClaudeDesktopConfigPath();
200
202
  return Boolean(path && (existsSync(path) || isClaudeDesktopInstalled()));
@@ -221,6 +223,7 @@ const SUPPORTED_CLIENTS = {
221
223
  codex: {
222
224
  id: "codex",
223
225
  name: "Codex",
226
+ selectionLabel: "Codex",
224
227
  detect: () => hasCommand("codex") || existsSync(CODEX_CONFIG) || existsSync(join(homedir(), ".codex")),
225
228
  configure: (mode) => ({
226
229
  changed: configureCodexToml(CODEX_CONFIG, mode),
@@ -235,6 +238,7 @@ const SUPPORTED_CLIENTS = {
235
238
  cursor: {
236
239
  id: "cursor",
237
240
  name: "Cursor",
241
+ selectionLabel: "Cursor",
238
242
  detect: () => hasCommand("cursor-agent") ||
239
243
  existsSync(CURSOR_MCP_JSON) ||
240
244
  existsSync(join(homedir(), ".cursor")),
@@ -255,6 +259,7 @@ const SUPPORTED_CLIENTS = {
255
259
  windsurf: {
256
260
  id: "windsurf",
257
261
  name: "Windsurf",
262
+ selectionLabel: "Windsurf",
258
263
  detect: () => hasCommand("windsurf") ||
259
264
  existsSync(WINDSURF_MCP_JSON) ||
260
265
  existsSync(join(homedir(), ".codeium")),
@@ -550,29 +555,30 @@ function configurePermissions(tools) {
550
555
  writeJson(SETTINGS_JSON, settings);
551
556
  return tools.length;
552
557
  }
553
- /* ── Agent selection screen ─────────────────────────────────────── */
554
- function renderAgentSelect(_cursor, mode = "default") {
558
+ /* ── Client selection screen ────────────────────────────────────── */
559
+ function renderClientSelect(clients, selected, cursor, mode = "default") {
555
560
  process.stdout.write("\x1b[2J\x1b[H");
556
561
  renderHeader(mode);
557
562
  printBadge();
558
563
  w("");
559
- stepActive(`Select your agent`);
564
+ stepActive(`Select where to install Almanac`);
560
565
  w(BAR);
561
- for (const agent of AGENTS) {
562
- if (agent.supported) {
563
- w(` ${DIM}\u2502${RST} ${BLUE}\u276f${RST} ${BLUE}\u25cf${RST} ${BOLD}${agent.name}${RST}`);
564
- }
565
- else {
566
- w(` ${DIM}\u2502${RST} ${DIM}\u25cb ${agent.name}${" "}coming soon${RST}`);
567
- }
566
+ for (let i = 0; i < clients.length; i++) {
567
+ const client = clients[i];
568
+ const arrow = i === cursor ? `${BLUE}\u276f${RST}` : " ";
569
+ const check = selected[i] ? `${BLUE}\u2713${RST}` : " ";
570
+ const label = i === cursor ? `${BOLD}${client.selectionLabel ?? client.name}${RST}` : client.selectionLabel ?? client.name;
571
+ w(` ${DIM}\u2502${RST} ${arrow} [${check}] ${label}`);
568
572
  }
569
573
  w(BAR);
570
- w(` ${DIM}\u2502${RST} ${BLUE}${BOLD}[enter]${RST} confirm ${DIM}[q] quit${RST}`);
574
+ w(` ${DIM}\u2502${RST} ${BLUE}${BOLD}[space]${RST} toggle ${BLUE}${BOLD}[\u2191\u2193]${RST} move ${BLUE}${BOLD}[a]${RST} all ${BLUE}${BOLD}[enter]${RST} confirm ${DIM}[q] quit${RST}`);
571
575
  w("");
572
576
  }
573
- function runAgentSelect(mode = "default") {
577
+ function runClientSelect(clients, mode = "default") {
574
578
  return new Promise((resolve) => {
575
- renderAgentSelect(0, mode);
579
+ const selected = clients.map(() => true);
580
+ let cursor = 0;
581
+ renderClientSelect(clients, selected, cursor, mode);
576
582
  process.stdin.setRawMode(true);
577
583
  process.stdin.resume();
578
584
  process.stdin.setEncoding("utf-8");
@@ -588,16 +594,38 @@ function runAgentSelect(mode = "default") {
588
594
  console.log("\n Setup cancelled.\n");
589
595
  process.exit(0);
590
596
  }
591
- if (key === "\r" || key === "\n") {
597
+ if (key === "\x1b[A" || key === "k") {
598
+ cursor = (cursor - 1 + clients.length) % clients.length;
599
+ }
600
+ else if (key === "\x1b[B" || key === "j") {
601
+ cursor = (cursor + 1) % clients.length;
602
+ }
603
+ else if (key === " ") {
604
+ selected[cursor] = !selected[cursor];
605
+ }
606
+ else if (key === "a") {
607
+ const all = selected.every(Boolean);
608
+ selected.fill(!all);
609
+ }
610
+ else if (key === "\r" || key === "\n") {
592
611
  cleanup();
593
- const supported = AGENTS.find((a) => a.supported);
594
- resolve(supported.name);
612
+ const chosen = clients.filter((_, index) => selected[index]);
613
+ if (chosen.length === 0) {
614
+ console.log("\n Select at least one client.\n");
615
+ process.exit(1);
616
+ }
617
+ resolve(chosen);
595
618
  return;
596
619
  }
620
+ renderClientSelect(clients, selected, cursor, mode);
597
621
  };
598
622
  process.stdin.on("data", onData);
599
623
  });
600
624
  }
625
+ async function runAgentSelect(mode = "default") {
626
+ const [client] = await runClientSelect([SUPPORTED_CLIENTS["claude-code"]], mode);
627
+ return client.name;
628
+ }
601
629
  /* ── Login step ─────────────────────────────────────────────────── */
602
630
  function loginLabel(result) {
603
631
  if (result.status === "already")
@@ -780,16 +808,16 @@ async function runLoginStep(agent, mcpChanged, toolCount, mode = "default") {
780
808
  }
781
809
  /* ── Tool permissions TUI ───────────────────────────────────────── */
782
810
  const MAX_NAME = Math.max(...TOOL_GROUPS.map((g) => g.name.length));
783
- function renderToolSelect(selected, cursor, agent, mcpChanged, mode = "default") {
811
+ function renderToolSelect(selected, cursor, clientsLabel, mcpChanged, mode = "default") {
784
812
  process.stdout.write("\x1b[2J\x1b[H");
785
813
  renderHeader(mode);
786
814
  printBadge();
787
815
  w("");
788
- stepDone(`Agent \u2192 ${WHITE_BOLD}${agent}${RST}`);
816
+ stepDone(`Clients \u2192 ${WHITE_BOLD}${clientsLabel}${RST}`);
789
817
  w(BAR);
790
818
  stepDone(`MCP server ${mcpChanged ? "configured" : `${DIM}already configured${RST}`}`);
791
819
  w(BAR);
792
- stepActive(`Select tool permissions ${DIM}deselect any you'd rather approve manually${RST}`);
820
+ stepActive(`Select Claude Code tool permissions ${DIM}deselect any you'd rather approve manually${RST}`);
793
821
  w(BAR);
794
822
  for (let i = 0; i < TOOL_GROUPS.length; i++) {
795
823
  const arrow = i === cursor ? `${BLUE}\u276f${RST}` : " ";
@@ -803,11 +831,11 @@ function renderToolSelect(selected, cursor, agent, mcpChanged, mode = "default")
803
831
  w(` ${DIM}\u2502${RST} ${BLUE}${BOLD}[space]${RST} toggle ${BLUE}${BOLD}[\u2191\u2193]${RST} move ${BLUE}${BOLD}[a]${RST} all ${BLUE}${BOLD}[enter]${RST} confirm ${DIM}[q] quit${RST}`);
804
832
  w("");
805
833
  }
806
- function runToolSelect(agent, mcpChanged, mode = "default") {
834
+ function runToolSelect(clientsLabel, mcpChanged, mode = "default") {
807
835
  return new Promise((resolve) => {
808
836
  const selected = TOOL_GROUPS.map(() => true);
809
837
  let cursor = 0;
810
- renderToolSelect(selected, cursor, agent, mcpChanged, mode);
838
+ renderToolSelect(selected, cursor, clientsLabel, mcpChanged, mode);
811
839
  process.stdin.setRawMode(true);
812
840
  process.stdin.resume();
813
841
  process.stdin.setEncoding("utf-8");
@@ -843,7 +871,7 @@ function runToolSelect(agent, mcpChanged, mode = "default") {
843
871
  resolve(tools);
844
872
  return;
845
873
  }
846
- renderToolSelect(selected, cursor, agent, mcpChanged, mode);
874
+ renderToolSelect(selected, cursor, clientsLabel, mcpChanged, mode);
847
875
  };
848
876
  process.stdin.on("data", onData);
849
877
  });
@@ -926,7 +954,7 @@ function getNextSteps(clientsLabel) {
926
954
  /* ── Entry point ────────────────────────────────────────────────── */
927
955
  export async function runSetup() {
928
956
  const options = parseSetupArgs(process.argv.slice(3));
929
- const clients = resolveClients(options);
957
+ let clients = resolveClients(options);
930
958
  if (options.print || options.dryRun) {
931
959
  printSetupPlan(clients, options);
932
960
  process.exit(0);
@@ -937,13 +965,17 @@ export async function runSetup() {
937
965
  }
938
966
  const skipTui = options.yes;
939
967
  const interactive = process.stdin.isTTY && !skipTui;
968
+ if (interactive && options.clients.length === 0) {
969
+ clients = await runClientSelect(clients);
970
+ }
971
+ const clientsLabel = clients.map((client) => client.name).join(", ");
940
972
  const setupSummary = applyClientSetup(clients, "apply");
941
973
  const permissionClient = clients.find((client) => client.supportsPermissions);
942
974
  let tools = [];
943
975
  if (permissionClient) {
944
976
  const mcpChanged = setupSummary.configured.includes(permissionClient.name);
945
977
  if (interactive) {
946
- tools = await runToolSelect(permissionClient.name, mcpChanged);
978
+ tools = await runToolSelect(clientsLabel, mcpChanged);
947
979
  }
948
980
  else {
949
981
  tools = TOOL_GROUPS.flatMap((g) => g.tools);
@@ -952,7 +984,6 @@ export async function runSetup() {
952
984
  const count = tools.length > 0 ? configurePermissions(tools) : 0;
953
985
  const permissionCount = permissionClient ? count : null;
954
986
  let loginResult;
955
- const clientsLabel = clients.map((client) => client.name).join(", ");
956
987
  if (interactive) {
957
988
  loginResult = await runLoginStep(clientsLabel, setupSummary.configured.length > 0, permissionCount);
958
989
  }
@@ -20,30 +20,79 @@ function coerceJson(schema) {
20
20
  }, schema);
21
21
  }
22
22
  export function registerResearchTools(server) {
23
+ // Discriminated union on `source` — each source's schema only surfaces its own
24
+ // options, so agents get typed autocomplete instead of an opaque options bag.
25
+ //
26
+ // Adding a new source (e.g. hackernews) = append another z.object variant with
27
+ // its own `source: z.literal(...)` and per-source fields. No existing schema
28
+ // changes.
29
+ const WebSearchInput = z.object({
30
+ source: z.literal("web").describe("Generic web search via Google/Serper. Use for general references, news, docs."),
31
+ query: z.string().min(1).describe("Search terms. Supports quoted phrases and site: operators."),
32
+ limit: z.number().int().min(1).max(20).default(10).describe("Max results (1-20, default 10)."),
33
+ });
34
+ const RedditSearchInput = z.object({
35
+ source: z.literal("reddit").describe("Search Reddit — use when the user wants community perspectives, subreddit consensus, lived experiences, or ranked-by-engagement content. Goes through a residential proxy so it sees past Reddit's anti-scraping."),
36
+ subreddit: z.string().optional().describe("Subreddit name without the 'r/' prefix (e.g. 'Harvard'). Omit to search across all of Reddit. Case-insensitive."),
37
+ query: z.string().optional().describe("Optional full-text search terms. Omit to return the subreddit's sorted listing (top posts of the year, etc.)."),
38
+ sort: z.enum(["top", "hot", "new", "rising", "controversial", "relevance", "comments"])
39
+ .default("top")
40
+ .describe("For listings: 'top'/'hot'/'new'/'rising'/'controversial'. For searches: 'relevance'/'top'/'new'/'comments'. Default 'top'."),
41
+ time_range: z.enum(["hour", "day", "week", "month", "year", "all"])
42
+ .default("year")
43
+ .describe("Time window for top/controversial listings and all searches. Default 'year'."),
44
+ limit: z.number().int().min(1).max(100).default(25).describe("Max posts to return (1-100, default 25)."),
45
+ });
23
46
  server.addTool({
24
47
  name: "search_web",
25
- description: "Search the web for sources to cite in articles. Use this to find references before writing. Requires API key. Rate limit: 10/min.",
26
- parameters: z.object({
27
- query: z.string().describe("Search terms"),
28
- limit: z.number().default(10).describe("Max results (1-20, default 10)"),
29
- }),
30
- async execute({ query, limit }) {
48
+ description: "Search the web or a specific community source (Reddit). Discriminated on `source`:\n\n" +
49
+ "- `source: \"web\"` — general web search via Google. Use for news, docs, scholarly references.\n" +
50
+ "- `source: \"reddit\"` — Reddit-aware search returning posts with score, flair, num_comments, permalink. " +
51
+ "Use when the user is asking about community perspectives, subreddit consensus, or 'what do people think about X'.\n\n" +
52
+ "Each source exposes its own parameters — follow the schema for the source you pick. " +
53
+ "Rate limit: 10/min. Requires API key.",
54
+ parameters: z.discriminatedUnion("source", [WebSearchInput, RedditSearchInput]),
55
+ async execute(input) {
56
+ if (input.source === "reddit") {
57
+ const params = {
58
+ sort: input.sort,
59
+ time_range: input.time_range,
60
+ limit: input.limit,
61
+ };
62
+ if (input.subreddit)
63
+ params.subreddit = input.subreddit;
64
+ if (input.query)
65
+ params.query = input.query;
66
+ const resp = await request("GET", "/api/research/reddit/search", {
67
+ auth: true,
68
+ params,
69
+ });
70
+ return JSON.stringify(await resp.json(), null, 2);
71
+ }
31
72
  const resp = await request("GET", "/api/research/search", {
32
73
  auth: true,
33
- params: { query, limit },
74
+ params: { query: input.query, limit: input.limit },
34
75
  });
35
76
  return JSON.stringify(await resp.json(), null, 2);
36
77
  },
37
78
  });
38
79
  server.addTool({
39
80
  name: "read_webpage",
40
- 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.",
81
+ description: "Fetch a URL and return its content as markdown. Routes automatically based on URL:\n" +
82
+ "- **Reddit threads** (reddit.com/r/{sub}/comments/{id}/...) — returns the post plus top-level threaded comments with scores and authors, via a residential proxy.\n" +
83
+ "- **YouTube videos** — returns title, description, transcript when available.\n" +
84
+ "- **PDFs** — extracts text.\n" +
85
+ "- **LinkedIn posts/profiles** — uses the LinkedIn scraper.\n" +
86
+ "- **Everything else** — generic web scrape with Firecrawl/Jina fallback.\n\n" +
87
+ "Requires API key. Rate limit: 5/min.",
41
88
  parameters: z.object({
42
- url: z.string().describe("URL to read"),
89
+ url: z.string().url().describe("Full URL to read. For Reddit threads, use the canonical reddit.com/r/{sub}/comments/{id}/... form."),
43
90
  max_length: z
44
91
  .number()
92
+ .int()
93
+ .min(1000)
45
94
  .default(20000)
46
- .describe("Max characters to return (default 20000). Use higher values for long-form sources."),
95
+ .describe("Max characters to return (default 20000). Increase for long-form sources; response is truncated with a note when it hits the cap."),
47
96
  }),
48
97
  async execute({ url, max_length }) {
49
98
  const resp = await request("GET", "/api/research/read", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openalmanac",
3
- "version": "0.2.59",
3
+ "version": "0.3.2",
4
4
  "description": "OpenAlmanac — pull, edit, and push articles to the open knowledge base",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,2 +0,0 @@
1
- import { FastMCP } from "fastmcp";
2
- export declare function registerArticleTools(server: FastMCP): void;
@@ -1,401 +0,0 @@
1
- import { z } from "zod";
2
- import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync, unlinkSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { stringify as yamlStringify } from "yaml";
5
- import { request, ARTICLES_DIR, getAuthStatus } from "../auth.js";
6
- import { validateArticle, parseFrontmatter } from "../validate.js";
7
- import { openBrowser } from "../browser.js";
8
- const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
9
- const WRITING_GUIDE = `
10
- ## Article structure
11
-
12
- \`\`\`yaml
13
- ---
14
- article_id: the-slug
15
- title: Article Title
16
- sources:
17
- - key: example-source-title
18
- url: https://example.com
19
- title: Source Title
20
- accessed_date: "2025-01-15"
21
- infobox:
22
- header:
23
- image_url: https://... # optional hero image
24
- subtitle: Short tagline
25
- details:
26
- - key: Born
27
- value: January 1, 1990
28
- - key: Occupation
29
- value: Scientist
30
- links:
31
- - https://example.com
32
- sections:
33
- - type: timeline # chronological events
34
- title: Career Timeline
35
- items:
36
- - primary: "Started company"
37
- period: "2010"
38
- location: "San Francisco"
39
- - type: list # key figures, works, features
40
- title: Known For
41
- items:
42
- - title: First achievement
43
- - title: Second achievement
44
- subtitle: Additional detail
45
- - type: tags # inline tags/chips
46
- title: Genres
47
- items:
48
- - Rock
49
- - Jazz
50
- - type: grid # image grid
51
- title: Gallery
52
- items:
53
- - title: Caption
54
- image_url: https://...
55
- - type: table # structured comparison
56
- title: Statistics
57
- items:
58
- headers:
59
- - Name
60
- - Value
61
- rows:
62
- - cells:
63
- - Height
64
- - "6'2\\""
65
- - type: key_value # simple key-value pairs
66
- title: Quick Facts
67
- items:
68
- - key: Population
69
- value: "1.4 billion"
70
- ---
71
-
72
- Article body with [@key] citation markers...
73
- \`\`\`
74
-
75
- ## Infobox
76
-
77
- Include an infobox for any article about a person, place, organization, event, or concept. Pick the section types that fit — you don't need all six.
78
-
79
- ## Citations
80
-
81
- - Mark claims with [@key] after punctuation: "The population is 1.4 billion.[@who-world-population]"
82
- - Keys must be kebab-case with at least one hyphen (e.g. 'nytimes-climate-report', 'who-malaria-2024')
83
- - Generate keys BibTeX-style: {domain}-{title-words} (e.g. 'arxiv-attention-is-all')
84
- - Every source in the sources list must be referenced at least once in the body with [@key]
85
- - Every [@key] marker must have a matching source with that key
86
- - Display numbers are computed automatically from first-appearance order — just use the keys
87
-
88
- ## Images
89
-
90
- Use search_images to find relevant images. Images render as figures with visible captions.
91
-
92
- **Syntax:** \`![Caption text](url "position")\`
93
-
94
- Positions: \`"right"\` (default if omitted), \`"left"\`, \`"center"\`
95
-
96
- \`\`\`markdown
97
- ![Alan Turing in 1930, aged 18](https://upload.wikimedia.org/...)
98
-
99
- The early life of Alan Turing began...
100
-
101
- ![Panoramic view of Bletchley Park](https://upload.wikimedia.org/... "center")
102
- \`\`\`
103
-
104
- **Caption rules:**
105
- - Every image MUST have a descriptive caption — it is displayed below the image
106
- - Describe what the image shows: "Alan Turing in 1930, aged 18" not "Photo"
107
- - Include dates, context, or attribution when relevant
108
-
109
- **Placement:** 1-3 images per major section, spread throughout. First image near the top.
110
- For the infobox hero image, use \`infobox.header.image_url\` in frontmatter instead.
111
-
112
- External image URLs are auto-persisted on publish — no extra steps needed.
113
-
114
- ## Writing quality
115
-
116
- - Every sentence should contain a specific fact the reader didn't know
117
- - No filler phrases ("It is worth noting", "In today's world", "Throughout history")
118
- - No promotional language ("revolutionary", "groundbreaking", "game-changing")
119
- - No inflated significance ("one of the most important", "changed the world forever")
120
- - No vague attribution ("many experts say", "it is widely regarded")
121
- - No formulaic conclusions ("In conclusion", "continues to shape")
122
- - Write like a concise encyclopedia, not a blog post
123
- `.trim();
124
- function ensureArticlesDir() {
125
- mkdirSync(ARTICLES_DIR, { recursive: true });
126
- }
127
- export function registerArticleTools(server) {
128
- server.addTool({
129
- name: "search_articles",
130
- description: "Search existing OpenAlmanac articles and stubs. Accepts multiple queries for batch lookup. " +
131
- "Use this to check if articles or stubs exist before creating them, or to find entity slugs for wikilinks. " +
132
- "Results include 'stub' field (true/false) and 'entity_type' field. No authentication needed.",
133
- parameters: z.object({
134
- queries: z.array(z.string()).min(1).max(20).describe("Search queries (1-20)"),
135
- limit: z.number().default(5).describe("Max results per query (1-50, default 5)"),
136
- include_stubs: z.boolean().default(true).describe("Include stub articles in results (default true)"),
137
- }),
138
- async execute({ queries, limit, include_stubs }) {
139
- const resp = await request("POST", "/api/search/batch", {
140
- json: { queries, limit, include_stubs },
141
- });
142
- return JSON.stringify(await resp.json(), null, 2);
143
- },
144
- });
145
- server.addTool({
146
- name: "read",
147
- description: "Read article content from OpenAlmanac. Returns the content, sources, and metadata for each slug. " +
148
- "Use this to reference or summarize existing articles in conversation. " +
149
- "For editing articles locally, use 'download' instead. No authentication needed.",
150
- parameters: z.object({
151
- slugs: z.array(z.string()).min(1).max(20).describe("Article slugs to read (1-20)"),
152
- }),
153
- async execute({ slugs }) {
154
- const resp = await request("POST", "/api/articles/batch", {
155
- json: { slugs },
156
- });
157
- return JSON.stringify(await resp.json(), null, 2);
158
- },
159
- });
160
- server.addTool({
161
- name: "create_stubs",
162
- description: "Create stub articles — placeholders for entities that don't have full articles yet. " +
163
- "Use this for every entity (person, organization, topic, etc.) mentioned in an article. " +
164
- "Idempotent: existing slugs return their current status. Requires login.",
165
- parameters: z.object({
166
- stubs: z.array(z.object({
167
- slug: z
168
- .string()
169
- .min(1)
170
- .max(500)
171
- .describe("Unique kebab-case identifier. For people with LinkedIn: use their vanity ID (e.g. 'john-smith-4a8b2c1'). " +
172
- "For others: descriptive kebab-case (e.g. 'reinforcement-learning', 'openai')"),
173
- title: z.string().describe("Display title (e.g. 'John Smith', 'Reinforcement Learning')"),
174
- entity_type: z
175
- .enum(["person", "organization", "topic", "event", "creative_work", "place"])
176
- .optional()
177
- .describe("Entity type: 'person' for individuals, 'organization' for companies/institutions/nonprofits, " +
178
- "'topic' for concepts/fields/technologies, 'event' for conferences/historical events, " +
179
- "'creative_work' for books/papers/films/software, 'place' for cities/countries/landmarks"),
180
- headline: z
181
- .string()
182
- .optional()
183
- .describe("Short headline (e.g. 'Professor of CS at MIT', 'AI research laboratory')"),
184
- image_url: z.string().url().optional().describe("Image URL for the entity"),
185
- summary: z
186
- .string()
187
- .optional()
188
- .describe("2-4 sentence summary of the entity. This becomes the stub page content. " +
189
- "Be informative — include key facts, dates, and context."),
190
- })).min(1).max(50).describe("Stubs to create (1-50)"),
191
- }),
192
- async execute({ stubs }) {
193
- const resp = await request("POST", "/api/articles/stubs", {
194
- auth: true,
195
- json: { stubs },
196
- });
197
- return JSON.stringify(await resp.json(), null, 2);
198
- },
199
- });
200
- server.addTool({
201
- name: "download",
202
- description: "Download an article to your local workspace for editing. The file is saved to ~/.openalmanac/articles/{slug}.md " +
203
- "with YAML frontmatter. Returns a writing guide covering article structure, infobox format, citations, and quality rules. " +
204
- "After editing, use 'publish' to push your changes live.",
205
- parameters: z.object({
206
- slug: z.string().describe("Article slug (e.g. 'machine-learning')"),
207
- }),
208
- async execute({ slug }) {
209
- if (!SLUG_RE.test(slug)) {
210
- throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning').`);
211
- }
212
- const resp = await request("GET", `/api/articles/${slug}`, {
213
- params: { format: "md" },
214
- });
215
- const markdown = await resp.text();
216
- ensureArticlesDir();
217
- const filePath = join(ARTICLES_DIR, `${slug}.md`);
218
- writeFileSync(filePath, markdown, "utf-8");
219
- const originalPath = join(ARTICLES_DIR, `.${slug}.original.md`);
220
- writeFileSync(originalPath, markdown, "utf-8");
221
- const { frontmatter, content } = parseFrontmatter(markdown);
222
- const title = frontmatter.title || "(untitled)";
223
- const wordCount = content.trim().split(/\s+/).filter(Boolean).length;
224
- const isStub = frontmatter.stub === true;
225
- const stubNote = isStub
226
- ? "\n\nThis is a STUB article — a placeholder that hasn't been fully written yet. " +
227
- "Fill in the content body with a complete article, then push to publish."
228
- : "";
229
- return `Downloaded "${title}" to ${filePath}\n${wordCount} words, ${frontmatter.sources?.length ?? 0} sources.${stubNote}\n\n${WRITING_GUIDE}`;
230
- },
231
- });
232
- server.addTool({
233
- name: "new",
234
- description: "Create a new article scaffold in your local working directory (~/.openalmanac/articles/). " +
235
- "The file is created with YAML frontmatter and an empty body. Returns a writing guide covering article structure, infobox format, citations, and quality rules. " +
236
- "Edit the file to add content and sources, then use publish to go live.",
237
- parameters: z.object({
238
- slug: z
239
- .string()
240
- .describe("Unique kebab-case identifier (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$"),
241
- title: z.string().describe("Article title"),
242
- }),
243
- async execute({ slug, title }) {
244
- if (!SLUG_RE.test(slug)) {
245
- throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$`);
246
- }
247
- ensureArticlesDir();
248
- const filePath = join(ARTICLES_DIR, `${slug}.md`);
249
- if (existsSync(filePath)) {
250
- throw new Error(`File already exists: ${filePath}\nUse download to refresh it, or publish to push changes.`);
251
- }
252
- const frontmatter = yamlStringify({ article_id: slug, title, sources: [] });
253
- const scaffold = `---\n${frontmatter}---\n\n`;
254
- writeFileSync(filePath, scaffold, "utf-8");
255
- return `Created ${filePath}\n\n${WRITING_GUIDE}`;
256
- },
257
- });
258
- server.addTool({
259
- name: "publish",
260
- description: "Validate and publish an article from your local workspace. Reads ~/.openalmanac/articles/{slug}.md, " +
261
- "validates content and sources, and publishes to OpenAlmanac. Requires login.",
262
- parameters: z.object({
263
- slug: z.string().describe("Article slug matching the filename (without .md)"),
264
- change_title: z.string().optional().describe("Short title for the change (e.g. 'Added early life section')"),
265
- change_description: z.string().optional().describe("Longer description of what changed and why"),
266
- }),
267
- async execute({ slug, change_title, change_description }) {
268
- if (!SLUG_RE.test(slug)) {
269
- throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning').`);
270
- }
271
- const filePath = join(ARTICLES_DIR, `${slug}.md`);
272
- let raw;
273
- try {
274
- raw = readFileSync(filePath, "utf-8");
275
- }
276
- catch {
277
- throw new Error(`File not found: ${filePath}\nUse download to get an existing article or new to create a scaffold.`);
278
- }
279
- // Local validation
280
- const errors = validateArticle(raw);
281
- if (errors.length > 0) {
282
- const lines = errors.map((e) => ` ${e.field}: ${e.message}`);
283
- throw new Error(`Validation failed (${errors.length} error${errors.length > 1 ? "s" : ""}):\n${lines.join("\n")}\n\nFix the file and try again.`);
284
- }
285
- // Inject change_title/change_description into frontmatter if provided
286
- let body = raw;
287
- if (change_title || change_description) {
288
- const { frontmatter, content } = parseFrontmatter(raw);
289
- if (change_title)
290
- frontmatter.change_title = change_title;
291
- if (change_description)
292
- frontmatter.change_description = change_description;
293
- const newFrontmatter = yamlStringify(frontmatter);
294
- body = `---\n${newFrontmatter}---\n${content}`;
295
- }
296
- const resp = await request("PUT", `/api/articles/${slug}`, {
297
- auth: true,
298
- body,
299
- contentType: "text/markdown",
300
- });
301
- const data = (await resp.json());
302
- const articleUrl = `https://www.openalmanac.org/article/${slug}?celebrate=true`;
303
- openBrowser(articleUrl);
304
- // Clean up local files after successful publish
305
- let cleanupWarning = "";
306
- try {
307
- unlinkSync(filePath);
308
- }
309
- catch (e) {
310
- if (e.code !== "ENOENT") {
311
- cleanupWarning = `\nNote: could not remove local draft: ${e.message}`;
312
- }
313
- }
314
- try {
315
- unlinkSync(join(ARTICLES_DIR, `.${slug}.original.md`));
316
- }
317
- catch (e) {
318
- if (e.code !== "ENOENT") {
319
- cleanupWarning += `\nNote: could not remove original copy: ${e.message}`;
320
- }
321
- }
322
- return `Pushed successfully.\n\nArticle URL (share this exact link with the user): ${articleUrl}${cleanupWarning}\n\n${JSON.stringify(data, null, 2)}`;
323
- },
324
- });
325
- server.addTool({
326
- name: "requested_articles",
327
- description: "List requested articles — stubs that are referenced by the most articles but haven't been fully written yet. " +
328
- "Use this to find high-demand topics to write about. No authentication needed.",
329
- parameters: z.object({
330
- limit: z.number().default(20).describe("Max results (1-200, default 20)"),
331
- offset: z.number().default(0).describe("Pagination offset (default 0)"),
332
- }),
333
- async execute({ limit, offset }) {
334
- const resp = await request("GET", "/api/articles/requested", {
335
- params: { limit, offset },
336
- });
337
- return JSON.stringify(await resp.json(), null, 2);
338
- },
339
- });
340
- server.addTool({
341
- name: "propose_article",
342
- description: "Propose an article before writing it. Call this when you've researched enough and a specific article topic has come into focus. " +
343
- "Structures your proposal with a user-facing summary and a detailed brief. " +
344
- "The client environment determines what happens next — in GUI environments the user sees a plan card with options, " +
345
- "in CLI environments you'll get a response telling you to proceed with writing. " +
346
- "Do not start writing an article without proposing first.",
347
- parameters: z.object({
348
- summary: z.string().describe("User-facing summary: title, key sections, angle. Markdown. Concise — 3-5 bullet points."),
349
- details: z.string().describe("Full handoff brief for the background agent. Include: all sources, key facts, user preferences, angle, what to avoid, related articles. Be thorough."),
350
- title: z.string().describe("Proposed article title"),
351
- slug: z.string().describe("Proposed article slug (kebab-case)"),
352
- _userChoice: z.enum(["background", "here", "expired", "already_in_progress"]).optional().describe("Internal field set by GUI client. Never set this manually."),
353
- }),
354
- async execute({ summary, details, title, slug, _userChoice }) {
355
- if (!SLUG_RE.test(slug)) {
356
- throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$`);
357
- }
358
- if (_userChoice === "background") {
359
- return `Article "${title}" is now being written in a background process. Continue exploring with the user. Do not write this article in this conversation.`;
360
- }
361
- if (_userChoice === "expired") {
362
- return `The user navigated away before responding to the proposal. Proposal expired. Continue the conversation naturally.`;
363
- }
364
- if (_userChoice === "already_in_progress") {
365
- return `Article "${title}" is already being generated in a background process. No action needed.`;
366
- }
367
- // "here" OR no _userChoice (CLI default) — proceed with writing
368
- return `Article Proposal: ${title}\n\n${summary}\n\nProceed with writing this article following the writing flow in your instructions.`;
369
- },
370
- });
371
- server.addTool({
372
- name: "status",
373
- description: "Show login status and list all article files in your local working directory (~/.openalmanac/articles/). " +
374
- "Shows auth state, filename, title, file size, and last modified time.",
375
- async execute() {
376
- ensureArticlesDir();
377
- const auth = await getAuthStatus();
378
- const authLine = auth.loggedIn
379
- ? `Logged in as ${auth.name}.`
380
- : "Not logged in. Use login to authenticate.";
381
- const files = readdirSync(ARTICLES_DIR).filter((f) => f.endsWith(".md") && !f.startsWith("."));
382
- if (files.length === 0) {
383
- return `${authLine}\n\nLocal articles (0 files in ${ARTICLES_DIR}):\n (none — use download or new to get started)`;
384
- }
385
- const rows = [];
386
- for (const file of files) {
387
- const filePath = join(ARTICLES_DIR, file);
388
- const stat = statSync(filePath);
389
- const raw = readFileSync(filePath, "utf-8");
390
- const { frontmatter } = parseFrontmatter(raw);
391
- const title = frontmatter.title || "(untitled)";
392
- const size = stat.size < 1024
393
- ? `${stat.size}B`
394
- : `${(stat.size / 1024).toFixed(1)}KB`;
395
- const modified = stat.mtime.toISOString().slice(0, 16).replace("T", " ");
396
- rows.push(` ${file} — "${title}" (${size}, modified ${modified})`);
397
- }
398
- return `${authLine}\n\nLocal articles (${files.length} file(s) in ${ARTICLES_DIR}):\n${rows.join("\n")}`;
399
- },
400
- });
401
- }
@@ -1,2 +0,0 @@
1
- import { FastMCP } from "fastmcp";
2
- export declare function registerCommunityTools(server: FastMCP): void;
@@ -1,127 +0,0 @@
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
- }
@@ -1,2 +0,0 @@
1
- import { FastMCP } from "fastmcp";
2
- export declare function registerPeopleTools(server: FastMCP): void;
@@ -1,20 +0,0 @@
1
- import { z } from "zod";
2
- import { request } from "../auth.js";
3
- export function registerPeopleTools(server) {
4
- server.addTool({
5
- name: "search_people",
6
- description: "Search for people to find their canonical slug for linking. Returns candidates with name, headline, " +
7
- "image, and location. Use the returned slug when creating stubs and [[links]] for people. Requires login.",
8
- parameters: z.object({
9
- query: z.string().describe("Search terms (e.g. 'John Smith MIT professor')"),
10
- limit: z.number().min(1).max(10).default(5).describe("Max results (1-10, default 5)"),
11
- }),
12
- async execute({ query, limit }) {
13
- const resp = await request("GET", "/api/people/search", {
14
- auth: true,
15
- params: { query, limit },
16
- });
17
- return JSON.stringify(await resp.json(), null, 2);
18
- },
19
- });
20
- }