openalmanac 0.2.29 → 0.2.31

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
@@ -31,7 +31,7 @@ export function createServer() {
31
31
  name: "OpenAlmanac",
32
32
  version: pkg.version,
33
33
  instructions: [
34
- "OpenAlmanac is an open knowledge base — a Wikipedia anyone can read from and write to through an API. Articles are markdown files with YAML frontmatter and [N] citation markers mapped to sources.",
34
+ "OpenAlmanac is an open knowledge base — a Wikipedia anyone can read from and write to through an API. Articles are markdown files with YAML frontmatter and [@key] citation markers mapped to named sources.",
35
35
  "",
36
36
  "## How this should feel",
37
37
  "",
@@ -95,28 +95,30 @@ export function createServer() {
95
95
  "",
96
96
  "## Writing flow",
97
97
  "",
98
- "When the user agrees to write an article:",
98
+ "When you've researched enough and a specific article topic has come into focus:",
99
99
  "",
100
100
  '1. **Align briefly with the user** — Talk about what the article should cover, what to focus on, what angle to take. Not a rigid outline — a quick conversation. "I\'m thinking we cover the history, the Royal Brahmins, daily worship, and the Ramakien — anything you want to add or skip?"',
101
101
  "",
102
- "2. **Read the writing guidelines** — Fetch https://www.openalmanac.org/writing-guidelines.md and https://www.openalmanac.org/ai-patterns-to-avoid.md before writing a single word.",
102
+ "2. **Propose the article** — Call `propose_article` with your summary (title, key sections, angle — 3-5 bullet points) and a detailed brief (all sources, key facts, user preferences, angle, what to avoid, related articles). Do not start writing an article without proposing first. The client environment determines what happens next — in some environments the user will see a plan card with options (write here vs. run in background), in others you'll get a text response telling you to proceed. Follow whatever the tool response says.",
103
103
  "",
104
- "3. **Scaffold** Use `new` to create the article file. List ALL sources you've gathered in the frontmatter before writing any body text. Don't discard sources — if you read it during research and it's relevant, include it.",
104
+ "3. **Read the writing guidelines** Fetch https://www.openalmanac.org/writing-guidelines.md and https://www.openalmanac.org/ai-patterns-to-avoid.md before writing a single word.",
105
105
  "",
106
- "4. **Write a pure text draft** This whole process (writing, review, fact-check, images, linking) takes a few minutes. Let the user know in a fun way that they can step away and that once it's ready, you're happy to discuss any edits or polishing.",
106
+ "4. **Scaffold** Use `new` to create the article file. List ALL sources you've gathered in the frontmatter before writing any body text. Don't discard sources if you read it during research and it's relevant, include it.",
107
107
  "",
108
- " Write the full article body with citation markers [N]. No wikilinks, no `[[slug|Display Text]]` syntax, no images, no stubs. Just prose and citations. The linking and images come later from subagents who need to read the finished text.",
108
+ "5. **Write a pure text draft** This whole process (writing, review, fact-check, images, linking) takes a few minutes. Let the user know in a fun way that they can step away — and that once it's ready, you're happy to discuss any edits or polishing.",
109
109
  "",
110
- "5. **Dispatch four subagents in parallel** After the draft is complete, dispatch these simultaneously. Each agent has its own guidelines file — tell it to fetch and read that file as its first step. The guidelines file tells the agent what to do, what additional guidelines to fetch, and what format to return results in.",
110
+ " Write the full article body with citation markers [@key]. No wikilinks, no `[[slug|Display Text]]` syntax, no images, no stubs. Just prose and citations. The linking and images come later from subagents who need to read the finished text.",
111
+ "",
112
+ "6. **Dispatch four subagents in parallel** — After the draft is complete, dispatch these simultaneously. Each agent has its own guidelines file — tell it to fetch and read that file as its first step. The guidelines file tells the agent what to do, what additional guidelines to fetch, and what format to return results in.",
111
113
  "",
112
114
  " - **Review agent** → tell it to read https://www.openalmanac.org/review-guidelines.md and review the draft at `~/.openalmanac/articles/{slug}.md`",
113
115
  " - **Fact-check agent** → tell it to read https://www.openalmanac.org/fact-checking-guidelines.md and fact-check the draft at `~/.openalmanac/articles/{slug}.md`",
114
116
  " - **Image agent** → tell it to read https://www.openalmanac.org/image-guidelines.md and find images for the draft at `~/.openalmanac/articles/{slug}.md`",
115
117
  " - **Linking agent** → tell it to read https://www.openalmanac.org/linking-guidelines.md and create stubs/wikilinks for the draft at `~/.openalmanac/articles/{slug}.md`",
116
118
  "",
117
- "6. **Integrate** — Present the review and fact-check feedback to the user. Then fix everything in one pass: review issues, fact-check corrections, add images, add wikilinks.",
119
+ "7. **Integrate** — Present the review and fact-check feedback to the user. Then fix everything in one pass: review issues, fact-check corrections, add images, add wikilinks.",
118
120
  "",
119
- "7. **Publish** — Validate and publish. Share the exact URL from the publish response (it includes a celebration page). Then search_communities for relevant communities and suggest linking.",
121
+ "8. **Publish** — Validate and publish. Share the exact URL from the publish response (it includes a celebration page). Then search_communities for relevant communities and suggest linking.",
120
122
  "",
121
123
  "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.",
122
124
  "",
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync } from "node:fs";
2
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync, unlinkSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { stringify as yamlStringify } from "yaml";
5
5
  import { request, ARTICLES_DIR, getAuthStatus } from "../auth.js";
@@ -14,7 +14,8 @@ const WRITING_GUIDE = `
14
14
  article_id: the-slug
15
15
  title: Article Title
16
16
  sources:
17
- - url: https://example.com
17
+ - key: example-source-title
18
+ url: https://example.com
18
19
  title: Source Title
19
20
  accessed_date: "2025-01-15"
20
21
  infobox:
@@ -68,7 +69,7 @@ infobox:
68
69
  value: "1.4 billion"
69
70
  ---
70
71
 
71
- Article body with [1] citation markers...
72
+ Article body with [@key] citation markers...
72
73
  \`\`\`
73
74
 
74
75
  ## Infobox
@@ -77,10 +78,12 @@ Include an infobox for any article about a person, place, organization, event, o
77
78
 
78
79
  ## Citations
79
80
 
80
- - Mark claims with [N] after punctuation: "The population is 1.4 billion.[1]"
81
- - Number sequentially starting at [1]
82
- - Every source in the sources list must be referenced at least once in the body
83
- - Every [N] marker must have a matching source
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
84
87
 
85
88
  ## Images
86
89
 
@@ -203,6 +206,9 @@ export function registerArticleTools(server) {
203
206
  slug: z.string().describe("Article slug (e.g. 'machine-learning')"),
204
207
  }),
205
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
+ }
206
212
  const resp = await request("GET", `/api/articles/${slug}`, {
207
213
  params: { format: "md" },
208
214
  });
@@ -210,6 +216,8 @@ export function registerArticleTools(server) {
210
216
  ensureArticlesDir();
211
217
  const filePath = join(ARTICLES_DIR, `${slug}.md`);
212
218
  writeFileSync(filePath, markdown, "utf-8");
219
+ const originalPath = join(ARTICLES_DIR, `.${slug}.original.md`);
220
+ writeFileSync(originalPath, markdown, "utf-8");
213
221
  const { frontmatter, content } = parseFrontmatter(markdown);
214
222
  const title = frontmatter.title || "(untitled)";
215
223
  const wordCount = content.trim().split(/\s+/).filter(Boolean).length;
@@ -257,6 +265,9 @@ export function registerArticleTools(server) {
257
265
  change_description: z.string().optional().describe("Longer description of what changed and why"),
258
266
  }),
259
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
+ }
260
271
  const filePath = join(ARTICLES_DIR, `${slug}.md`);
261
272
  let raw;
262
273
  try {
@@ -290,7 +301,25 @@ export function registerArticleTools(server) {
290
301
  const data = (await resp.json());
291
302
  const articleUrl = `https://www.openalmanac.org/article/${slug}?celebrate=true`;
292
303
  openBrowser(articleUrl);
293
- return `Pushed successfully.\n\nArticle URL (share this exact link with the user): ${articleUrl}\n\n${JSON.stringify(data, null, 2)}`;
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)}`;
294
323
  },
295
324
  });
296
325
  server.addTool({
@@ -308,6 +337,37 @@ export function registerArticleTools(server) {
308
337
  return JSON.stringify(await resp.json(), null, 2);
309
338
  },
310
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
+ });
311
371
  server.addTool({
312
372
  name: "status",
313
373
  description: "Show login status and list all article files in your local working directory (~/.openalmanac/articles/). " +
@@ -318,7 +378,7 @@ export function registerArticleTools(server) {
318
378
  const authLine = auth.loggedIn
319
379
  ? `Logged in as ${auth.name}.`
320
380
  : "Not logged in. Use login to authenticate.";
321
- const files = readdirSync(ARTICLES_DIR).filter((f) => f.endsWith(".md"));
381
+ const files = readdirSync(ARTICLES_DIR).filter((f) => f.endsWith(".md") && !f.startsWith("."));
322
382
  if (files.length === 0) {
323
383
  return `${authLine}\n\nLocal articles (0 files in ${ARTICLES_DIR}):\n (none — use download or new to get started)`;
324
384
  }
package/dist/validate.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { parse as parseYaml } from "yaml";
2
2
  const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
3
3
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
4
- const MARKER_RE = /\[(\d+)\]/g;
4
+ const KEY_RE = /^[a-z0-9]+-[a-z0-9]+(-[a-z0-9]+)*$/;
5
+ const CITE_RE = /\[@([a-z0-9]+-[a-z0-9]+(?:-[a-z0-9]+)*)\]/g;
5
6
  export function parseFrontmatter(raw) {
6
7
  const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
7
8
  if (!match) {
@@ -39,8 +40,26 @@ export function validateArticle(raw) {
39
40
  errors.push({ field: "sources", message: "Sources must be an array" });
40
41
  }
41
42
  else {
43
+ const seenKeys = new Set();
42
44
  for (let i = 0; i < sources.length; i++) {
43
45
  const s = sources[i];
46
+ // key validation
47
+ const key = s.key;
48
+ if (!key || typeof key !== "string") {
49
+ errors.push({ field: `sources[${i}].key`, message: "Key is required" });
50
+ }
51
+ else if (!KEY_RE.test(key)) {
52
+ errors.push({
53
+ field: `sources[${i}].key`,
54
+ message: `Key "${key}" must be kebab-case with at least one hyphen (e.g. 'nytimes-climate-report')`,
55
+ });
56
+ }
57
+ else if (seenKeys.has(key)) {
58
+ errors.push({ field: `sources[${i}].key`, message: `Duplicate key "${key}"` });
59
+ }
60
+ else {
61
+ seenKeys.add(key);
62
+ }
44
63
  if (!s.url || typeof s.url !== "string") {
45
64
  errors.push({ field: `sources[${i}].url`, message: "URL is required" });
46
65
  }
@@ -61,44 +80,30 @@ export function validateArticle(raw) {
61
80
  errors.push({ field: `sources[${i}].accessed_date`, message: "Must be YYYY-MM-DD format" });
62
81
  }
63
82
  }
64
- }
65
- // citation markers
66
- const markerNums = new Set();
67
- let match;
68
- while ((match = MARKER_RE.exec(content)) !== null) {
69
- markerNums.add(parseInt(match[1], 10));
70
- }
71
- if (markerNums.size > 0) {
72
- const sorted = [...markerNums].sort((a, b) => a - b);
73
- if (sorted[0] !== 1) {
74
- errors.push({ field: "citations", message: "Citation markers must start at [1]" });
83
+ // citation markers — collect all [@key] references from content
84
+ const citedKeys = new Set();
85
+ for (const match of content.matchAll(CITE_RE)) {
86
+ citedKeys.add(match[1]);
75
87
  }
76
- for (let i = 1; i < sorted.length; i++) {
77
- if (sorted[i] !== sorted[i - 1] + 1) {
88
+ // cross-check: every [@key] in body must have a matching source
89
+ for (const key of citedKeys) {
90
+ if (!seenKeys.has(key)) {
78
91
  errors.push({
79
92
  field: "citations",
80
- message: `Gap in citation markers: [${sorted[i - 1]}] [${sorted[i]}]`,
93
+ message: `[@${key}] referenced in content but no source has key "${key}"`,
81
94
  });
82
- break;
83
95
  }
84
96
  }
85
- // cross-check markers vs sources
86
- if (Array.isArray(sources)) {
87
- const maxMarker = sorted[sorted.length - 1];
88
- if (maxMarker > sources.length) {
97
+ // cross-check: every source key must be referenced at least once
98
+ for (let i = 0; i < sources.length; i++) {
99
+ const s = sources[i];
100
+ const key = s.key;
101
+ if (key && typeof key === "string" && !citedKeys.has(key)) {
89
102
  errors.push({
90
- field: "citations",
91
- message: `Citation [${maxMarker}] referenced but only ${sources.length} source(s) provided`,
103
+ field: `sources[${i}]`,
104
+ message: `Source "${key}" is never referenced with [@${key}] in the content`,
92
105
  });
93
106
  }
94
- for (let i = 0; i < sources.length; i++) {
95
- if (!markerNums.has(i + 1)) {
96
- errors.push({
97
- field: `sources[${i}]`,
98
- message: `Source ${i + 1} is never referenced with [${i + 1}] in the content`,
99
- });
100
- }
101
- }
102
107
  }
103
108
  }
104
109
  return errors;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openalmanac",
3
- "version": "0.2.29",
3
+ "version": "0.2.31",
4
4
  "description": "OpenAlmanac — pull, edit, and push articles to the open knowledge base",
5
5
  "type": "module",
6
6
  "bin": {