openwriter 0.29.2 → 0.30.1

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.
@@ -10,8 +10,8 @@
10
10
  <link rel="preconnect" href="https://fonts.googleapis.com" />
11
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
12
  <link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
13
- <script type="module" crossorigin src="/assets/index-DHaZI7nA.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-Gdw1m46J.css">
13
+ <script type="module" crossorigin src="/assets/index-D1naX68L.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-BtCWWQrZ.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -8,10 +8,14 @@
8
8
  */
9
9
  import type { Express } from 'express';
10
10
  interface PluginConfigField {
11
- type: 'string' | 'number' | 'boolean';
11
+ type: 'string' | 'number' | 'boolean' | 'select';
12
12
  required?: boolean;
13
13
  env?: string;
14
14
  description?: string;
15
+ options?: Array<{
16
+ value: string;
17
+ label: string;
18
+ }>;
15
19
  }
16
20
  interface PluginRouteContext {
17
21
  app: Express;
@@ -23,11 +23,27 @@ const plugin = {
23
23
  env: 'AV_BACKEND_URL',
24
24
  description: 'AV backend URL',
25
25
  },
26
+ // Model tier for rewrites. Sent as `modelTier` on /api/voice/* (the AV API owns the
27
+ // tier→model map + default — this is a pure pass-through of the user's pick). Blank =
28
+ // API default (strongest). Labels mirror the API's TIER_PICKER_OPTIONS.
29
+ 'model': {
30
+ type: 'select',
31
+ env: 'AV_MODEL_TIER',
32
+ description: 'Writing model',
33
+ options: [
34
+ { value: '', label: 'Default (Strongest)' },
35
+ { value: 'strongest', label: 'Strongest — Claude Opus (best quality)' },
36
+ { value: 'gemini-pro', label: 'Gemini Pro — flagship, strong + cheap' },
37
+ { value: 'balanced', label: 'Balanced — Claude Sonnet' },
38
+ { value: 'fast', label: 'Fast — Gemini Flash (cheapest)' },
39
+ ],
40
+ },
26
41
  },
