openwriter 0.37.1 → 0.38.0

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.
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Shared markdown-it instance for manuscript rendering — matches the
3
+ * configuration in export-routes.ts (the single-doc export path) so manuscript
4
+ * output is consistent with OpenWriter's existing exports: same flavor, same
5
+ * footnote plugin. `xhtml` mode self-closes void elements for EPUB's XHTML.
6
+ *
7
+ * adr: adr/manuscript-engine.md
8
+ */
9
+ import MarkdownIt from 'markdown-it';
10
+ import markdownItIns from 'markdown-it-ins';
11
+ import markdownItMark from 'markdown-it-mark';
12
+ import markdownItSub from 'markdown-it-sub';
13
+ import markdownItSup from 'markdown-it-sup';
14
+ import markdownItFootnote from 'markdown-it-footnote';
15
+ export function createMd(xhtml = false) {
16
+ const md = new MarkdownIt({ linkify: false, html: true, xhtmlOut: xhtml });
17
+ md.enable('strikethrough');
18
+ md.use(markdownItIns);
19
+ md.use(markdownItMark);
20
+ md.use(markdownItSub);
21
+ md.use(markdownItSup);
22
+ md.use(markdownItFootnote);
23
+ // Chapter anchors: every top-level heading (h1 = a chapter; beats are demoted
24
+ // to h2+) gets a stable sequential id `ch-N`. The assembler's {{toc}} links to
25
+ // these (#ch-N) and the preview's chapter tick-rail also keys off them, so the
26
+ // contents page and the side navigator point at the same targets.
27
+ md.core.ruler.push('chapter_ids', (state) => {
28
+ let n = 0;
29
+ for (const tok of state.tokens) {
30
+ if (tok.type === 'heading_open' && tok.tag === 'h1') {
31
+ n += 1;
32
+ tok.attrSet('id', `ch-${n}`);
33
+ }
34
+ }
35
+ });
36
+ return md;
37
+ }
38
+ /** Render a markdown string to an HTML fragment. */
39
+ export function renderBodyHtml(markdown, xhtml = false) {
40
+ return createMd(xhtml).render(markdown);
41
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Manuscript doc resolution — disk I/O.
3
+ *
4
+ * Maps each manifest doc-pointer (docId) to its current on-disk body. Resolution
5
+ * is by stable docId (rename-safe): filenameByDocId → readFrontmatter().content.
6
+ * The body read from disk is the canonical, ACCEPTED text — pending agent
7
+ * suggestions live in the `_pending/<docId>.json` sidecar, never in the .md body
8
+ * — so a compiled manuscript never ships an un-accepted edit. A docId resolves
9
+ * regardless of which workspace/container the doc lives in (location-independent),
10
+ * which is why a transition doc written "in some random folder" links fine.
11
+ *
12
+ * adr: adr/manuscript-engine.md
13
+ */
14
+ import { filenameByDocId } from '../documents.js';
15
+ import { readFrontmatter } from '../backlinks.js';
16
+ export function resolveManifestDocs(manifest) {
17
+ const bodyMap = new Map();
18
+ const warnings = [];
19
+ for (const section of manifest.sections) {
20
+ for (const item of section.items) {
21
+ if (item.kind !== 'doc' || !item.docId)
22
+ continue;
23
+ if (bodyMap.has(item.docId))
24
+ continue;
25
+ const filename = filenameByDocId(item.docId);
26
+ if (!filename) {
27
+ warnings.push(`docId ${item.docId} not found (${item.text ?? ''})`);
28
+ continue;
29
+ }
30
+ const fm = readFrontmatter(filename);
31
+ if (!fm) {
32
+ warnings.push(`Could not read ${filename} for docId ${item.docId}`);
33
+ continue;
34
+ }
35
+ bodyMap.set(item.docId, {
36
+ title: fm.data.title || item.text || 'Untitled',
37
+ body: fm.content,
38
+ });
39
+ }
40
+ }
41
+ return { bodyMap, warnings };
42
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Manuscript routes: compile + render a manuscript doc to a file or preview.
3
+ *
4
+ * GET /api/manuscript/preview?docId=<id> -> book HTML (iframe source)
5
+ * GET /api/manuscript/export?docId=<id>&format=epub -> epub | docx | html | md
6
+ *
7
+ * Both resolve the manifest doc by stable docId, compile it (resolve pointers +
8
+ * assemble), then render. Preview and export share the same compile() path, so
9
+ * the preview can never disagree with the exported book. adr: adr/manuscript-engine.md
10
+ */
11
+ import { Router } from 'express';
12
+ import { compileManuscript, renderBookHtml, renderEpub, renderDocx, } from './manuscript/index.js';
13
+ import { listManuscripts, loadManifest, safeName } from './manuscript/load.js';
14
+ export function createManuscriptRouter() {
15
+ const router = Router();
16
+ // Always-on launcher list for the right rail — every manuscript in the profile.
17
+ router.get('/api/manuscripts', (_req, res) => {
18
+ res.json({ manuscripts: listManuscripts() });
19
+ });
20
+ router.get('/api/manuscript/preview', (req, res) => {
21
+ const ms = loadManifest(String(req.query.docId || ''));
22
+ if (!ms)
23
+ return res.status(404).json({ error: 'manuscript doc not found' });
24
+ const { markdown, meta } = compileManuscript(ms.body, ms.meta);
25
+ // The manuscript compose view frames this preview SAME-ORIGIN. The global
26
+ // security gate sends X-Frame-Options: DENY + frame-ancestors 'none', which
27
+ // blocks the iframe entirely. Relax BOTH to same-origin for this route only —
28
+ // a self-contained book page, framed by the app itself, nothing external.
29
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
30
+ res.setHeader('Content-Security-Policy', "default-src 'self'; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-ancestors 'self'");
31
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
32
+ // Screen light/dark follows the app's Appearance setting, passed by the
33
+ // compose view. Export (below) never does — the book ships print-light.
34
+ const mode = req.query.mode === 'dark' ? 'dark' : 'light';
35
+ res.send(renderBookHtml(markdown, meta, mode));
36
+ });
37
+ router.get('/api/manuscript/export', async (req, res) => {
38
+ const ms = loadManifest(String(req.query.docId || ''));
39
+ if (!ms)
40
+ return res.status(404).json({ error: 'manuscript doc not found' });
41
+ const format = String(req.query.format || 'epub').toLowerCase();
42
+ const result = compileManuscript(ms.body, ms.meta);
43
+ const name = safeName(result.meta.title || '');
44
+ try {
45
+ switch (format) {
46
+ case 'epub': {
47
+ const buf = await renderEpub(result.markdown, result.meta);
48
+ res.setHeader('Content-Type', 'application/epub+zip');
49
+ res.setHeader('Content-Disposition', `attachment; filename="${name}.epub"`);
50
+ return res.send(buf);
51
+ }
52
+ case 'docx': {
53
+ const buf = await renderDocx(result.markdown, result.meta);
54
+ res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
55
+ res.setHeader('Content-Disposition', `attachment; filename="${name}.docx"`);
56
+ return res.send(buf);
57
+ }
58
+ case 'html': {
59
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
60
+ res.setHeader('Content-Disposition', `attachment; filename="${name}.html"`);
61
+ return res.send(renderBookHtml(result.markdown, result.meta));
62
+ }
63
+ case 'md': {
64
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
65
+ res.setHeader('Content-Disposition', `attachment; filename="${name}.md"`);
66
+ return res.send(result.markdown);
67
+ }
68
+ default:
69
+ return res.status(400).json({ error: `Unknown format: ${format}. Use epub, docx, html, or md.` });
70
+ }
71
+ }
72
+ catch (err) {
73
+ console.error('[Manuscript] export error:', err?.message);
74
+ return res.status(500).json({ error: 'Manuscript export failed' });
75
+ }
76
+ });
77
+ return router;
78
+ }
@@ -9,7 +9,9 @@ import { randomUUID } from 'crypto';
9
9
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
11
  import { z } from 'zod';
12
- import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId, atomicWriteFileSync, readConfig } from './helpers.js';
12
+ import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId, atomicWriteFileSync, readConfig, ROOT_DIR } from './helpers.js';
13
+ import { compileManuscript, renderBookHtml, renderEpub, renderDocx } from './manuscript/index.js';
14
+ import { loadManifest, safeName } from './manuscript/load.js';
13
15
  import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, setAgentLockActive, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, getIsTemp, extractText, countPending, addDocTag, removeDocTag, getCachedDocument, invalidateDocCache, isAutoAcceptActive, removePendingCacheEntry, getExternalMtimeDrift, reloadActiveDocFromDisk, getCanonical, cloneWithPendingReverted, bumpDocVersion, setSortProposalOnFile, clearSortRequestOnFile, } from './state.js';
