openalmanac 0.3.2 → 0.3.4
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 +0 -0
- package/dist/setup.js +143 -0
- package/dist/tools/articles.d.ts +2 -0
- package/dist/tools/articles.js +511 -0
- package/dist/tools/communities.d.ts +2 -0
- package/dist/tools/communities.js +101 -0
- package/dist/tools/people.d.ts +2 -0
- package/dist/tools/people.js +20 -0
- package/dist/tools/research.js +33 -27
- package/dist/tools/topics.js +28 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/setup.js
CHANGED
|
@@ -137,6 +137,9 @@ const CLAUDE_CODE_MCP = join(CLAUDE_DIR, "mcp.json"); // Claude Code local MCP c
|
|
|
137
137
|
const SETTINGS_JSON = join(CLAUDE_DIR, "settings.json");
|
|
138
138
|
const CODEX_CONFIG = join(homedir(), ".codex", "config.toml");
|
|
139
139
|
const CURSOR_MCP_JSON = join(homedir(), ".cursor", "mcp.json");
|
|
140
|
+
const OPENCODE_DIR = join(homedir(), ".config", "opencode");
|
|
141
|
+
const OPENCODE_JSON = join(OPENCODE_DIR, "opencode.json");
|
|
142
|
+
const OPENCODE_JSONC = join(OPENCODE_DIR, "opencode.jsonc");
|
|
140
143
|
const WINDSURF_MCP_JSON = join(homedir(), ".codeium", "mcp_config.json");
|
|
141
144
|
function ensureDir(dir) {
|
|
142
145
|
if (!existsSync(dir))
|
|
@@ -160,6 +163,7 @@ const SUPPORTED_CLIENT_IDS = [
|
|
|
160
163
|
"claude-desktop",
|
|
161
164
|
"codex",
|
|
162
165
|
"cursor",
|
|
166
|
+
"opencode",
|
|
163
167
|
"windsurf",
|
|
164
168
|
];
|
|
165
169
|
const SUPPORTED_CLIENTS = {
|
|
@@ -256,6 +260,36 @@ const SUPPORTED_CLIENTS = {
|
|
|
256
260
|
],
|
|
257
261
|
}),
|
|
258
262
|
},
|
|
263
|
+
opencode: {
|
|
264
|
+
id: "opencode",
|
|
265
|
+
name: "OpenCode",
|
|
266
|
+
selectionLabel: "OpenCode",
|
|
267
|
+
detect: () => hasCommand("opencode") ||
|
|
268
|
+
existsSync(OPENCODE_JSON) ||
|
|
269
|
+
existsSync(OPENCODE_JSONC) ||
|
|
270
|
+
existsSync(OPENCODE_DIR),
|
|
271
|
+
configure: (mode) => {
|
|
272
|
+
const path = getOpenCodeConfigPath();
|
|
273
|
+
return {
|
|
274
|
+
changed: configureOpenCodeFile(path, mode),
|
|
275
|
+
snippets: [
|
|
276
|
+
{
|
|
277
|
+
path,
|
|
278
|
+
content: jsonSnippet({
|
|
279
|
+
"$schema": "https://opencode.ai/config.json",
|
|
280
|
+
mcp: {
|
|
281
|
+
almanac: {
|
|
282
|
+
type: "local",
|
|
283
|
+
command: [ALMANAC_MCP_ENTRY.command, ...ALMANAC_MCP_ENTRY.args],
|
|
284
|
+
enabled: true,
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
}),
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
};
|
|
291
|
+
},
|
|
292
|
+
},
|
|
259
293
|
windsurf: {
|
|
260
294
|
id: "windsurf",
|
|
261
295
|
name: "Windsurf",
|
|
@@ -340,6 +374,7 @@ function normalizeClientId(value) {
|
|
|
340
374
|
desktop: "claude-desktop",
|
|
341
375
|
codex: "codex",
|
|
342
376
|
cursor: "cursor",
|
|
377
|
+
opencode: "opencode",
|
|
343
378
|
windsurf: "windsurf",
|
|
344
379
|
};
|
|
345
380
|
const normalized = aliases[value];
|
|
@@ -390,6 +425,13 @@ function isClaudeDesktopInstalled() {
|
|
|
390
425
|
function jsonSnippet(data) {
|
|
391
426
|
return JSON.stringify(data, null, 2);
|
|
392
427
|
}
|
|
428
|
+
function getOpenCodeConfigPath() {
|
|
429
|
+
if (existsSync(OPENCODE_JSON))
|
|
430
|
+
return OPENCODE_JSON;
|
|
431
|
+
if (existsSync(OPENCODE_JSONC))
|
|
432
|
+
return OPENCODE_JSONC;
|
|
433
|
+
return OPENCODE_JSON;
|
|
434
|
+
}
|
|
393
435
|
function codexSnippet() {
|
|
394
436
|
return [
|
|
395
437
|
"[mcp_servers.almanac]",
|
|
@@ -401,6 +443,12 @@ function isAlmanacCurrent(server) {
|
|
|
401
443
|
return (server?.command === "npx" &&
|
|
402
444
|
JSON.stringify(server.args) === JSON.stringify(ALMANAC_MCP_ENTRY.args));
|
|
403
445
|
}
|
|
446
|
+
function isOpenCodeAlmanacCurrent(entry) {
|
|
447
|
+
return (entry?.type === "local" &&
|
|
448
|
+
entry?.enabled === true &&
|
|
449
|
+
JSON.stringify(entry.command) ===
|
|
450
|
+
JSON.stringify([ALMANAC_MCP_ENTRY.command, ...ALMANAC_MCP_ENTRY.args]));
|
|
451
|
+
}
|
|
404
452
|
function configureJsonMcpFile(path, mode) {
|
|
405
453
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
406
454
|
const json = readJson(path);
|
|
@@ -428,6 +476,29 @@ function configureCodexToml(path, mode) {
|
|
|
428
476
|
}
|
|
429
477
|
return true;
|
|
430
478
|
}
|
|
479
|
+
function configureOpenCodeFile(path, mode) {
|
|
480
|
+
const current = readJsonWithComments(path);
|
|
481
|
+
if (!current.$schema) {
|
|
482
|
+
current.$schema = "https://opencode.ai/config.json";
|
|
483
|
+
}
|
|
484
|
+
const mcp = isRecord(current.mcp) ? current.mcp : {};
|
|
485
|
+
if (!isRecord(current.mcp)) {
|
|
486
|
+
current.mcp = mcp;
|
|
487
|
+
}
|
|
488
|
+
if (isOpenCodeAlmanacCurrent(mcp.almanac)) {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
mcp.almanac = {
|
|
492
|
+
type: "local",
|
|
493
|
+
command: [ALMANAC_MCP_ENTRY.command, ...ALMANAC_MCP_ENTRY.args],
|
|
494
|
+
enabled: true,
|
|
495
|
+
};
|
|
496
|
+
if (mode === "apply") {
|
|
497
|
+
ensureDir(dirname(path));
|
|
498
|
+
writeJson(path, current);
|
|
499
|
+
}
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
431
502
|
function readToml(path) {
|
|
432
503
|
try {
|
|
433
504
|
return readFileSync(path, "utf-8");
|
|
@@ -436,6 +507,72 @@ function readToml(path) {
|
|
|
436
507
|
return "";
|
|
437
508
|
}
|
|
438
509
|
}
|
|
510
|
+
function readJsonWithComments(path) {
|
|
511
|
+
try {
|
|
512
|
+
const raw = readFileSync(path, "utf-8");
|
|
513
|
+
const parsed = JSON.parse(stripJsonComments(raw));
|
|
514
|
+
return isRecord(parsed) ? parsed : {};
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
return {};
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
function stripJsonComments(input) {
|
|
521
|
+
let result = "";
|
|
522
|
+
let inString = false;
|
|
523
|
+
let inLineComment = false;
|
|
524
|
+
let inBlockComment = false;
|
|
525
|
+
let escaped = false;
|
|
526
|
+
for (let i = 0; i < input.length; i++) {
|
|
527
|
+
const char = input[i];
|
|
528
|
+
const next = input[i + 1];
|
|
529
|
+
if (inLineComment) {
|
|
530
|
+
if (char === "\n") {
|
|
531
|
+
inLineComment = false;
|
|
532
|
+
result += char;
|
|
533
|
+
}
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (inBlockComment) {
|
|
537
|
+
if (char === "*" && next === "/") {
|
|
538
|
+
inBlockComment = false;
|
|
539
|
+
i++;
|
|
540
|
+
}
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (inString) {
|
|
544
|
+
result += char;
|
|
545
|
+
if (escaped) {
|
|
546
|
+
escaped = false;
|
|
547
|
+
}
|
|
548
|
+
else if (char === "\\") {
|
|
549
|
+
escaped = true;
|
|
550
|
+
}
|
|
551
|
+
else if (char === "\"") {
|
|
552
|
+
inString = false;
|
|
553
|
+
}
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (char === "/" && next === "/") {
|
|
557
|
+
inLineComment = true;
|
|
558
|
+
i++;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (char === "/" && next === "*") {
|
|
562
|
+
inBlockComment = true;
|
|
563
|
+
i++;
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
if (char === "\"") {
|
|
567
|
+
inString = true;
|
|
568
|
+
}
|
|
569
|
+
result += char;
|
|
570
|
+
}
|
|
571
|
+
return result;
|
|
572
|
+
}
|
|
573
|
+
function isRecord(value) {
|
|
574
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
575
|
+
}
|
|
439
576
|
function upsertCodexServer(content) {
|
|
440
577
|
const sectionName = "mcp_servers.almanac";
|
|
441
578
|
const header = `[${sectionName}]`;
|
|
@@ -934,6 +1071,12 @@ function getNextSteps(clientsLabel) {
|
|
|
934
1071
|
`Ask ${BLUE}"Let's explore <topic> using Almanac"${RST}`,
|
|
935
1072
|
];
|
|
936
1073
|
}
|
|
1074
|
+
if (clientsLabel === "OpenCode") {
|
|
1075
|
+
return [
|
|
1076
|
+
`Type ${WHITE_BOLD}opencode${RST} to start OpenCode`,
|
|
1077
|
+
`Ask ${BLUE}"Let's explore <topic> using Almanac"${RST}`,
|
|
1078
|
+
];
|
|
1079
|
+
}
|
|
937
1080
|
if (clientsLabel === "Windsurf") {
|
|
938
1081
|
return [
|
|
939
1082
|
`Open ${WHITE_BOLD}Windsurf${RST} in your project`,
|
|
@@ -0,0 +1,511 @@
|
|
|
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 } 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
|
+
function slugify(title) {
|
|
10
|
+
return title
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.normalize("NFD")
|
|
13
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
14
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
15
|
+
.replace(/^-+|-+$/g, "")
|
|
16
|
+
.replace(/-{2,}/g, "-");
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Workaround for Claude Agent SDK MCP transport bug (#18260):
|
|
20
|
+
* Array/object parameters are sometimes serialized as JSON strings
|
|
21
|
+
* instead of native values. This preprocessor coerces them back.
|
|
22
|
+
*/
|
|
23
|
+
function coerceJson(schema) {
|
|
24
|
+
return z.preprocess((val) => {
|
|
25
|
+
if (typeof val === "string") {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(val);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return val;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return val;
|
|
34
|
+
}, schema);
|
|
35
|
+
}
|
|
36
|
+
const WRITING_GUIDE = `
|
|
37
|
+
## Article structure
|
|
38
|
+
|
|
39
|
+
\`\`\`yaml
|
|
40
|
+
---
|
|
41
|
+
article_id: the-slug
|
|
42
|
+
title: Article Title
|
|
43
|
+
sources:
|
|
44
|
+
- key: example-source-title
|
|
45
|
+
url: https://example.com
|
|
46
|
+
title: Source Title
|
|
47
|
+
accessed_date: "2025-01-15"
|
|
48
|
+
infobox:
|
|
49
|
+
header:
|
|
50
|
+
image_url: https://... # optional hero image
|
|
51
|
+
subtitle: Short tagline
|
|
52
|
+
details:
|
|
53
|
+
- key: Born
|
|
54
|
+
value: January 1, 1990
|
|
55
|
+
- key: Occupation
|
|
56
|
+
value: Scientist
|
|
57
|
+
links:
|
|
58
|
+
- https://example.com
|
|
59
|
+
sections:
|
|
60
|
+
- type: timeline # chronological events
|
|
61
|
+
title: Career Timeline
|
|
62
|
+
items:
|
|
63
|
+
- primary: "Started company"
|
|
64
|
+
period: "2010"
|
|
65
|
+
location: "San Francisco"
|
|
66
|
+
- type: list # key figures, works, features
|
|
67
|
+
title: Known For
|
|
68
|
+
items:
|
|
69
|
+
- title: First achievement
|
|
70
|
+
- title: Second achievement
|
|
71
|
+
subtitle: Additional detail
|
|
72
|
+
- type: tags # inline tags/chips
|
|
73
|
+
title: Genres
|
|
74
|
+
items:
|
|
75
|
+
- Rock
|
|
76
|
+
- Jazz
|
|
77
|
+
- type: grid # image grid
|
|
78
|
+
title: Gallery
|
|
79
|
+
items:
|
|
80
|
+
- title: Caption
|
|
81
|
+
image_url: https://...
|
|
82
|
+
- type: table # structured comparison
|
|
83
|
+
title: Statistics
|
|
84
|
+
items:
|
|
85
|
+
headers:
|
|
86
|
+
- Name
|
|
87
|
+
- Value
|
|
88
|
+
rows:
|
|
89
|
+
- cells:
|
|
90
|
+
- Height
|
|
91
|
+
- "6'2\\""
|
|
92
|
+
- type: key_value # simple key-value pairs
|
|
93
|
+
title: Quick Facts
|
|
94
|
+
items:
|
|
95
|
+
- key: Population
|
|
96
|
+
value: "1.4 billion"
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
Article body with [@key] citation markers...
|
|
100
|
+
\`\`\`
|
|
101
|
+
|
|
102
|
+
## Infobox
|
|
103
|
+
|
|
104
|
+
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.
|
|
105
|
+
|
|
106
|
+
## Citations
|
|
107
|
+
|
|
108
|
+
- Mark claims with [@key] after punctuation: "The population is 1.4 billion.[@who-world-population]"
|
|
109
|
+
- Keys must be kebab-case with at least one hyphen (e.g. 'nytimes-climate-report', 'who-malaria-2024')
|
|
110
|
+
- Generate keys BibTeX-style: {domain}-{title-words} (e.g. 'arxiv-attention-is-all')
|
|
111
|
+
- Every source in the sources list must be referenced at least once in the body with [@key]
|
|
112
|
+
- Every [@key] marker must have a matching source with that key
|
|
113
|
+
- Display numbers are computed automatically from first-appearance order — just use the keys
|
|
114
|
+
|
|
115
|
+
## Images
|
|
116
|
+
|
|
117
|
+
Use search_images to find relevant images. Images render as figures with visible captions.
|
|
118
|
+
|
|
119
|
+
**Syntax:** \`\`
|
|
120
|
+
|
|
121
|
+
Positions: \`"right"\` (default if omitted), \`"left"\`, \`"center"\`
|
|
122
|
+
|
|
123
|
+
\`\`\`markdown
|
|
124
|
+

|
|
125
|
+
|
|
126
|
+
The early life of Alan Turing began...
|
|
127
|
+
|
|
128
|
+

|
|
129
|
+
\`\`\`
|
|
130
|
+
|
|
131
|
+
**Caption rules:**
|
|
132
|
+
- Every image MUST have a descriptive caption — it is displayed below the image
|
|
133
|
+
- Describe what the image shows: "Alan Turing in 1930, aged 18" not "Photo"
|
|
134
|
+
- Include dates, context, or attribution when relevant
|
|
135
|
+
|
|
136
|
+
**Placement:** 1-3 images per major section, spread throughout. First image near the top.
|
|
137
|
+
For the infobox hero image, use \`infobox.header.image_url\` in frontmatter instead.
|
|
138
|
+
|
|
139
|
+
External image URLs are auto-persisted on publish — no extra steps needed.
|
|
140
|
+
|
|
141
|
+
## Writing quality
|
|
142
|
+
|
|
143
|
+
- Every sentence should contain a specific fact the reader didn't know
|
|
144
|
+
- No filler phrases ("It is worth noting", "In today's world", "Throughout history")
|
|
145
|
+
- No promotional language ("revolutionary", "groundbreaking", "game-changing")
|
|
146
|
+
- No inflated significance ("one of the most important", "changed the world forever")
|
|
147
|
+
- No vague attribution ("many experts say", "it is widely regarded")
|
|
148
|
+
- No formulaic conclusions ("In conclusion", "continues to shape")
|
|
149
|
+
- Write like a concise encyclopedia, not a blog post
|
|
150
|
+
`.trim();
|
|
151
|
+
function ensureArticlesDir() {
|
|
152
|
+
mkdirSync(ARTICLES_DIR, { recursive: true });
|
|
153
|
+
}
|
|
154
|
+
function resolveArticleDir(communitySlug) {
|
|
155
|
+
return communitySlug ? join(ARTICLES_DIR, communitySlug) : ARTICLES_DIR;
|
|
156
|
+
}
|
|
157
|
+
function resolveArticlePaths(slug, communitySlug) {
|
|
158
|
+
const dir = resolveArticleDir(communitySlug);
|
|
159
|
+
return {
|
|
160
|
+
dir,
|
|
161
|
+
filePath: join(dir, `${slug}.md`),
|
|
162
|
+
originalPath: join(dir, `.${slug}.original.md`),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function findDraftCandidates(slug) {
|
|
166
|
+
const matches = [];
|
|
167
|
+
const root = resolveArticlePaths(slug);
|
|
168
|
+
if (existsSync(root.filePath)) {
|
|
169
|
+
matches.push({ communitySlug: null, filePath: root.filePath, originalPath: root.originalPath });
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const dirs = readdirSync(ARTICLES_DIR).filter((d) => {
|
|
173
|
+
try {
|
|
174
|
+
return statSync(join(ARTICLES_DIR, d)).isDirectory();
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
for (const communitySlug of dirs) {
|
|
181
|
+
const scoped = resolveArticlePaths(slug, communitySlug);
|
|
182
|
+
if (existsSync(scoped.filePath)) {
|
|
183
|
+
matches.push({ communitySlug, filePath: scoped.filePath, originalPath: scoped.originalPath });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch { /* ignore readdir errors */ }
|
|
188
|
+
return matches;
|
|
189
|
+
}
|
|
190
|
+
function resolvePublishCandidate(slug, communitySlug) {
|
|
191
|
+
if (communitySlug) {
|
|
192
|
+
const paths = resolveArticlePaths(slug, communitySlug);
|
|
193
|
+
if (!existsSync(paths.filePath)) {
|
|
194
|
+
throw new Error(`File not found: ${paths.filePath}\nUse download to get an existing article or new to create a scaffold.`);
|
|
195
|
+
}
|
|
196
|
+
return { communitySlug, ...paths };
|
|
197
|
+
}
|
|
198
|
+
const matches = findDraftCandidates(slug);
|
|
199
|
+
if (matches.length === 0) {
|
|
200
|
+
const fallback = resolveArticlePaths(slug);
|
|
201
|
+
throw new Error(`File not found: ${fallback.filePath}\nUse download to get an existing article or new to create a scaffold.`);
|
|
202
|
+
}
|
|
203
|
+
if (matches.length > 1) {
|
|
204
|
+
const paths = matches.map((match) => ` - ${match.filePath}`).join("\n");
|
|
205
|
+
throw new Error(`Multiple local drafts found for slug "${slug}". Publish is ambiguous.\n${paths}\nRemove the duplicate draft or publish with community_slug set.`);
|
|
206
|
+
}
|
|
207
|
+
return matches[0];
|
|
208
|
+
}
|
|
209
|
+
export function registerArticleTools(server) {
|
|
210
|
+
server.addTool({
|
|
211
|
+
name: "search_articles",
|
|
212
|
+
description: "Search existing OpenAlmanac articles and stubs. Accepts multiple queries for batch lookup. " +
|
|
213
|
+
"Use this to check if articles or stubs exist before creating them, or to find entity slugs for wikilinks. " +
|
|
214
|
+
"Results include 'stub' field (true/false) and 'entity_type' field. " +
|
|
215
|
+
"Pass community_slug to restrict results to articles owned by that community wiki (does not include global articles). No authentication needed.",
|
|
216
|
+
parameters: z.object({
|
|
217
|
+
queries: coerceJson(z.array(z.string()).min(1).max(20)).describe("Search queries (1-20)"),
|
|
218
|
+
limit: z.number().default(5).describe("Max results per query (1-50, default 5)"),
|
|
219
|
+
include_stubs: z.boolean().default(true).describe("Include stub articles in results (default true)"),
|
|
220
|
+
community_slug: z.string().optional().describe("Optional community slug. When set, results are restricted to articles owned by that community wiki only — global articles are excluded. Omit to search all of OpenAlmanac."),
|
|
221
|
+
}),
|
|
222
|
+
async execute({ queries, limit, include_stubs, community_slug }) {
|
|
223
|
+
const json = { queries, limit, include_stubs };
|
|
224
|
+
if (community_slug)
|
|
225
|
+
json.community_slug = community_slug;
|
|
226
|
+
const resp = await request("POST", "/api/search/batch", { json });
|
|
227
|
+
return JSON.stringify(await resp.json(), null, 2);
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
server.addTool({
|
|
231
|
+
name: "read",
|
|
232
|
+
description: "Read article content from OpenAlmanac. Returns the content, sources, and metadata for each slug. " +
|
|
233
|
+
"Use this to reference or summarize existing articles in conversation. " +
|
|
234
|
+
"For editing articles locally, use 'download' instead. No authentication needed.",
|
|
235
|
+
parameters: z.object({
|
|
236
|
+
slugs: coerceJson(z.array(z.string()).min(1).max(20)).describe("Article slugs to read (1-20)"),
|
|
237
|
+
community_slug: z.string().optional().describe("Community slug for reading community-owned wiki articles. Omit for global almanac articles."),
|
|
238
|
+
}),
|
|
239
|
+
async execute({ slugs, community_slug }) {
|
|
240
|
+
const json = { slugs };
|
|
241
|
+
if (community_slug)
|
|
242
|
+
json.community_slug = community_slug;
|
|
243
|
+
const resp = await request("POST", "/api/articles/batch", { json });
|
|
244
|
+
return JSON.stringify(await resp.json(), null, 2);
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
server.addTool({
|
|
248
|
+
name: "download",
|
|
249
|
+
description: "Download articles to your local workspace for editing. " +
|
|
250
|
+
"Global articles: ~/.openalmanac/articles/{slug}.md. Community wiki: ~/.openalmanac/articles/{community_slug}/{slug}.md. " +
|
|
251
|
+
"Returns a writing guide on first call. After editing, use publish to push changes.",
|
|
252
|
+
parameters: z.object({
|
|
253
|
+
slugs: coerceJson(z.array(z.string()).min(1).max(50)).describe("Article slugs to download (1-50)"),
|
|
254
|
+
community_slug: z.string().optional().describe("Community slug for community-owned wiki articles"),
|
|
255
|
+
}),
|
|
256
|
+
async execute({ slugs, community_slug }) {
|
|
257
|
+
for (const slug of slugs) {
|
|
258
|
+
if (!SLUG_RE.test(slug)) {
|
|
259
|
+
throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning').`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const json = { slugs };
|
|
263
|
+
if (community_slug)
|
|
264
|
+
json.community_slug = community_slug;
|
|
265
|
+
const resp = await request("POST", "/api/articles/batch-download", { json });
|
|
266
|
+
const data = (await resp.json());
|
|
267
|
+
ensureArticlesDir();
|
|
268
|
+
const lines = [];
|
|
269
|
+
for (const slug of slugs) {
|
|
270
|
+
if (data.errors[slug]) {
|
|
271
|
+
lines.push(`FAILED ${slug}: ${data.errors[slug]}`);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const markdown = data.articles[slug];
|
|
275
|
+
if (!markdown) {
|
|
276
|
+
lines.push(`FAILED ${slug}: missing from response`);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const { dir, filePath, originalPath } = resolveArticlePaths(slug, community_slug);
|
|
280
|
+
if (community_slug) {
|
|
281
|
+
mkdirSync(dir, { recursive: true });
|
|
282
|
+
}
|
|
283
|
+
writeFileSync(filePath, markdown, "utf-8");
|
|
284
|
+
writeFileSync(originalPath, markdown, "utf-8");
|
|
285
|
+
const { frontmatter, content } = parseFrontmatter(markdown);
|
|
286
|
+
const title = frontmatter.title || "(untitled)";
|
|
287
|
+
const wordCount = content.trim().split(/\s+/).filter(Boolean).length;
|
|
288
|
+
const isStub = frontmatter.stub === true;
|
|
289
|
+
const stubNote = isStub
|
|
290
|
+
? "\n\nThis is a STUB article — a placeholder that hasn't been fully written yet. " +
|
|
291
|
+
"Fill in the content body with a complete article, then push to publish."
|
|
292
|
+
: "";
|
|
293
|
+
lines.push(`Downloaded "${title}" to ${filePath}\n${wordCount} words, ${frontmatter.sources?.length ?? 0} sources.${stubNote}`);
|
|
294
|
+
}
|
|
295
|
+
return [lines.join("\n"), "", WRITING_GUIDE].join("\n");
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
server.addTool({
|
|
299
|
+
name: "new",
|
|
300
|
+
description: "Scaffold new articles locally. Creates .md files with YAML frontmatter and empty bodies. " +
|
|
301
|
+
"Provide explicit slugs when you know the canonical ID; otherwise they are auto-derived from titles. For community wiki articles, provide community_slug. " +
|
|
302
|
+
"After writing content, use publish to go live.",
|
|
303
|
+
parameters: z.object({
|
|
304
|
+
articles: coerceJson(z.array(z.object({
|
|
305
|
+
title: z.string().describe("Article title"),
|
|
306
|
+
slug: z.string().optional().describe("Optional explicit kebab-case slug. Encouraged when you know the canonical ID."),
|
|
307
|
+
topics: z.array(z.string()).optional().describe("Topic slugs for community wiki articles"),
|
|
308
|
+
})).min(1).max(50)).describe("Articles to scaffold (1-50)"),
|
|
309
|
+
community_slug: z.string().optional().describe("Community slug for community-owned wiki articles"),
|
|
310
|
+
}),
|
|
311
|
+
async execute({ articles, community_slug }) {
|
|
312
|
+
if (community_slug && !SLUG_RE.test(community_slug)) {
|
|
313
|
+
throw new Error(`Invalid community_slug "${community_slug}". Must be kebab-case.`);
|
|
314
|
+
}
|
|
315
|
+
ensureArticlesDir();
|
|
316
|
+
let dir = ARTICLES_DIR;
|
|
317
|
+
if (community_slug) {
|
|
318
|
+
dir = join(ARTICLES_DIR, community_slug);
|
|
319
|
+
mkdirSync(dir, { recursive: true });
|
|
320
|
+
}
|
|
321
|
+
const created = [];
|
|
322
|
+
const skipped = [];
|
|
323
|
+
for (const item of articles) {
|
|
324
|
+
const slug = item.slug || slugify(item.title);
|
|
325
|
+
if (!slug) {
|
|
326
|
+
skipped.push(`(empty slug from title: "${item.title}")`);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (!SLUG_RE.test(slug)) {
|
|
330
|
+
skipped.push(`"${item.title}" → invalid slug "${slug}"`);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const filePath = join(dir, `${slug}.md`);
|
|
334
|
+
if (existsSync(filePath)) {
|
|
335
|
+
skipped.push(`${slug}.md already exists — skipped`);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const meta = { article_id: slug, title: item.title };
|
|
339
|
+
if (community_slug)
|
|
340
|
+
meta.community_slug = community_slug;
|
|
341
|
+
if (item.topics && item.topics.length > 0)
|
|
342
|
+
meta.topics = item.topics;
|
|
343
|
+
meta.sources = [];
|
|
344
|
+
const frontmatter = yamlStringify(meta);
|
|
345
|
+
const scaffold = `---\n${frontmatter}---\n\n`;
|
|
346
|
+
writeFileSync(filePath, scaffold, "utf-8");
|
|
347
|
+
created.push(filePath);
|
|
348
|
+
}
|
|
349
|
+
const parts = [
|
|
350
|
+
created.length > 0 ? `Created ${created.length} file(s):\n${created.map((p) => ` - ${p}`).join("\n")}` : "No new files created.",
|
|
351
|
+
skipped.length > 0 ? `Skipped:\n${skipped.map((s) => ` - ${s}`).join("\n")}` : "",
|
|
352
|
+
WRITING_GUIDE,
|
|
353
|
+
];
|
|
354
|
+
return parts.filter(Boolean).join("\n\n");
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
server.addTool({
|
|
358
|
+
name: "publish",
|
|
359
|
+
description: "Validate and publish articles from your local workspace. " +
|
|
360
|
+
"Provide specific slugs, or a community_slug to publish all articles in that community folder. " +
|
|
361
|
+
"Empty-body files become stubs. Dead wikilinks auto-create stubs on the server. " +
|
|
362
|
+
"Put edit_summary in frontmatter for per-article change descriptions. Requires login.",
|
|
363
|
+
parameters: z.object({
|
|
364
|
+
slugs: coerceJson(z.array(z.string()).min(1).max(50)).optional()
|
|
365
|
+
.describe("Specific article slugs to publish"),
|
|
366
|
+
community_slug: z.string().optional()
|
|
367
|
+
.describe("Publish all .md files in this community folder under ~/.openalmanac/articles/"),
|
|
368
|
+
}),
|
|
369
|
+
async execute({ slugs, community_slug }) {
|
|
370
|
+
if (!slugs?.length && !community_slug) {
|
|
371
|
+
throw new Error("Provide slugs or community_slug (explicit intent required).");
|
|
372
|
+
}
|
|
373
|
+
const tasks = [];
|
|
374
|
+
if (community_slug && !slugs?.length) {
|
|
375
|
+
if (!SLUG_RE.test(community_slug)) {
|
|
376
|
+
throw new Error(`Invalid community_slug "${community_slug}". Must be kebab-case.`);
|
|
377
|
+
}
|
|
378
|
+
const dir = join(ARTICLES_DIR, community_slug);
|
|
379
|
+
if (!existsSync(dir)) {
|
|
380
|
+
throw new Error(`Community folder not found: ${dir}`);
|
|
381
|
+
}
|
|
382
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".md") && !f.startsWith("."));
|
|
383
|
+
if (files.length === 0) {
|
|
384
|
+
throw new Error(`No .md files in ${dir}`);
|
|
385
|
+
}
|
|
386
|
+
for (const f of files) {
|
|
387
|
+
const slug = f.replace(/\.md$/i, "");
|
|
388
|
+
tasks.push({ slug, communitySlug: community_slug, ...resolveArticlePaths(slug, community_slug) });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
else if (slugs?.length) {
|
|
392
|
+
for (const slug of slugs) {
|
|
393
|
+
if (!SLUG_RE.test(slug)) {
|
|
394
|
+
throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning').`);
|
|
395
|
+
}
|
|
396
|
+
tasks.push({ slug, ...resolvePublishCandidate(slug, community_slug ?? undefined) });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const validationLines = [];
|
|
400
|
+
const validArticles = [];
|
|
401
|
+
for (const task of tasks) {
|
|
402
|
+
const raw = readFileSync(task.filePath, "utf-8");
|
|
403
|
+
const errors = validateArticle(raw);
|
|
404
|
+
if (errors.length > 0) {
|
|
405
|
+
const lines = errors.map((e) => ` ${e.field}: ${e.message}`);
|
|
406
|
+
validationLines.push(`FAILED ${task.slug}: Validation failed (${errors.length} error${errors.length > 1 ? "s" : ""}):\n${lines.join("\n")}`);
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
validArticles.push({ slug: task.slug, markdown: raw });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const inGui = process.env.OPENALMANAC_GUI === "1";
|
|
413
|
+
const resultLines = [...validationLines];
|
|
414
|
+
let okCount = 0;
|
|
415
|
+
if (validArticles.length > 0) {
|
|
416
|
+
const resp = await request("POST", "/api/articles/batch-publish", {
|
|
417
|
+
auth: true,
|
|
418
|
+
json: { articles: validArticles },
|
|
419
|
+
});
|
|
420
|
+
const data = (await resp.json());
|
|
421
|
+
for (const r of data.results) {
|
|
422
|
+
if (r.status === "failed") {
|
|
423
|
+
resultLines.push(`FAILED ${r.slug}: ${r.error ?? "unknown error"}`);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
okCount += 1;
|
|
427
|
+
const task = tasks.find((t) => t.slug === r.slug);
|
|
428
|
+
if (task) {
|
|
429
|
+
try {
|
|
430
|
+
unlinkSync(task.filePath);
|
|
431
|
+
}
|
|
432
|
+
catch (e) {
|
|
433
|
+
if (e.code !== "ENOENT") {
|
|
434
|
+
resultLines.push(`Note: could not remove local draft for ${r.slug}: ${e.message}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
unlinkSync(task.originalPath);
|
|
439
|
+
}
|
|
440
|
+
catch (e) {
|
|
441
|
+
if (e.code !== "ENOENT") {
|
|
442
|
+
resultLines.push(`Note: could not remove original copy for ${r.slug}: ${e.message}`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
resultLines.push(`OK ${r.slug}: ${r.status}\n${JSON.stringify(r, null, 2)}`);
|
|
447
|
+
if (!inGui && tasks.length === 1 && r.canonical_path) {
|
|
448
|
+
const articleUrl = `https://www.openalmanac.org${r.canonical_path}?celebrate=true`;
|
|
449
|
+
openBrowser(articleUrl);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const urlHint = inGui
|
|
454
|
+
? "\n\nThe article(s) have been published! Let the user know they're live. Do not send them to a web URL."
|
|
455
|
+
: tasks.length > 1
|
|
456
|
+
? "\n\n(Opening browser skipped for batch publish — share URLs from results above.)"
|
|
457
|
+
: "";
|
|
458
|
+
return `Published ${okCount}/${tasks.length}.\n\n${resultLines.join("\n\n")}${urlHint}`;
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
server.addTool({
|
|
462
|
+
name: "list_articles",
|
|
463
|
+
description: "Browse a community's wiki articles. Structured listing, not fuzzy search. " +
|
|
464
|
+
"Use this to see what exists, find stubs to fill, or discover most-referenced gaps.",
|
|
465
|
+
parameters: z.object({
|
|
466
|
+
community_slug: z.string().describe("Community slug"),
|
|
467
|
+
topic: z.string().optional().describe("Filter by topic slug"),
|
|
468
|
+
sort: z.enum(["recent", "most_referenced"]).default("recent").describe("Sort order"),
|
|
469
|
+
stubs_only: z.boolean().default(false).describe("Only return stubs"),
|
|
470
|
+
limit: z.number().min(1).max(200).default(50).describe("Max results (1-200)"),
|
|
471
|
+
}),
|
|
472
|
+
async execute({ community_slug, topic, sort, stubs_only, limit }) {
|
|
473
|
+
const params = { sort, stubs_only, limit };
|
|
474
|
+
if (topic)
|
|
475
|
+
params.topic = topic;
|
|
476
|
+
const resp = await request("GET", `/api/communities/${community_slug}/wiki`, { params });
|
|
477
|
+
return JSON.stringify(await resp.json(), null, 2);
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
server.addTool({
|
|
481
|
+
name: "propose_article",
|
|
482
|
+
description: "Propose an article before writing it. Call this when you've researched enough and a specific article topic has come into focus. " +
|
|
483
|
+
"Structures your proposal with a user-facing summary and a detailed brief. " +
|
|
484
|
+
"The client environment determines what happens next — in GUI environments the user sees a plan card with options, " +
|
|
485
|
+
"in CLI environments you'll get a response telling you to proceed with writing. " +
|
|
486
|
+
"Do not start writing an article without proposing first.",
|
|
487
|
+
parameters: z.object({
|
|
488
|
+
summary: z.string().describe("User-facing summary: title, key sections, angle. Markdown. Concise — 3-5 bullet points."),
|
|
489
|
+
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."),
|
|
490
|
+
title: z.string().describe("Proposed article title"),
|
|
491
|
+
slug: z.string().describe("Proposed article slug (kebab-case)"),
|
|
492
|
+
_userChoice: z.enum(["background", "here", "expired", "already_in_progress"]).optional().describe("Internal field set by GUI client. Never set this manually."),
|
|
493
|
+
}),
|
|
494
|
+
async execute({ summary, details, title, slug, _userChoice }) {
|
|
495
|
+
if (!SLUG_RE.test(slug)) {
|
|
496
|
+
throw new Error(`Invalid slug "${slug}". Must be kebab-case (e.g. 'machine-learning'). Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$`);
|
|
497
|
+
}
|
|
498
|
+
if (_userChoice === "background") {
|
|
499
|
+
return `Article "${title}" is now being written in a background process. Continue exploring with the user. Do not write this article in this conversation.`;
|
|
500
|
+
}
|
|
501
|
+
if (_userChoice === "expired") {
|
|
502
|
+
return `The user navigated away before responding to the proposal. Proposal expired. Continue the conversation naturally.`;
|
|
503
|
+
}
|
|
504
|
+
if (_userChoice === "already_in_progress") {
|
|
505
|
+
return `Article "${title}" is already being generated in a background process. No action needed.`;
|
|
506
|
+
}
|
|
507
|
+
// "here" OR no _userChoice (CLI default) — proceed with writing
|
|
508
|
+
return `Article Proposal: ${title}\n\n${summary}\n\nProceed with writing this article following the writing flow in your instructions.`;
|
|
509
|
+
},
|
|
510
|
+
});
|
|
511
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
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 to discover communities by topic. 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
|
+
cover_image_url: z.string().url().optional().describe("Hero/banner image URL. Use search_images to find a compelling image first."),
|
|
58
|
+
cover_image_position: z.number().min(0).max(100).optional().describe("Vertical focal point of cover image (0=top, 50=center, 100=bottom)"),
|
|
59
|
+
}),
|
|
60
|
+
async execute({ name, slug, description, cover_image_url, cover_image_position }) {
|
|
61
|
+
const json = { name, slug, description };
|
|
62
|
+
if (cover_image_url)
|
|
63
|
+
json.cover_image_url = cover_image_url;
|
|
64
|
+
if (cover_image_position !== undefined)
|
|
65
|
+
json.cover_image_position = cover_image_position;
|
|
66
|
+
const resp = await request("POST", "/api/communities", {
|
|
67
|
+
auth: true,
|
|
68
|
+
json,
|
|
69
|
+
});
|
|
70
|
+
const data = (await resp.json());
|
|
71
|
+
const communityUrl = `https://www.openalmanac.org/communities/${slug}`;
|
|
72
|
+
return `Community created!\n\nURL: ${communityUrl}\n\n${JSON.stringify(data, null, 2)}`;
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
server.addTool({
|
|
76
|
+
name: "create_post",
|
|
77
|
+
description: "Create a post in an OpenAlmanac community. Requires login and community membership. " +
|
|
78
|
+
"If you get a 403 error, you need to join the community first.",
|
|
79
|
+
parameters: z.object({
|
|
80
|
+
community_slug: z.string().describe("Community slug (e.g. 'machine-learning')"),
|
|
81
|
+
title: z.string().min(1).max(300).describe("Post title (1-300 chars)"),
|
|
82
|
+
body: z.string().max(10000).default("").describe("Post body (max 10000 chars)"),
|
|
83
|
+
flair: z
|
|
84
|
+
.enum(["discussion", "article-request", "question", "announcement"])
|
|
85
|
+
.optional()
|
|
86
|
+
.describe("Post flair/category"),
|
|
87
|
+
}),
|
|
88
|
+
async execute({ community_slug, title, body, flair }) {
|
|
89
|
+
const json = { title, body };
|
|
90
|
+
if (flair)
|
|
91
|
+
json.flair = flair;
|
|
92
|
+
const resp = await request("POST", `/api/communities/${community_slug}/posts`, {
|
|
93
|
+
auth: true,
|
|
94
|
+
json,
|
|
95
|
+
});
|
|
96
|
+
const data = (await resp.json());
|
|
97
|
+
const postUrl = `https://www.openalmanac.org/communities/${community_slug}/post/${data.id}`;
|
|
98
|
+
return `Post created!\n\nURL: ${postUrl}\n\n${JSON.stringify(data, null, 2)}`;
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
}
|
package/dist/tools/research.js
CHANGED
|
@@ -20,44 +20,50 @@ function coerceJson(schema) {
|
|
|
20
20
|
}, schema);
|
|
21
21
|
}
|
|
22
22
|
export function registerResearchTools(server) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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.)."),
|
|
23
|
+
const SearchWebInput = z.object({
|
|
24
|
+
source: z.enum(["web", "reddit"]).describe("Search source. Use 'web' for Google/Serper and 'reddit' for community perspectives via Reddit."),
|
|
25
|
+
query: z.string().min(1).optional().describe("Search terms. Required for source='web'. Optional for source='reddit' — omit it there to return a sorted subreddit listing."),
|
|
26
|
+
subreddit: z.string().optional().describe("Reddit-only. Subreddit name without the 'r/' prefix (e.g. 'Harvard'). Omit to search across all of Reddit."),
|
|
38
27
|
sort: z.enum(["top", "hot", "new", "rising", "controversial", "relevance", "comments"])
|
|
39
|
-
.
|
|
40
|
-
.describe("
|
|
28
|
+
.optional()
|
|
29
|
+
.describe("Reddit-only. Listing/search sort. Defaults to 'top'. Use 'relevance' or 'comments' only when query is present."),
|
|
41
30
|
time_range: z.enum(["hour", "day", "week", "month", "year", "all"])
|
|
42
|
-
.
|
|
43
|
-
.describe("Time window for top/controversial listings and all searches.
|
|
44
|
-
limit: z.number().int().min(1).max(100).
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("Reddit-only. Time window for top/controversial listings and all searches. Defaults to 'year'."),
|
|
33
|
+
limit: z.number().int().min(1).max(100).optional().describe("Result limit. For source='web' defaults to 10 and must be <= 20. For source='reddit' defaults to 25 and can be up to 100."),
|
|
34
|
+
}).superRefine((input, ctx) => {
|
|
35
|
+
if (input.source === "web") {
|
|
36
|
+
if (!input.query?.trim()) {
|
|
37
|
+
ctx.addIssue({
|
|
38
|
+
code: z.ZodIssueCode.custom,
|
|
39
|
+
path: ["query"],
|
|
40
|
+
message: "query is required when source='web'",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (input.limit !== undefined && input.limit > 20) {
|
|
44
|
+
ctx.addIssue({
|
|
45
|
+
code: z.ZodIssueCode.custom,
|
|
46
|
+
path: ["limit"],
|
|
47
|
+
message: "limit must be <= 20 when source='web'",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
45
51
|
});
|
|
46
52
|
server.addTool({
|
|
47
53
|
name: "search_web",
|
|
48
|
-
description: "Search the web or a specific community source (Reddit).
|
|
54
|
+
description: "Search the web or a specific community source (Reddit). Pick the source with the `source` field:\n\n" +
|
|
49
55
|
"- `source: \"web\"` — general web search via Google. Use for news, docs, scholarly references.\n" +
|
|
50
56
|
"- `source: \"reddit\"` — Reddit-aware search returning posts with score, flair, num_comments, permalink. " +
|
|
51
57
|
"Use when the user is asking about community perspectives, subreddit consensus, or 'what do people think about X'.\n\n" +
|
|
52
|
-
"
|
|
58
|
+
"Use only the fields relevant to the source you pick. " +
|
|
53
59
|
"Rate limit: 10/min. Requires API key.",
|
|
54
|
-
parameters:
|
|
60
|
+
parameters: SearchWebInput,
|
|
55
61
|
async execute(input) {
|
|
56
62
|
if (input.source === "reddit") {
|
|
57
63
|
const params = {
|
|
58
|
-
sort: input.sort,
|
|
59
|
-
time_range: input.time_range,
|
|
60
|
-
limit: input.limit,
|
|
64
|
+
sort: input.sort ?? "top",
|
|
65
|
+
time_range: input.time_range ?? "year",
|
|
66
|
+
limit: input.limit ?? 25,
|
|
61
67
|
};
|
|
62
68
|
if (input.subreddit)
|
|
63
69
|
params.subreddit = input.subreddit;
|
|
@@ -71,7 +77,7 @@ export function registerResearchTools(server) {
|
|
|
71
77
|
}
|
|
72
78
|
const resp = await request("GET", "/api/research/search", {
|
|
73
79
|
auth: true,
|
|
74
|
-
params: { query: input.query, limit: input.limit },
|
|
80
|
+
params: { query: input.query.trim(), limit: input.limit ?? 10 },
|
|
75
81
|
});
|
|
76
82
|
return JSON.stringify(await resp.json(), null, 2);
|
|
77
83
|
},
|
package/dist/tools/topics.js
CHANGED
|
@@ -46,6 +46,34 @@ export function registerTopicTools(server) {
|
|
|
46
46
|
return JSON.stringify(await resp.json(), null, 2);
|
|
47
47
|
},
|
|
48
48
|
});
|
|
49
|
+
server.addTool({
|
|
50
|
+
name: "update_topic",
|
|
51
|
+
description: "Update a topic's title, description, or image. Requires wiki moderator role.",
|
|
52
|
+
parameters: z.object({
|
|
53
|
+
wiki_slug: z.string().describe("Wiki slug"),
|
|
54
|
+
topic_slug: z.string().describe("Slug of the topic to update"),
|
|
55
|
+
title: z.string().optional().describe("New title (also updates slug)"),
|
|
56
|
+
description: z.string().optional().describe("New description"),
|
|
57
|
+
image_url: z
|
|
58
|
+
.string()
|
|
59
|
+
.url()
|
|
60
|
+
.max(2048)
|
|
61
|
+
.nullable()
|
|
62
|
+
.optional()
|
|
63
|
+
.describe("Topic image URL (https:// or http://). Pass null to clear."),
|
|
64
|
+
}),
|
|
65
|
+
async execute({ wiki_slug, topic_slug, title, description, image_url }) {
|
|
66
|
+
const body = {};
|
|
67
|
+
if (title !== undefined)
|
|
68
|
+
body.title = title;
|
|
69
|
+
if (description !== undefined)
|
|
70
|
+
body.description = description;
|
|
71
|
+
if (image_url !== undefined)
|
|
72
|
+
body.image_url = image_url;
|
|
73
|
+
const resp = await request("PATCH", `/api/w/${wiki_slug}/topics/${topic_slug}`, { auth: true, json: body });
|
|
74
|
+
return JSON.stringify(await resp.json(), null, 2);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
49
77
|
server.addTool({
|
|
50
78
|
name: "create_topics_batch",
|
|
51
79
|
description: "Batch create topics in a wiki. Useful for bootstrapping a topic hierarchy. Requires wiki membership.",
|