27
42
  registerRoutes(ctx) {
28
43
  const backendUrl = ctx.config['backend-url'] || process.env.AV_BACKEND_URL || 'https://authors-voice.com';
29
44
  const apiKey = ctx.config['api-key'] || process.env.AV_API_KEY || '';
30
45
  const debugEnabled = process.env.AV_DEBUG === '1' || process.env.AV_DEBUG === 'true';
46
+ const modelTier = ctx.config['model'] || process.env.AV_MODEL_TIER || '';
31
47
  const authHeaders = () => {
32
48
  const h = { 'Content-Type': 'application/json' };
33
49
  if (apiKey)
@@ -39,6 +55,13 @@ const plugin = {
39
55
  return body;
40
56
  return { ...body, debug: true };
41
57
  };
58
+ // Inject the user's model-tier pick. Pure pass-through: the AV API validates the slug
59
+ // and falls back to its own default if absent/unknown. Blank → nothing injected.
60
+ const withModel = (body) => {
61
+ if (!modelTier || !body || typeof body !== 'object')
62
+ return body;
63
+ return { ...body, modelTier };
64
+ };
42
65
  // Wildcard proxy for /api/voice/* routes. Pure pass-through: the AV API owns the
43
66
  // engine choice (v1/v2) via its own AV_DEFAULT_ENGINE setting, so the plugin injects
44
67
  // nothing but the optional owner-only dev debug flag.
@@ -46,7 +69,7 @@ const plugin = {
46
69
  try {
47
70
  const subPath = req.params[0] || '';
48
71
  const targetUrl = `${backendUrl}/api/voice/${subPath}`;
49
- const body = withDebug(req.body);
72
+ const body = withModel(withDebug(req.body));
50
73
  console.log(`[AV Plugin] ${req.method} ${req.path} → ${targetUrl}`);
51
74
  const upstream = await fetch(targetUrl, {
52
75
  method: 'POST',
@@ -31,6 +31,33 @@ async function ghAuthOk(cwd) {
31
31
  return false;
32
32
  }
33
33
  }
34
+ /**
35
+ * Last-resort site-URL detection via the GitHub Pages API. `inferSiteUrl`
36
+ * only reads files committed to the repo (CNAME, wrangler route); this
37
+ * catches GitHub Pages sites whose served URL — custom domain or the
38
+ * `<owner>.github.io/<repo>` default — lives in Pages settings, not a file.
39
+ * Returns the canonical served base URL (trailing slash stripped), or
40
+ * undefined when Pages is not enabled / not accessible. Credential-free
41
+ * (rides the existing `gh auth`); other hosts (Cloudflare Pages, Vercel,
42
+ * Netlify) configure the domain in their dashboard and can't be derived
43
+ * here — those rely on the user supplying site_url.
44
+ */
45
+ async function inferSiteUrlFromGitHubPages(owner, repo, cwd) {
46
+ try {
47
+ const out = await exec('gh', ['api', `repos/${owner}/${repo}/pages`, '--jq', '.html_url'], cwd);
48
+ const url = out.split('\n')[0].trim();
49
+ if (/^https?:\/\//i.test(url))
50
+ return url.replace(/\/+$/, '');
51
+ }
52
+ catch { /* Pages not enabled, or no access — fall through */ }
53
+ return undefined;
54
+ }
55
+ // Shared hint surfaced when site_url couldn't be determined, so the agent
56
+ // knows to ask the user rather than silently shipping posts with no live link.
57
+ const SITE_URL_HINT = 'Could not auto-detect the public site URL (no CNAME, wrangler route, or GitHub Pages config — ' +
58
+ 'common for Cloudflare Pages / Vercel / Netlify sites whose domain lives in the host dashboard). ' +
59
+ 'Ask the user for the public base URL (e.g. https://example.com) and set it via site_url. ' +
60
+ 'Without it, published posts get no clickable "View Post" link (only the commit/file).';
34
61
  function slugify(s) {
35
62
  return s.toLowerCase().replace(/['"]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
36
63
  }
@@ -535,6 +562,18 @@ export function buildFrontmatter(title, blogCtx, site, coverImagePath) {
535
562
  if (!fm[dateDest] && !(publishedDateDest && fm[publishedDateDest])) {
536
563
  fm[dateDest] = new Date().toISOString().slice(0, 10);
537
564
  }
565
+ // Date fields emit as UNQUOTED yaml scalars (pubDate: 2026-05-31), never
566
+ // quoted strings. Astro's z.date() rejects a quoted value — js-yaml parses it
567
+ // as a String, not a Date — which froze a live Netlify build (paybotapp.com,
568
+ // 2026-06-01). The unquoted form is ALSO accepted by z.coerce.date() and by
569
+ // Jekyll/Hugo/Next (gray-matter), so it is the universally-correct emit.
570
+ // adr: adr/blog-image-contract.md
571
+ const dateKeys = new Set([dateDest]);
572
+ if (publishedDateDest)
573
+ dateKeys.add(publishedDateDest);
574
+ const emitLine = (k, v) => dateKeys.has(k) && typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v)
575
+ ? `${k}: ${v}`
576
+ : `${k}: ${yamlValue(v)}`;
538
577
  // Emit in stable order: defaults first (in their declared order),
539
578
  // then title, then any new keys we added
540
579
  const lines = [];
@@ -542,7 +581,7 @@ export function buildFrontmatter(title, blogCtx, site, coverImagePath) {
542
581
  if (site.frontmatter_defaults) {
543
582
  for (const k of Object.keys(site.frontmatter_defaults)) {
544
583
  if (k in fm) {
545
- lines.push(`${k}: ${yamlValue(fm[k])}`);
584
+ lines.push(emitLine(k, fm[k]));
546
585
  written.add(k);
547
586
  }
548
587
  }
@@ -554,7 +593,7 @@ export function buildFrontmatter(title, blogCtx, site, coverImagePath) {
554
593
  for (const [k, v] of Object.entries(fm)) {
555
594
  if (written.has(k))
556
595
  continue;
557
- lines.push(`${k}: ${yamlValue(v)}`);
596
+ lines.push(emitLine(k, v));
558
597
  written.add(k);
559
598
  }
560
599
  return `---\n${lines.join('\n')}\n---\n\n`;
@@ -653,7 +692,10 @@ export function blogTools() {
653
692
  const confidence = framework !== 'unknown' && detected ? 'high'
654
693
  : detected ? 'medium'
655
694
  : 'low';
656
- const siteUrl = inferSiteUrl(cloneDir);
695
+ // site_url: prefer files committed to the repo (CNAME / wrangler route),
696
+ // then fall back to the GitHub Pages API for GH-hosted sites whose served
697
+ // URL lives in Pages settings rather than a committed file.
698
+ const siteUrl = inferSiteUrl(cloneDir) || await inferSiteUrlFromGitHubPages(owner, repo, cacheRoot);
657
699
  return {
658
700
  owner,
659
701
  repo,
@@ -669,6 +711,9 @@ export function blogTools() {
669
711
  // Always propose a pattern even when site_url is unknown so the user can fill in the URL
670
712
  site_url: siteUrl,
671
713
  blog_url_pattern: '/blog/{slug}/',
714
+ // When site_url couldn't be derived, tell the agent to ask the user —
715
+ // otherwise it ships posts with no live "View Post" link, silently.
716
+ ...(siteUrl ? {} : { needs_site_url: true, site_url_hint: SITE_URL_HINT }),
672
717
  samples_analyzed: samplesAfterFilter.length,
673
718
  samples_skipped_openwriter_leak: samplesSkipped,
674
719
  markdown_files_found: mdBest?.count ?? 0,
@@ -707,7 +752,7 @@ export function blogTools() {
707
752
  },
708
753
  site_url: {
709
754
  type: 'string',
710
- description: 'Public base URL of the site (e.g. "https://example.com"). Used to construct the live URL surfaced after publish. inspect_blog_repo proposes this from CNAME / wrangler.toml when found.',
755
+ description: 'Public base URL of the site (e.g. "https://example.com"). Used to construct the live "View Post" URL surfaced after publish. inspect_blog_repo proposes this from CNAME / wrangler.toml / the GitHub Pages API when found; for Cloudflare Pages / Vercel / Netlify (domain configured in the host dashboard) it can\'t be auto-detected — ask the user and pass it here. If omitted, the response returns needs_site_url so you know to follow up; backfill later with edit_blog_site.',
711
756
  },
712
757
  blog_url_pattern: {
713
758
  type: 'string',
@@ -752,7 +797,12 @@ export function blogTools() {
752
797
  const sites = await listBlogSites();
753
798
  sites.push(site);
754
799
  await writeBlogSites(sites);
755
- return { success: true, site };
800
+ return {
801
+ success: true,
802
+ site,
803
+ // No site_url ⇒ no live link on publish. Tell the agent to follow up.
804
+ ...(site.site_url ? {} : { needs_site_url: true, site_url_hint: SITE_URL_HINT }),
805
+ };
756
806
  },
757
807
  },
758
808
  {
@@ -784,6 +834,68 @@ export function blogTools() {
784
834
  return { success: true, removed: id };
785
835
  },
786
836
  },
837
+ {
838
+ name: 'edit_blog_site',
839
+ description: 'Update fields on an already-registered blog site by id. Only the fields you pass change; everything else is left intact. The common use is backfilling site_url / blog_url_pattern after registration so published posts get a live "View Post" link (e.g. for Cloudflare Pages / Vercel / Netlify sites where the domain couldn\'t be auto-detected).',
840
+ inputSchema: {
841
+ type: 'object',
842
+ properties: {
843
+ id: { type: 'string', description: 'Blog site id (from list_blog_sites)' },
844
+ label: { type: 'string', description: 'User-facing name' },
845
+ branch: { type: 'string', description: 'Branch to push to' },
846
+ content_dir: { type: 'string', description: 'Directory where post .md files live' },
847
+ image_dir: { type: 'string', description: 'Directory where image files write' },
848
+ image_public_prefix: { type: 'string', description: 'Directory prefix images live under' },
849
+ image_path_style: { type: 'string', enum: ['relative', 'absolute'], description: 'How image paths are written' },
850
+ image_naming: { type: 'string', description: 'Cover filename template with `{slug}` + `{ext}`' },
851
+ framework: { type: 'string', enum: ['astro', 'next', 'jekyll', 'hugo', 'unknown'], description: 'Site framework' },
852
+ frontmatter_defaults: { type: 'object', description: 'Constants applied to every post\'s frontmatter' },
853
+ frontmatter_field_map: { type: 'object', description: 'Rename map: openwriter blogContext key → site frontmatter key' },
854
+ frontmatter_schema: { type: 'array', items: { type: 'string' }, description: 'List of frontmatter keys the site uses' },
855
+ site_url: { type: 'string', description: 'Public base URL (e.g. "https://example.com"). Pass an empty string to clear it.' },
856
+ blog_url_pattern: { type: 'string', description: 'URL path pattern with `{slug}` placeholder (e.g. "/blog/{slug}/").' },
857
+ },
858
+ required: ['id'],
859
+ },
860
+ handler: async (params) => {
861
+ const id = String(params.id);
862
+ const sites = await listBlogSites();
863
+ const site = sites.find(s => s.id === id);
864
+ if (!site)
865
+ return { error: `No blog site with id ${id}` };
866
+ // Plain string fields — set when a non-empty string is provided.
867
+ for (const key of ['label', 'branch', 'content_dir', 'image_dir', 'image_public_prefix', 'image_naming', 'blog_url_pattern']) {
868
+ if (typeof params[key] === 'string' && params[key].trim()) {
869
+ site[key] = params[key].trim();
870
+ }
871
+ }
872
+ // site_url: trim + strip trailing slash; empty string clears it.
873
+ if (typeof params.site_url === 'string') {
874
+ const v = params.site_url.trim().replace(/\/+$/, '');
875
+ if (v)
876
+ site.site_url = v;
877
+ else
878
+ delete site.site_url;
879
+ }
880
+ if (params.image_path_style === 'relative' || params.image_path_style === 'absolute') {
881
+ site.image_path_style = params.image_path_style;
882
+ }
883
+ if (params.framework && ['astro', 'next', 'jekyll', 'hugo', 'unknown'].includes(String(params.framework))) {
884
+ site.framework = params.framework;
885
+ }
886
+ if (params.frontmatter_defaults && typeof params.frontmatter_defaults === 'object') {
887
+ site.frontmatter_defaults = params.frontmatter_defaults;
888
+ }
889
+ if (params.frontmatter_field_map && typeof params.frontmatter_field_map === 'object') {
890
+ site.frontmatter_field_map = params.frontmatter_field_map;
891
+ }
892
+ if (Array.isArray(params.frontmatter_schema)) {
893
+ site.frontmatter_schema = params.frontmatter_schema.map(String);
894
+ }
895
+ await writeBlogSites(sites);
896
+ return { success: true, site };
897
+ },
898
+ },
787
899
  {
788
900
  name: 'post_to_blog',
789
901
  description: 'Publish the active OpenWriter document to a registered GitHub blog site via local git ops (clone-or-pull, write file + images, commit, push). Auth uses your existing `gh auth login`.',
@@ -979,6 +1091,15 @@ export function blogTools() {
979
1091
  // Same convention mcp.ts:1112 uses for active-doc metadata writes.
980
1092
  srv.bumpDocVersion();
981
1093
  srv.save();
1094
+ // Notify every connected client so the file-tree "published" ✓ + the
1095
+ // "Republish to Blog" context-menu label + the compose-view "Published"
1096
+ // pill flip live, with no manual reload. metadata-changed updates the
1097
+ // active doc's compose view; documents-changed re-reads /api/documents
1098
+ // (where blogContext.lastPublish.publishedUrl → postedUrl drives the
1099
+ // file tree). Mirrors the broadcast-after-setMetadata convention the
1100
+ // core MCP tools follow. adr: adr/plugin-metadata-broadcast.md
1101
+ srv.broadcastMetadataChanged(srv.getMetadata());
1102
+ srv.broadcastDocumentsChanged();
982
1103
  }
983
1104
  catch (err) {
984
1105
  writebackWarning = `Published successfully, but failed to mark doc as sent: ${err.message}`;
@@ -15,6 +15,8 @@ export interface ServerModules {
15
15
  setMetadata: (updates: Record<string, any>) => void;
16
16
  bumpDocVersion: () => number;
17
17
  broadcastSyncStatus: (status: any) => void;
18
+ broadcastMetadataChanged: (metadata: Record<string, any>) => void;
19
+ broadcastDocumentsChanged: () => void;
18
20
  tiptapToMarkdown: (doc: any, title: string, metadata?: Record<string, any>) => string;
19
21
  }
20
22
  export declare function getServerModules(): Promise<ServerModules>;
@@ -39,6 +39,8 @@ export async function getServerModules() {
39
39
  setMetadata: state.setMetadata,
40
40
  bumpDocVersion: state.bumpDocVersion,
41
41
  broadcastSyncStatus: ws.broadcastSyncStatus,
42
+ broadcastMetadataChanged: ws.broadcastMetadataChanged,
43
+ broadcastDocumentsChanged: ws.broadcastDocumentsChanged,
42
44
  tiptapToMarkdown: markdown.tiptapToMarkdown,
43
45
  };
44
46
  return _cached;
@@ -300,6 +300,7 @@ const plugin = {
300
300
  properties: {
301
301
  content: { type: 'string', description: 'Tweet text (max 280 characters)' },
302
302
  connection_id: { type: 'string', description: 'X connection ID. If omitted, uses the first active X connection.' },
303
+ autoplug: { type: 'boolean', description: 'Eligible for engagement autoplugs (default true). Pass false to opt this post out of autoplug tracking.' },
303
304
  },
304
305
  required: ['content'],
305
306
  },
@@ -321,7 +322,7 @@ const plugin = {
321
322
  const server = await getServerModules();
322
323
  const res = await server.platformFetch(`/connections/${id}/post`, {
323
324
  method: 'POST',
324
- body: JSON.stringify({ content: params.content }),
325
+ body: JSON.stringify({ content: params.content, no_autoplug: params.autoplug === false }),
325
326
  });
326
327
  const data = await res.json();
327
328
  if (!res.ok)
@@ -500,6 +501,10 @@ const plugin = {
500
501
  totalMedia = mediaIds.length;
501
502
  queueContent = mediaIds.length > 0 ? { text, mediaIds } : { text };
502
503
  }
504
+ // Honor the doc's autoplug opt-out — rides inside the content JSONB so
505
+ // the platform cron skips enrollment for this scheduled post.
506
+ if (metadata?.autoplug === false)
507
+ queueContent.no_autoplug = true;
503
508
  // --- Queue it ---
504
509
  const body = {
505
510
  content: queueContent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.29.2",
3
+ "version": "0.30.1",
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",
@@ -31,7 +31,8 @@
31
31
  "./plugin-types": "./dist/server/plugin-types.js"
32
32
  },
33
33
  "scripts": {
34
- "build": "vite build && tsc -p tsconfig.server.json",
34
+ "build": "vite build && tsc -p tsconfig.server.json && npm run build:plugins",
35
+ "build:plugins": "node scripts/build-plugins.cjs",
35
36
  "prepublishOnly": "node scripts/prepublish.cjs",
36
37
  "preview": "node dist/bin/pad.js",
37
38
  "lint": "eslint src server bin --ext .ts,.tsx"