14
16
  import { tiptapToBlocks } from './node-blocks.js';
15
17
  import { readBlame, summarizeBlame } from './attribution.js';
@@ -489,7 +491,7 @@ export const TOOL_REGISTRY = [
489
491
  workspace: z.string().optional().describe('Workspace title to add this doc to. Creates the workspace if it doesn\'t exist.'),
490
492
  container: z.string().optional().describe('Container name within the workspace (e.g. "Chapters", "Notes", "References"). Creates the container if it doesn\'t exist. Requires workspace.'),
491
493
  empty: z.boolean().optional().describe('ONLY for content_type template docs (tweets, articles) that start blank. Skips the spinner and switches immediately. Do NOT set this for content documents — use the two-step flow (create_document → populate_document) instead.'),
492
- content_type: z.enum(['document', 'tweet', 'reply', 'quote', 'article', 'linkedin', 'newsletter', 'blog']).describe('Required. Use "document" for plain documents. Tweet/reply/quote/article/linkedin/newsletter/blog set type-specific metadata automatically.'),
494
+ content_type: z.enum(['document', 'tweet', 'reply', 'quote', 'article', 'linkedin', 'newsletter', 'blog', 'manuscript']).describe('Required. Use "document" for plain documents. Tweet/reply/quote/article/linkedin/newsletter/blog set type-specific metadata automatically. "manuscript" = a binding doc whose body is an ordered list of [text](doc:ID) pointers under ## chapter headings; populate it with the manifest, then it compiles to EPUB/DOCX via the manuscript routes.'),
493
495
  url: z.string().optional().describe('Tweet URL — REQUIRED for content_type "reply" or "quote" (e.g. "https://x.com/user/status/123"). Sets tweetContext.url automatically. Ignored for other content types.'),
494
496
  afterId: z.string().optional().describe('Place the new doc immediately after this docId (8-char hex) or containerId inside its parent. Omit to append to the bottom of the parent (the default — matches ascending-order convention: newest at bottom). Requires workspace.'),
495
497
  status: z.enum(['canonical', 'draft']).optional().describe('Agent-owned lifecycle. "canonical" = committed to spine / load-bearing for the workspace (use for Beats docs that have locked, Research Notes, Master References). "draft" = working / not load-bearing yet / scratch (DUMP docs, first-pass beats). Defaults to "draft" when omitted. Change later via set_metadata({ status: ... }) on lifecycle transitions. v0.19.0.'),
@@ -728,7 +730,7 @@ export const TOOL_REGISTRY = [
728
730
  schema: {
729
731
  writes: z.array(z.object({
730
732
  title: z.string().describe('Title for the document.'),
731
- content_type: z.enum(['document', 'tweet', 'reply', 'quote', 'article', 'linkedin', 'newsletter', 'blog']).describe('Content type. Use "document" for plain docs.'),
733
+ content_type: z.enum(['document', 'tweet', 'reply', 'quote', 'article', 'linkedin', 'newsletter', 'blog', 'manuscript']).describe('Content type. Use "document" for plain docs. "manuscript" = a binding doc of ordered doc: pointers.'),
732
734
  workspace: z.string().optional().describe('Workspace title to add this doc to. Creates the workspace if it does not exist.'),
733
735
  container: z.string().optional().describe('Container name within the workspace (e.g. "Chapters"). Requires workspace.'),
734
736
  url: z.string().optional().describe('Tweet URL — REQUIRED for content_type "reply" or "quote".'),
@@ -877,6 +879,78 @@ export const TOOL_REGISTRY = [
877
879
  return { content: [{ type: 'text', text: `Restored "${result.title}" [${docId}] from archive` }] };
878
880
  },
879
881
  },
882
+ {
883
+ name: 'compile_manuscript',
884
+ description: 'Compile a manuscript doc (content_type "manuscript") into the assembled master book and report its structure + any problems — WITHOUT writing a file. Resolves every `doc:` pointer in the manifest, concatenates the canonical (accepted) bodies in manifest order under their chapter headings, namespaces footnotes, then returns: title, per-chapter word counts, chapter count, total word count, and warnings. Warnings flag unresolved pointers — a beat that points at a missing/renamed/archived doc — so this is the build-time feedback loop: run it to confirm the binding resolves and see how each chapter is sizing up. Pass includeMarkdown:true to also return the full assembled markdown (large for a real book). Target the manifest by docId (8-char hex).',
885
+ schema: {
886
+ docId: z.string().describe('The manuscript doc (the manifest) by docId (8-char hex from list_documents).'),
887
+ includeMarkdown: z.boolean().optional().describe('Also return the full assembled master markdown. Off by default — for a long book this is very large.'),
888
+ },
889
+ handler: async ({ docId, includeMarkdown }) => {
890
+ const ms = loadManifest(docId);
891
+ if (!ms)
892
+ return { content: [{ type: 'text', text: `No manuscript doc found for docId ${docId}. Is it content_type "manuscript"?` }] };
893
+ const { markdown, meta, warnings } = compileManuscript(ms.body, ms.meta);
894
+ const wc = (s) => { const t = s.trim(); return t ? t.split(/\s+/).length : 0; };
895
+ const chapters = [];
896
+ let cur = null;
897
+ for (const line of markdown.split('\n')) {
898
+ const h = line.match(/^# (.+)/);
899
+ if (h) {
900
+ cur = { title: h[1].trim(), words: 0 };
901
+ chapters.push(cur);
902
+ }
903
+ else if (cur)
904
+ cur.words += wc(line);
905
+ }
906
+ const summary = {
907
+ title: meta.title,
908
+ chapterCount: chapters.length,
909
+ totalWords: wc(markdown),
910
+ chapters,
911
+ warnings,
912
+ };
913
+ if (includeMarkdown)
914
+ summary.markdown = markdown;
915
+ return { content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }] };
916
+ },
917
+ },
918
+ {
919
+ name: 'export_manuscript',
920
+ description: 'Compile a manuscript doc and WRITE the rendered book to a file: epub (KDP-ready ebook), docx (Word), html (single styled file), or md (the raw assembled master markdown). Same compile + render path as the in-app preview, so the file matches what you see there. Writes to ~/.openwriter/exports/<title>.<ext> by default (or an explicit outputPath) and returns the absolute path plus any compile warnings. EPUB/HTML ship print-light (e-readers handle their own dark mode). Target the manifest by docId (8-char hex).',
921
+ schema: {
922
+ docId: z.string().describe('The manuscript doc (the manifest) by docId (8-char hex from list_documents).'),
923
+ format: z.enum(['epub', 'docx', 'html', 'md']).describe('Output format. epub = KDP-ready ebook; docx = Word; html = single styled file; md = raw assembled master markdown.'),
924
+ outputPath: z.string().optional().describe('Absolute file path to write. Defaults to ~/.openwriter/exports/<title>.<ext>.'),
925
+ },
926
+ handler: async ({ docId, format, outputPath }) => {
927
+ const ms = loadManifest(docId);
928
+ if (!ms)
929
+ return { content: [{ type: 'text', text: `No manuscript doc found for docId ${docId}. Is it content_type "manuscript"?` }] };
930
+ const result = compileManuscript(ms.body, ms.meta);
931
+ const dir = join(ROOT_DIR, 'exports');
932
+ mkdirSync(dir, { recursive: true });
933
+ const outPath = outputPath || join(dir, `${safeName(result.meta.title || '')}.${format}`);
934
+ switch (format) {
935
+ case 'epub':
936
+ writeFileSync(outPath, await renderEpub(result.markdown, result.meta));
937
+ break;
938
+ case 'docx':
939
+ writeFileSync(outPath, await renderDocx(result.markdown, result.meta));
940
+ break;
941
+ case 'html':
942
+ writeFileSync(outPath, renderBookHtml(result.markdown, result.meta), 'utf-8');
943
+ break;
944
+ case 'md':
945
+ writeFileSync(outPath, result.markdown, 'utf-8');
946
+ break;
947
+ }
948
+ const warn = result.warnings.length
949
+ ? ` — ${result.warnings.length} warning(s): ${result.warnings.slice(0, 3).join('; ')}${result.warnings.length > 3 ? ' …' : ''}`
950
+ : '';
951
+ return { content: [{ type: 'text', text: `Exported "${result.meta.title}" (${format}) → ${outPath}${warn}` }] };
952
+ },
953
+ },
880
954
  {
881
955
  name: 'get_metadata',
882
956
  description: 'Get the JSON frontmatter metadata for a document. Returns all key-value pairs stored in frontmatter (title, summary, characters, tags, etc.). Useful for understanding document context without reading full content.',
@@ -1358,7 +1432,7 @@ export const TOOL_REGISTRY = [
1358
1432
  settings: z.record(z.string()).optional().describe('Setting name → description (merged)'),
1359
1433
  rules: z.array(z.string()).optional().describe('Writing rules for this workspace (replaces)'),
1360
1434
  logline: z.string().nullable().optional().describe('One-sentence "what this workspace is for". Set null to clear.'),
1361
- domain: z.string().nullable().optional().describe('Subject area (e.g. "Male ethology"). Set null to clear.'),
1435
+ domain: z.string().nullable().optional().describe('Subject area (e.g. "Marine biology"). Set null to clear.'),
1362
1436
  schema: z.string().nullable().optional().describe('Workspace kind: book / concept-library / inbox / social / reference. Set null to clear.'),
1363
1437
  vocab: z.array(z.string()).nullable().optional().describe('Closed list of valid domain names — Haiku classifies docs INTO these. Set null to clear (opens vocab to free-form).'),
1364
1438
  relatedWorkspaces: z.array(z.string()).nullable().optional().describe('Sibling workspace filenames. Set null to clear.'),
@@ -831,7 +831,7 @@ export function mergeMetadataUpdates(existing, updates) {
831
831
  return null;
832
832
  }
833
833
  // Deep-merge known context objects
834
- const CONTEXT_KEYS = ['blogContext', 'newsletterContext', 'articleContext', 'tweetContext', 'linkedinContext'];
834
+ const CONTEXT_KEYS = ['blogContext', 'newsletterContext', 'articleContext', 'tweetContext', 'linkedinContext', 'manuscriptContext'];
835
835
  for (const key of CONTEXT_KEYS) {
836
836
  if (updates[key] && typeof updates[key] === 'object' && existing?.[key] && typeof existing[key] === 'object') {
837
837
  updates[key] = { ...existing[key], ...updates[key] };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.37.1",
3
+ "version": "0.38.0",
4
4
  "description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -63,6 +63,7 @@
63
63
  "@xdevplatform/xdk": "^0.4.0",
64
64
  "express": "^4.21.0",
65
65
  "gray-matter": "^4.0.3",
66
+ "jszip": "^3.10.1",
66
67
  "lowlight": "^3.3.0",
67
68
  "markdown-it": "^14.1.1",
68
69
  "markdown-it-footnote": "^4.0.0",