openwriter 0.2.2 → 0.3.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.
@@ -3,16 +3,21 @@
3
3
  * Uses compact wire format for token efficiency.
4
4
  * Exports TOOL_REGISTRY for HTTP proxy (multi-session support).
5
5
  */
6
+ import { join } from 'path';
7
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
8
+ import { randomUUID } from 'crypto';
6
9
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
11
  import { z } from 'zod';
12
+ import { DATA_DIR, ensureDataDir } from './helpers.js';
9
13
  import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, getMetadata, setMetadata, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, } from './state.js';
10
14
  import { listDocuments, switchDocument, createDocument, deleteDocument, openFile, getActiveFilename } from './documents.js';
11
- import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished } from './ws.js';
15
+ import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished } from './ws.js';
12
16
  import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc } from './workspaces.js';
13
17
  import { addDocTag, removeDocTag, getDocTagsByFilename } from './state.js';
14
18
  import { importGoogleDoc } from './gdoc-import.js';
15
19
  import { toCompactFormat, compactNodes, parseMarkdownContent } from './compact.js';
20
+ import { getUpdateInfo } from './update-check.js';
16
21
  export const TOOL_REGISTRY = [
17
22
  {
18
23
  name: 'read_pad',
@@ -63,7 +68,10 @@ export const TOOL_REGISTRY = [
63
68
  description: 'Get the current status of the pad: word count, pending changes. Cheap call for polling.',
64
69
  schema: {},
65
70
  handler: async () => {
66
- return { content: [{ type: 'text', text: JSON.stringify(getStatus()) }] };
71
+ const status = getStatus();
72
+ const latestVersion = getUpdateInfo();
73
+ const payload = latestVersion ? { ...status, updateAvailable: latestVersion } : status;
74
+ return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
67
75
  },
68
76
  },
69
77
  {
@@ -106,14 +114,15 @@ export const TOOL_REGISTRY = [
106
114
  },
107
115
  {
108
116
  name: 'create_document',
109
- description: 'Create a new empty document and switch to it. Always provide a title. Saves the current document first. Shows a sidebar spinner that persists until populate_document is called — always call populate_document next to add content. If workspace is provided, the doc is automatically added to it (workspace is created if it doesn\'t exist). If container is also provided, the doc is placed inside that container (created if it doesn\'t exist).',
117
+ description: 'Create a new empty document and switch to it. Always provide a title. Saves the current document first. By default shows a sidebar spinner that persists until populate_document is called — set empty=true to skip the spinner and switch immediately (use for template docs like tweets/articles that don\'t need agent content). If workspace is provided, the doc is automatically added to it (workspace is created if it doesn\'t exist). If container is also provided, the doc is placed inside that container (created if it doesn\'t exist).',
110
118
  schema: {
111
119
  title: z.string().optional().describe('Title for the new document. Defaults to "Untitled".'),
112
120
  path: z.string().optional().describe('Absolute file path to create the document at (e.g. "C:/projects/doc.md"). If omitted, creates in ~/.openwriter/.'),
113
121
  workspace: z.string().optional().describe('Workspace title to add this doc to. Creates the workspace if it doesn\'t exist.'),
114
122
  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.'),
123
+ empty: z.boolean().optional().describe('If true, skip the writing spinner and switch to the doc immediately. No need to call populate_document. Use for template docs (tweets, articles) that start empty.'),
115
124
  },
116
- handler: async ({ title, path, workspace, container }) => {
125
+ handler: async ({ title, path, workspace, container, empty }) => {
117
126
  // Resolve workspace/container up front so spinner renders in the right place
118
127
  let wsTarget;
119
128
  if (workspace) {
@@ -126,24 +135,38 @@ export const TOOL_REGISTRY = [
126
135
  wsTarget = { wsFilename: ws.filename, containerId };
127
136
  broadcastWorkspacesChanged(); // Browser sees container structure before spinner
128
137
  }
129
- broadcastWritingStarted(title || 'Untitled', wsTarget);
130
- // Yield so the browser receives and renders the placeholder before heavy work
131
- await new Promise((resolve) => setTimeout(resolve, 200));
138
+ if (!empty) {
139
+ broadcastWritingStarted(title || 'Untitled', wsTarget);
140
+ // Yield so the browser receives and renders the placeholder before heavy work
141
+ await new Promise((resolve) => setTimeout(resolve, 200));
142
+ }
132
143
  try {
133
144
  // Lock browser doc-updates: prevents race where browser sends a doc-update
134
145
  // for the previous document but server has already switched active doc.
135
146
  setAgentLock();
136
147
  const result = createDocument(title, undefined, path);
137
- setMetadata({ agentCreated: true });
138
- save(); // Persist agentCreated flag to frontmatter
139
- // Auto-add to workspace if specified (defer sidebar broadcasts to populate_document
140
- // so the real doc entry doesn't appear alongside the spinner placeholder)
148
+ // Auto-add to workspace if specified
141
149
  let wsInfo = '';
142
150
  if (wsTarget) {
143
151
  addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
144
152
  wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
145
153
  }
146
- // Spinner persists until populate_document is called
154
+ if (empty) {
155
+ // Immediate switch — no spinner, no populate_document needed
156
+ save();
157
+ broadcastDocumentsChanged();
158
+ broadcastWorkspacesChanged();
159
+ broadcastDocumentSwitched(getDocument(), getTitle(), getActiveFilename());
160
+ return {
161
+ content: [{
162
+ type: 'text',
163
+ text: `Created "${result.title}" (${result.filename})${wsInfo} — ready.`,
164
+ }],
165
+ };
166
+ }
167
+ // Two-step flow: spinner persists until populate_document is called
168
+ setMetadata({ agentCreated: true });
169
+ save(); // Persist agentCreated flag to frontmatter
147
170
  return {
148
171
  content: [{
149
172
  type: 'text',
@@ -152,7 +175,8 @@ export const TOOL_REGISTRY = [
152
175
  };
153
176
  }
154
177
  catch (err) {
155
- broadcastWritingFinished();
178
+ if (!empty)
179
+ broadcastWritingFinished();
156
180
  throw err;
157
181
  }
158
182
  },
@@ -270,6 +294,7 @@ export const TOOL_REGISTRY = [
270
294
  for (const key of removed)
271
295
  delete meta[key];
272
296
  save();
297
+ broadcastMetadataChanged(getMetadata());
273
298
  if (cleaned.title) {
274
299
  broadcastTitleChanged(cleaned.title);
275
300
  broadcastDocumentsChanged();
@@ -492,8 +517,61 @@ export const TOOL_REGISTRY = [
492
517
  return { content: [{ type: 'text', text }] };
493
518
  },
494
519
  },
520
+ {
521
+ name: 'generate_image',
522
+ description: 'Generate an image using Gemini Imagen 4. Saves to ~/.openwriter/_images/. Optionally sets it as the active article\'s cover image atomically. Requires GEMINI_API_KEY env var.',
523
+ schema: {
524
+ prompt: z.string().max(1000).describe('Image generation prompt (max 1000 chars)'),
525
+ aspect_ratio: z.string().optional().describe('Aspect ratio (default "16:9"). Use "5:2" for article covers.'),
526
+ set_cover: z.boolean().optional().describe('If true, atomically set the generated image as the article cover (articleContext.coverImage in metadata).'),
527
+ },
528
+ handler: async ({ prompt, aspect_ratio, set_cover }) => {
529
+ const apiKey = process.env.GEMINI_API_KEY;
530
+ if (!apiKey) {
531
+ return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
532
+ }
533
+ const { GoogleGenAI } = await import('@google/genai');
534
+ const ai = new GoogleGenAI({ apiKey });
535
+ const response = await ai.models.generateImages({
536
+ model: 'imagen-4.0-generate-001',
537
+ prompt,
538
+ config: {
539
+ numberOfImages: 1,
540
+ aspectRatio: (aspect_ratio || '16:9'),
541
+ },
542
+ });
543
+ const image = response.generatedImages?.[0];
544
+ if (!image?.image?.imageBytes) {
545
+ return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
546
+ }
547
+ // Save to ~/.openwriter/_images/
548
+ ensureDataDir();
549
+ const imagesDir = join(DATA_DIR, '_images');
550
+ if (!existsSync(imagesDir))
551
+ mkdirSync(imagesDir, { recursive: true });
552
+ const filename = `${randomUUID().slice(0, 8)}.png`;
553
+ const filePath = join(imagesDir, filename);
554
+ writeFileSync(filePath, Buffer.from(image.image.imageBytes, 'base64'));
555
+ const src = `/_images/${filename}`;
556
+ // Optionally set as article cover
557
+ if (set_cover) {
558
+ const meta = getMetadata();
559
+ const articleContext = meta.articleContext || {};
560
+ articleContext.coverImage = src;
561
+ setMetadata({ articleContext });
562
+ save();
563
+ broadcastMetadataChanged(getMetadata());
564
+ }
565
+ return {
566
+ content: [{
567
+ type: 'text',
568
+ text: JSON.stringify({ success: true, src, ...(set_cover ? { coverSet: true } : {}) }),
569
+ }],
570
+ };
571
+ },
572
+ },
495
573
  ];
496
- /** Register MCP tools from plugins. Call before startMcpServer(). */
574
+ /** Register MCP tools from plugins. Tools added after startMcpServer() won't be visible to existing MCP sessions. */
497
575
  export function registerPluginTools(tools) {
498
576
  for (const tool of tools) {
499
577
  TOOL_REGISTRY.push({
@@ -518,7 +596,7 @@ export function removePluginTools(names) {
518
596
  }
519
597
  export async function startMcpServer() {
520
598
  const server = new McpServer({
521
- name: 'open-writer',
599
+ name: 'openwriter',
522
600
  version: '0.2.0',
523
601
  });
524
602
  for (const tool of TOOL_REGISTRY) {
@@ -10,6 +10,7 @@ import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
10
10
  import { applyTextEditsToNode } from './text-edit.js';
11
11
  import { DATA_DIR, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc } from './helpers.js';
12
12
  import { snapshotIfNeeded, ensureDocId } from './versions.js';
13
+ import trash from 'trash';
13
14
  const DEFAULT_DOC = {
14
15
  type: 'doc',
15
16
  content: [{ type: 'paragraph', content: [] }],
@@ -143,6 +144,22 @@ export function setMetadata(updates) {
143
144
  state.metadata = { ...state.metadata, ...updates };
144
145
  if (updates.title)
145
146
  state.title = updates.title;
147
+ // Auto-tag: tweetContext / articleContext ↔ "x" tag
148
+ for (const key of ['tweetContext', 'articleContext']) {
149
+ if (key in updates) {
150
+ const filename = state.filePath
151
+ ? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
152
+ : '';
153
+ if (filename) {
154
+ if (updates[key]) {
155
+ addDocTag(filename, 'x');
156
+ }
157
+ else {
158
+ removeDocTag(filename, 'x');
159
+ }
160
+ }
161
+ }
162
+ }
146
163
  }
147
164
  export function getStatus() {
148
165
  return {
@@ -621,6 +638,8 @@ export function load() {
621
638
  migrateSwJsonFiles();
622
639
  // Clean up empty temp files from previous sessions
623
640
  cleanupEmptyTempFiles();
641
+ // Trash docs marked as ephemeral from previous sessions
642
+ cleanupEphemeralDocs();
624
643
  // Find most recently modified .md file
625
644
  const files = readdirSync(DATA_DIR)
626
645
  .filter((f) => f.endsWith('.md'))
@@ -630,33 +649,38 @@ export function load() {
630
649
  return { name: f, path: fullPath, mtime: stat.mtimeMs };
631
650
  })
632
651
  .sort((a, b) => b.mtime - a.mtime);
633
- if (files.length === 0) {
634
- // No existing docs start fresh with temp file
635
- state.filePath = tempFilePath();
636
- state.isTemp = true;
637
- return;
638
- }
639
- // Open the most recent file
640
- const latest = files[0];
641
- try {
642
- const raw = readFileSync(latest.path, 'utf-8');
643
- const parsed = markdownToTiptap(raw);
644
- state.document = parsed.document;
645
- state.title = parsed.title;
646
- state.metadata = parsed.metadata;
647
- state.lastModified = new Date(statSync(latest.path).mtimeMs);
648
- state.filePath = latest.path;
649
- state.isTemp = latest.name.startsWith(TEMP_PREFIX);
650
- // Lazy docId migration: assign if missing, save to persist
651
- const hadDocId = !!state.metadata.docId;
652
- state.docId = ensureDocId(state.metadata);
653
- if (!hadDocId) {
654
- const md = tiptapToMarkdown(state.document, state.title, state.metadata);
655
- writeFileSync(state.filePath, md, 'utf-8');
652
+ // Walk sorted files until we find a real document with content.
653
+ // Skip empty temp files so we don't open a blank scratch pad when real docs exist.
654
+ for (const file of files) {
655
+ try {
656
+ const raw = readFileSync(file.path, 'utf-8');
657
+ const parsed = markdownToTiptap(raw);
658
+ const isTemp = file.name.startsWith(TEMP_PREFIX);
659
+ // Skip empty temp files — prefer a real document
660
+ if (isTemp && isDocEmpty(parsed.document))
661
+ continue;
662
+ state.document = parsed.document;
663
+ state.title = parsed.title;
664
+ state.metadata = parsed.metadata;
665
+ state.lastModified = new Date(statSync(file.path).mtimeMs);
666
+ state.filePath = file.path;
667
+ state.isTemp = isTemp;
668
+ // Lazy docId migration: assign if missing, save to persist
669
+ const hadDocId = !!state.metadata.docId;
670
+ state.docId = ensureDocId(state.metadata);
671
+ if (!hadDocId) {
672
+ const md = tiptapToMarkdown(state.document, state.title, state.metadata);
673
+ writeFileSync(state.filePath, md, 'utf-8');
674
+ }
675
+ break;
676
+ }
677
+ catch {
678
+ // Corrupt file — try next one
679
+ continue;
656
680
  }
657
681
  }
658
- catch {
659
- // Corrupt file — start fresh
682
+ // If nothing loaded (all files were empty temps or corrupt), start fresh
683
+ if (!state.filePath) {
660
684
  state.filePath = tempFilePath();
661
685
  state.isTemp = true;
662
686
  }
@@ -766,6 +790,26 @@ function cleanupEmptyTempFiles() {
766
790
  }
767
791
  catch { /* ignore errors during cleanup */ }
768
792
  }
793
+ /** Delete docs marked as ephemeral from previous sessions */
794
+ function cleanupEphemeralDocs() {
795
+ try {
796
+ const wsRefs = getWorkspaceReferencedFiles();
797
+ const files = readdirSync(DATA_DIR).filter(f => f.endsWith('.md'));
798
+ for (const f of files) {
799
+ if (wsRefs.has(f))
800
+ continue; // protect workspace-referenced docs
801
+ try {
802
+ const raw = readFileSync(join(DATA_DIR, f), 'utf-8');
803
+ const { data } = matter(raw);
804
+ if (data.ephemeral) {
805
+ trash(join(DATA_DIR, f)).catch(() => { }); // move to OS trash, fire-and-forget
806
+ }
807
+ }
808
+ catch { /* skip unreadable */ }
809
+ }
810
+ }
811
+ catch { /* ignore */ }
812
+ }
769
813
  // ============================================================================
770
814
  // DOCUMENT-LEVEL TAG OPERATIONS
771
815
  // ============================================================================
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Tweet embed proxy: fetches tweet data from fxtwitter API.
3
+ * GET /api/tweet-embed?url=... → normalized TweetEmbedData JSON.
4
+ */
5
+ import { Router } from 'express';
6
+ // In-memory cache: URL → { data, expires }
7
+ const cache = new Map();
8
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
9
+ function parseTweetUrl(url) {
10
+ try {
11
+ const parsed = new URL(url);
12
+ if (!['twitter.com', 'x.com', 'www.twitter.com', 'www.x.com'].includes(parsed.hostname)) {
13
+ return null;
14
+ }
15
+ // Path: /{username}/status/{id}
16
+ const match = parsed.pathname.match(/^\/([^/]+)\/status\/(\d+)/);
17
+ if (!match)
18
+ return null;
19
+ return { username: match[1], statusId: match[2] };
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ function normalizeTweet(tweet) {
26
+ const data = {
27
+ author: {
28
+ name: tweet.author?.name || '',
29
+ username: tweet.author?.screen_name || '',
30
+ avatarUrl: tweet.author?.avatar_url || '',
31
+ },
32
+ text: tweet.text || '',
33
+ createdAt: tweet.created_at || '',
34
+ metrics: {
35
+ likes: tweet.likes ?? 0,
36
+ retweets: tweet.retweets ?? 0,
37
+ replies: tweet.replies ?? 0,
38
+ views: tweet.views ?? 0,
39
+ },
40
+ };
41
+ if (tweet.media?.all?.length) {
42
+ data.media = tweet.media.all.map((m) => ({
43
+ type: m.type || 'photo',
44
+ url: m.url || m.thumbnail_url || '',
45
+ }));
46
+ }
47
+ if (tweet.quote) {
48
+ data.quoteTweet = normalizeTweet(tweet.quote);
49
+ }
50
+ return data;
51
+ }
52
+ export function createTweetRouter() {
53
+ const router = Router();
54
+ router.get('/api/tweet-embed', async (req, res) => {
55
+ const url = req.query.url;
56
+ if (!url) {
57
+ res.status(400).json({ error: 'url query parameter is required' });
58
+ return;
59
+ }
60
+ const parsed = parseTweetUrl(url);
61
+ if (!parsed) {
62
+ res.status(400).json({ error: 'Invalid tweet URL. Supports x.com and twitter.com URLs.' });
63
+ return;
64
+ }
65
+ // Check cache
66
+ const cacheKey = `${parsed.username}/${parsed.statusId}`;
67
+ const cached = cache.get(cacheKey);
68
+ if (cached && cached.expires > Date.now()) {
69
+ res.json(cached.data);
70
+ return;
71
+ }
72
+ try {
73
+ const apiUrl = `https://api.fxtwitter.com/${parsed.username}/status/${parsed.statusId}`;
74
+ const response = await fetch(apiUrl);
75
+ if (!response.ok) {
76
+ if (response.status === 404) {
77
+ res.status(404).json({ error: 'Tweet not found' });
78
+ return;
79
+ }
80
+ res.status(502).json({ error: `fxtwitter API returned ${response.status}` });
81
+ return;
82
+ }
83
+ const json = await response.json();
84
+ if (!json.tweet) {
85
+ res.status(404).json({ error: 'Tweet not found in API response' });
86
+ return;
87
+ }
88
+ const data = normalizeTweet(json.tweet);
89
+ // Cache it
90
+ cache.set(cacheKey, { data, expires: Date.now() + CACHE_TTL_MS });
91
+ res.json(data);
92
+ }
93
+ catch (err) {
94
+ res.status(502).json({ error: `Failed to fetch tweet: ${err.message}` });
95
+ }
96
+ });
97
+ return router;
98
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Built-in update check — zero dependencies.
3
+ * Uses Node's built-in fetch + existing config system.
4
+ * Fire-and-forget: never blocks startup, never throws to caller.
5
+ */
6
+ import { readFileSync } from 'fs';
7
+ import { join, dirname } from 'path';
8
+ import { fileURLToPath } from 'url';
9
+ import { readConfig, saveConfig } from './helpers.js';
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
13
+ const FETCH_TIMEOUT_MS = 5000;
14
+ let cachedLatestVersion = null;
15
+ /** Compare two semver strings numerically. Returns -1, 0, or 1. */
16
+ export function compareVersions(a, b) {
17
+ const partsA = a.split('.').map((s) => parseInt(s, 10));
18
+ const partsB = b.split('.').map((s) => parseInt(s, 10));
19
+ const len = Math.max(partsA.length, partsB.length);
20
+ for (let i = 0; i < len; i++) {
21
+ const numA = partsA[i] || 0;
22
+ const numB = partsB[i] || 0;
23
+ if (numA < numB)
24
+ return -1;
25
+ if (numA > numB)
26
+ return 1;
27
+ }
28
+ return 0;
29
+ }
30
+ /** Read current package version from package.json on disk. */
31
+ function getCurrentVersion() {
32
+ try {
33
+ const pkgPath = join(__dirname, '../../package.json');
34
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
35
+ return pkg.version || '0.0.0';
36
+ }
37
+ catch {
38
+ return '0.0.0';
39
+ }
40
+ }
41
+ /**
42
+ * Check npm registry for a newer version. Fire-and-forget.
43
+ * - Respects NO_UPDATE_NOTIFIER env var
44
+ * - Caches result for 24h in config
45
+ * - Logs to stderr if update available
46
+ */
47
+ export async function checkForUpdate() {
48
+ if (process.env.NO_UPDATE_NOTIFIER)
49
+ return;
50
+ const config = readConfig();
51
+ const now = Date.now();
52
+ const currentVersion = getCurrentVersion();
53
+ // Use cached result if checked within 24h
54
+ if (config.lastUpdateCheck && config.latestVersion) {
55
+ const lastCheck = new Date(config.lastUpdateCheck).getTime();
56
+ if (now - lastCheck < CHECK_INTERVAL_MS) {
57
+ if (compareVersions(currentVersion, config.latestVersion) < 0) {
58
+ cachedLatestVersion = config.latestVersion;
59
+ console.error(`[OpenWriter] Update available: ${currentVersion} → ${config.latestVersion} — run: npm update -g openwriter`);
60
+ }
61
+ return;
62
+ }
63
+ }
64
+ // Fetch latest version from npm registry
65
+ const controller = new AbortController();
66
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
67
+ try {
68
+ const res = await fetch('https://registry.npmjs.org/openwriter/latest', {
69
+ signal: controller.signal,
70
+ });
71
+ clearTimeout(timeout);
72
+ if (!res.ok)
73
+ return;
74
+ const data = (await res.json());
75
+ const latestVersion = data.version;
76
+ if (!latestVersion)
77
+ return;
78
+ // Save to config for 24h cache
79
+ saveConfig({
80
+ lastUpdateCheck: new Date().toISOString(),
81
+ latestVersion,
82
+ });
83
+ if (compareVersions(currentVersion, latestVersion) < 0) {
84
+ cachedLatestVersion = latestVersion;
85
+ console.error(`[OpenWriter] Update available: ${currentVersion} → ${latestVersion} — run: npm update -g openwriter`);
86
+ }
87
+ }
88
+ catch {
89
+ // Network error, timeout, abort — silently ignore
90
+ clearTimeout(timeout);
91
+ }
92
+ }
93
+ /** Sync getter: returns latest version string if update available, null otherwise. */
94
+ export function getUpdateInfo() {
95
+ return cachedLatestVersion;
96
+ }
package/dist/server/ws.js CHANGED
@@ -49,6 +49,7 @@ export function setupWebSocket(server) {
49
49
  title: getTitle(),
50
50
  filename,
51
51
  docId: getDocId(),
52
+ metadata: getMetadata(),
52
53
  }));
53
54
  // Send pending docs info on connect
54
55
  ws.send(JSON.stringify({
@@ -85,6 +86,7 @@ export function setupWebSocket(server) {
85
86
  title: getTitle(),
86
87
  filename,
87
88
  docId: getDocId(),
89
+ metadata: getMetadata(),
88
90
  }));
89
91
  }
90
92
  if (msg.type === 'title-update' && msg.title) {
@@ -112,6 +114,33 @@ export function setupWebSocket(server) {
112
114
  console.error('[WS] Create document failed:', err.message);
113
115
  }
114
116
  }
117
+ if (msg.type === 'create-template' && msg.template) {
118
+ try {
119
+ const tmpl = msg.template;
120
+ const url = msg.url;
121
+ // Create with no title → temp file path (avoids naming conflicts)
122
+ const result = createDocument();
123
+ // Set template-appropriate metadata
124
+ if (tmpl === 'tweet') {
125
+ setMetadata({ tweetContext: { mode: 'tweet' }, title: 'Tweet' });
126
+ }
127
+ else if (tmpl === 'reply') {
128
+ setMetadata({ tweetContext: { url, mode: 'reply' }, title: 'Reply' });
129
+ }
130
+ else if (tmpl === 'quote') {
131
+ setMetadata({ tweetContext: { url, mode: 'quote' }, title: 'Quote Tweet' });
132
+ }
133
+ else if (tmpl === 'article') {
134
+ setMetadata({ articleContext: { active: true }, title: 'Article' });
135
+ }
136
+ save();
137
+ broadcastDocumentSwitched(result.document, getTitle(), result.filename, getMetadata());
138
+ broadcastDocumentsChanged();
139
+ }
140
+ catch (err) {
141
+ console.error('[WS] Create template failed:', err.message);
142
+ }
143
+ }
115
144
  if (msg.type === 'pending-resolved' && msg.filename) {
116
145
  const action = msg.action; // 'accept' or 'reject'
117
146
  const resolvedFilename = msg.filename;
@@ -168,14 +197,21 @@ export function setupWebSocket(server) {
168
197
  });
169
198
  });
170
199
  }
171
- export function broadcastDocumentSwitched(document, title, filename) {
172
- const msg = JSON.stringify({ type: 'document-switched', document, title, filename, docId: getDocId() });
200
+ export function broadcastDocumentSwitched(document, title, filename, metadata) {
201
+ const msg = JSON.stringify({ type: 'document-switched', document, title, filename, docId: getDocId(), metadata: metadata ?? getMetadata() });
173
202
  for (const ws of clients) {
174
203
  if (ws.readyState === WebSocket.OPEN) {
175
204
  ws.send(msg);
176
205
  }
177
206
  }
178
207
  }
208
+ export function broadcastMetadataChanged(metadata) {
209
+ const msg = JSON.stringify({ type: 'metadata-changed', metadata });
210
+ for (const ws of clients) {
211
+ if (ws.readyState === WebSocket.OPEN)
212
+ ws.send(msg);
213
+ }
214
+ }
179
215
  export function broadcastDocumentsChanged() {
180
216
  const msg = JSON.stringify({ type: 'documents-changed' });
181
217
  for (const ws of clients) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.2.2",
3
+ "version": "0.3.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",
@@ -33,6 +33,7 @@
33
33
  "lint": "eslint src server bin --ext .ts,.tsx"
34
34
  },
35
35
  "dependencies": {
36
+ "@google/genai": "^1.42.0",
36
37
  "@modelcontextprotocol/sdk": "^1.12.1",
37
38
  "@tiptap/core": "^3.0.0",
38
39
  "@tiptap/extension-code-block-lowlight": "^3.19.0",
package/skill/SKILL.md CHANGED
@@ -23,7 +23,7 @@ You are a writing collaborator. You read documents and make edits **exclusively
23
23
 
24
24
  ## Setup — Which Path?
25
25
 
26
- Check whether the `open-writer` MCP tools are available (e.g. `read_pad`, `write_to_pad`). This determines setup state:
26
+ Check whether the `openwriter` MCP tools are available (e.g. `read_pad`, `write_to_pad`). This determines setup state:
27
27
 
28
28
  ### MCP tools ARE available (ready to use)
29
29
 
@@ -38,11 +38,14 @@ Skip to [Writing Strategy](#writing-strategy) below.
38
38
 
39
39
  The user installed this skill from a directory but hasn't set up the MCP server yet. OpenWriter needs an MCP server to provide the 24 editing tools.
40
40
 
41
- **Step 1:** Tell the user to install the npm package and MCP server:
41
+ **Step 1:** Tell the user to install globally and add the MCP server:
42
42
 
43
43
  ```bash
44
+ # Install globally for instant startup (no npx resolution delay)
45
+ npm install -g openwriter
46
+
44
47
  # Add the OpenWriter MCP server to Claude Code
45
- claude mcp add -s user open-writer -- npx openwriter --no-open
48
+ claude mcp add -s user openwriter -- openwriter --no-open
46
49
  ```
47
50
 
48
51
  Then restart the Claude Code session. The MCP tools become available on next launch.
@@ -50,9 +53,9 @@ Then restart the Claude Code session. The MCP tools become available on next lau
50
53
  **Step 2 (if the user can't run the command above):** Edit `~/.claude.json` directly. Add to the `mcpServers` object:
51
54
 
52
55
  ```json
53
- "open-writer": {
54
- "command": "npx",
55
- "args": ["openwriter", "--no-open"]
56
+ "openwriter": {
57
+ "command": "openwriter",
58
+ "args": ["--no-open"]
56
59
  }
57
60
  ```
58
61
 
@@ -61,9 +64,9 @@ The `mcpServers` key is at the top level of `~/.claude.json`. If it doesn't exis
61
64
  ```json
62
65
  {
63
66
  "mcpServers": {
64
- "open-writer": {
65
- "command": "npx",
66
- "args": ["openwriter", "--no-open"]
67
+ "openwriter": {
68
+ "command": "openwriter",
69
+ "args": ["--no-open"]
67
70
  }
68
71
  }
69
72
  }
@@ -254,4 +257,4 @@ When importing or organizing book-length projects, read the source material firs
254
257
 
255
258
  **"pendingChanges" never clears** — User needs to accept/reject changes in the browser at http://localhost:5050.
256
259
 
257
- **Server not starting** — Ensure `npx openwriter` works from your terminal. If on Windows and using `npx`, the MCP config may need `"command": "cmd"` with `"args": ["/c", "npx", "openwriter", "--no-open"]`.
260
+ **Server not starting** — Ensure `openwriter` works from your terminal (`npm install -g openwriter` first). If on Windows and the global command isn't found, the MCP config may need `"command": "cmd"` with `"args": ["/c", "openwriter", "--no-open"]`.